@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,62 @@
1
+ package dockerstats
2
+
3
+ import (
4
+ "context"
5
+ "net"
6
+ "os"
7
+ "syscall"
8
+ "time"
9
+ )
10
+
11
+ const (
12
+ errorPipeBusy = 231
13
+ fileFlagOverlapped = 0x40000000
14
+ securitySQOSPresent = 0x00100000
15
+ securityIdentifyOnly = 0x00010000
16
+ )
17
+
18
+ // dialPipe opens a named pipe as a net.Conn.
19
+ //
20
+ // The handle is opened for overlapped I/O, which os.NewFile (Go 1.25+) hands
21
+ // to the runtime poller: reads and writes park the goroutine instead of a
22
+ // thread, deadlines work, and closing the file cancels a pending read. That is
23
+ // everything an http.Transport needs, without a third-party pipe library.
24
+ //
25
+ // The security flags stop the daemon from impersonating this process with
26
+ // more than an identification-level token.
27
+ func dialPipe(ctx context.Context, path string) (net.Conn, error) {
28
+ name, err := syscall.UTF16PtrFromString(path)
29
+ if err != nil {
30
+ return nil, err
31
+ }
32
+ for {
33
+ h, err := syscall.CreateFile(name, syscall.GENERIC_READ|syscall.GENERIC_WRITE, 0, nil,
34
+ syscall.OPEN_EXISTING, fileFlagOverlapped|securitySQOSPresent|securityIdentifyOnly, 0)
35
+ if err == nil {
36
+ return &pipeConn{File: os.NewFile(uintptr(h), path), addr: pipeAddr(path)}, nil
37
+ }
38
+ if err != syscall.Errno(errorPipeBusy) {
39
+ return nil, &net.OpError{Op: "dial", Net: "npipe", Addr: pipeAddr(path), Err: err}
40
+ }
41
+ // Every instance of the pipe is serving another client; one frees up
42
+ // within milliseconds.
43
+ select {
44
+ case <-ctx.Done():
45
+ return nil, ctx.Err()
46
+ case <-time.After(10 * time.Millisecond):
47
+ }
48
+ }
49
+ }
50
+
51
+ type pipeAddr string
52
+
53
+ func (a pipeAddr) Network() string { return "npipe" }
54
+ func (a pipeAddr) String() string { return string(a) }
55
+
56
+ type pipeConn struct {
57
+ *os.File
58
+ addr pipeAddr
59
+ }
60
+
61
+ func (c *pipeConn) LocalAddr() net.Addr { return c.addr }
62
+ func (c *pipeConn) RemoteAddr() net.Addr { return c.addr }
@@ -0,0 +1,626 @@
1
+ // Package dockerstats talks to the Docker Engine API over its unix socket, or
2
+ // the named pipe Docker Desktop uses on Windows.
3
+ //
4
+ // It uses `GET /containers/{id}/stats?stream=false&one-shot=true`. The one-shot
5
+ // form returns immediately; without it the daemon blocks each request for a
6
+ // full collection cycle to produce `precpu_stats`, which measured 8.3s per tick
7
+ // across 31 containers versus 0.07s here.
8
+ //
9
+ // The tradeoff is that one-shot zeroes `precpu_stats`, so CPU% is computed
10
+ // against the previous tick's counters instead — the same thing the daemon
11
+ // would have done, just over the poll interval rather than a 1s window. That is
12
+ // also a steadier number to read at a glance.
13
+ package dockerstats
14
+
15
+ import (
16
+ "context"
17
+ "encoding/json"
18
+ "errors"
19
+ "fmt"
20
+ "math"
21
+ "net"
22
+ "net/http"
23
+ "sort"
24
+ "sync"
25
+ "time"
26
+
27
+ "github.com/rene-roid/kanshi/internal/config"
28
+ )
29
+
30
+ // Pinned rather than negotiated: every field Kanshi reads has been stable
31
+ // since 1.43, and asking for a version the daemon predates is a hard error.
32
+ const apiVersion = "v1.43"
33
+
34
+ // When the daemon is not there at all — no socket, no pipe, connection
35
+ // refused — it is only looked for again after this long. A laptop without
36
+ // Docker should not pay for a failed connect every few seconds.
37
+ const retryAbsent = time.Minute
38
+
39
+ // Client holds the transport and the previous tick's counters. Every rate
40
+ // here is a delta, so the client has to outlive a single sample.
41
+ type Client struct {
42
+ cfg config.Config
43
+ http *http.Client
44
+
45
+ mu sync.Mutex
46
+ prevCPU map[string]cpuCounters
47
+ prevNet map[string]netCounters
48
+ // absentUntil and absent hold the "no daemon" answer between retries.
49
+ absentUntil time.Time
50
+ absent Result
51
+ }
52
+
53
+ type cpuCounters struct{ total, system uint64 }
54
+ type netCounters struct {
55
+ at time.Time
56
+ rx, tx uint64
57
+ }
58
+
59
+ func New(cfg config.Config) *Client {
60
+ dial, err := dialer(cfg.DockerHost)
61
+ if err != nil {
62
+ dial = func(context.Context) (net.Conn, error) { return nil, &net.OpError{Op: "dial", Err: err} }
63
+ }
64
+ return &Client{
65
+ cfg: cfg,
66
+ http: &http.Client{
67
+ Timeout: 20 * time.Second,
68
+ Transport: &http.Transport{
69
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
70
+ return dial(ctx)
71
+ },
72
+ // One idle connection per in-flight request, so a tick reuses
73
+ // the sockets the previous tick opened instead of paying a
74
+ // connect per container.
75
+ MaxIdleConns: cfg.DockerConcurrency,
76
+ MaxIdleConnsPerHost: cfg.DockerConcurrency,
77
+ IdleConnTimeout: 90 * time.Second,
78
+ },
79
+ },
80
+ prevCPU: make(map[string]cpuCounters),
81
+ prevNet: make(map[string]netCounters),
82
+ }
83
+ }
84
+
85
+ // Close releases the idle sockets so a shutdown does not leave the daemon
86
+ // holding connections.
87
+ func (c *Client) Close() { c.http.CloseIdleConnections() }
88
+
89
+ func (c *Client) get(ctx context.Context, path string, out any) error {
90
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/"+apiVersion+path, nil)
91
+ if err != nil {
92
+ return err
93
+ }
94
+ resp, err := c.http.Do(req)
95
+ if err != nil {
96
+ return err
97
+ }
98
+ defer resp.Body.Close()
99
+ if resp.StatusCode >= 400 {
100
+ return fmt.Errorf("HTTPStatusError: %s", resp.Status)
101
+ }
102
+ return json.NewDecoder(resp.Body).Decode(out)
103
+ }
104
+
105
+ /* ── engine payloads ────────────────────────────────────────────────────── */
106
+
107
+ type containerMeta struct {
108
+ ID string `json:"Id"`
109
+ Names []string `json:"Names"`
110
+ Image string `json:"Image"`
111
+ State string `json:"State"`
112
+ Status string `json:"Status"`
113
+ Created int64 `json:"Created"`
114
+ Labels map[string]string `json:"Labels"`
115
+ Ports []portMeta `json:"Ports"`
116
+ }
117
+
118
+ // portMeta is one entry of the engine's Ports array. PublicPort is absent for
119
+ // a port that is merely EXPOSEd rather than published to the host.
120
+ type portMeta struct {
121
+ IP string `json:"IP"`
122
+ PrivatePort int `json:"PrivatePort"`
123
+ PublicPort int `json:"PublicPort"`
124
+ Type string `json:"Type"`
125
+ }
126
+
127
+ // Pointers on total_usage and system_cpu_usage so a missing field is
128
+ // distinguishable from a genuine zero — a container that has used no CPU at
129
+ // all still reports 0, and that is a real reading.
130
+ type statsJSON struct {
131
+ CPUStats struct {
132
+ CPUUsage struct {
133
+ TotalUsage *uint64 `json:"total_usage"`
134
+ PercpuUsage []uint64 `json:"percpu_usage"`
135
+ } `json:"cpu_usage"`
136
+ SystemUsage *uint64 `json:"system_cpu_usage"`
137
+ OnlineCPUs int `json:"online_cpus"`
138
+ } `json:"cpu_stats"`
139
+ MemoryStats struct {
140
+ Usage *uint64 `json:"usage"`
141
+ Limit uint64 `json:"limit"`
142
+ Stats map[string]uint64 `json:"stats"`
143
+ } `json:"memory_stats"`
144
+ Networks map[string]struct {
145
+ RxBytes uint64 `json:"rx_bytes"`
146
+ TxBytes uint64 `json:"tx_bytes"`
147
+ } `json:"networks"`
148
+ PidsStats struct {
149
+ Current int `json:"current"`
150
+ } `json:"pids_stats"`
151
+ BlkioStats struct {
152
+ IoServiceBytesRecursive []struct {
153
+ Op string `json:"op"`
154
+ Value uint64 `json:"value"`
155
+ } `json:"io_service_bytes_recursive"`
156
+ } `json:"blkio_stats"`
157
+ }
158
+
159
+ /* ── wire payload ───────────────────────────────────────────────────────── */
160
+
161
+ // Result is the JSON the browser consumes; field names are part of the wire
162
+ // contract with web/app.js. Unavailable means no daemon could be reached at
163
+ // all, which the page shows as "Docker not detected" rather than an error.
164
+ type Result struct {
165
+ Containers []Container `json:"containers"`
166
+ Running int `json:"running"`
167
+ Total int `json:"total"`
168
+ Error string `json:"error,omitempty"`
169
+ Unavailable bool `json:"unavailable,omitempty"`
170
+ }
171
+
172
+ type Container struct {
173
+ ID string `json:"id"`
174
+ Name string `json:"name"`
175
+ FullName string `json:"full_name"`
176
+ Project string `json:"project"`
177
+ Image string `json:"image"`
178
+ State string `json:"state"`
179
+ Status string `json:"status"`
180
+ Health *string `json:"health"`
181
+ Created int64 `json:"created"`
182
+ CPU float64 `json:"cpu"`
183
+ MemUsed uint64 `json:"mem_used"`
184
+ MemLimit uint64 `json:"mem_limit"`
185
+ MemPercent float64 `json:"mem_percent"`
186
+ PIDs int `json:"pids"`
187
+ Net *Net `json:"net"`
188
+ Blkio *Blkio `json:"blkio"`
189
+ Ports []Port `json:"ports"`
190
+ }
191
+
192
+ // Port is one published or exposed mapping. Public is 0 when the port is only
193
+ // exposed inside Docker's own networks, which the UI renders without a link
194
+ // because there is no host address to send the browser to.
195
+ type Port struct {
196
+ Public int `json:"public"`
197
+ Private int `json:"private"`
198
+ Type string `json:"type"`
199
+ IP string `json:"ip,omitempty"`
200
+ }
201
+
202
+ type Net struct {
203
+ RX uint64 `json:"rx"`
204
+ TX uint64 `json:"tx"`
205
+ RXRate float64 `json:"rx_rate"`
206
+ TXRate float64 `json:"tx_rate"`
207
+ }
208
+
209
+ type Blkio struct {
210
+ Read uint64 `json:"read"`
211
+ Write uint64 `json:"write"`
212
+ }
213
+
214
+ /* ── derived figures ────────────────────────────────────────────────────── */
215
+
216
+ // cpuPercent measures against the previous tick. 100% = one full core, the
217
+ // same scale `docker stats` prints.
218
+ func (c *Client) cpuPercent(id string, s *statsJSON) float64 {
219
+ usage, system := s.CPUStats.CPUUsage.TotalUsage, s.CPUStats.SystemUsage
220
+ if usage == nil || system == nil {
221
+ return 0
222
+ }
223
+
224
+ c.mu.Lock()
225
+ prev, seen := c.prevCPU[id]
226
+ c.prevCPU[id] = cpuCounters{total: *usage, system: *system}
227
+ c.mu.Unlock()
228
+ if !seen {
229
+ return 0 // first sighting; the next tick has a real delta
230
+ }
231
+
232
+ // A restarted container resets its counters — report 0 rather than a
233
+ // nonsensical negative or a huge spike.
234
+ if *usage < prev.total || *system <= prev.system {
235
+ return 0
236
+ }
237
+ cpuDelta := float64(*usage - prev.total)
238
+ sysDelta := float64(*system - prev.system)
239
+
240
+ // online_cpus is absent on older daemons; fall back to the per-cpu array.
241
+ ncpu := s.CPUStats.OnlineCPUs
242
+ if ncpu == 0 {
243
+ ncpu = len(s.CPUStats.CPUUsage.PercpuUsage)
244
+ }
245
+ if ncpu == 0 {
246
+ ncpu = 1
247
+ }
248
+ pct := math.Min(cpuDelta/sysDelta*float64(ncpu)*100, float64(ncpu)*100)
249
+ return math.Round(pct*100) / 100
250
+ }
251
+
252
+ func memory(s *statsJSON) (used, limit uint64) {
253
+ if s.MemoryStats.Usage == nil {
254
+ return 0, 0
255
+ }
256
+ used = *s.MemoryStats.Usage
257
+ // Match `docker stats`: subtract page cache so the number reflects the
258
+ // working set. cgroup v2 exposes inactive_file, v1 exposes cache.
259
+ if v, ok := s.MemoryStats.Stats["inactive_file"]; ok {
260
+ used -= min(v, used)
261
+ } else if v, ok := s.MemoryStats.Stats["cache"]; ok {
262
+ used -= min(v, used)
263
+ }
264
+ return used, s.MemoryStats.Limit
265
+ }
266
+
267
+ func (c *Client) network(id string, s *statsJSON, now time.Time) *Net {
268
+ if len(s.Networks) == 0 {
269
+ // Containers on `network_mode: service:...` (e.g. behind gluetun)
270
+ // report no interfaces of their own — their traffic shows up on the
271
+ // provider instead.
272
+ c.mu.Lock()
273
+ delete(c.prevNet, id)
274
+ c.mu.Unlock()
275
+ return nil
276
+ }
277
+ var rx, tx uint64
278
+ for _, n := range s.Networks {
279
+ rx += n.RxBytes
280
+ tx += n.TxBytes
281
+ }
282
+
283
+ c.mu.Lock()
284
+ prev, seen := c.prevNet[id]
285
+ c.prevNet[id] = netCounters{at: now, rx: rx, tx: tx}
286
+ c.mu.Unlock()
287
+
288
+ out := &Net{RX: rx, TX: tx}
289
+ if seen && now.After(prev.at) {
290
+ dt := now.Sub(prev.at).Seconds()
291
+ // A restarted container resets its counters; clamp instead of going
292
+ // negative.
293
+ if rx > prev.rx {
294
+ out.RXRate = float64(rx-prev.rx) / dt
295
+ }
296
+ if tx > prev.tx {
297
+ out.TXRate = float64(tx-prev.tx) / dt
298
+ }
299
+ }
300
+ return out
301
+ }
302
+
303
+ func blockIO(s *statsJSON) *Blkio {
304
+ entries := s.BlkioStats.IoServiceBytesRecursive
305
+ if len(entries) == 0 {
306
+ return nil // commonly empty under cgroup v2
307
+ }
308
+ var out Blkio
309
+ for _, e := range entries {
310
+ switch {
311
+ case equalFold(e.Op, "read"):
312
+ out.Read += e.Value
313
+ case equalFold(e.Op, "write"):
314
+ out.Write += e.Value
315
+ }
316
+ }
317
+ return &out
318
+ }
319
+
320
+ // identify returns (full name, display name, project).
321
+ //
322
+ // Runtipi names containers `<project>-<service>-1`, so at phone width three
323
+ // Immich containers all truncate to the same "immich_migra…". The compose
324
+ // labels carry the service name on its own, which is what actually
325
+ // distinguishes them.
326
+ func identify(meta containerMeta) (full, display, project string) {
327
+ full = "?"
328
+ if len(meta.Names) > 0 {
329
+ full = trimLeadingSlash(meta.Names[0])
330
+ }
331
+ service := meta.Labels["com.docker.compose.service"]
332
+ project = meta.Labels["com.docker.compose.project"]
333
+ display = service
334
+ if display == "" {
335
+ display = full
336
+ }
337
+ return full, display, project
338
+ }
339
+
340
+ // knownHealth is the set the UI styles. Anything else the daemon writes in
341
+ // parentheses (a port mapping, a custom status) is not a health state and must
342
+ // not be shown as one.
343
+ var knownHealth = map[string]bool{
344
+ "healthy": true, "unhealthy": true, "health: starting": true, "starting": true,
345
+ }
346
+
347
+ // health digs the state out of the human-readable Status string, which is the
348
+ // only place /containers/json exposes it.
349
+ func health(status string) *string {
350
+ open := lastIndexByte(status, '(')
351
+ if open < 0 {
352
+ return nil
353
+ }
354
+ inner := trimTrailingParen(status[open+1:])
355
+ if !knownHealth[inner] {
356
+ return nil
357
+ }
358
+ return &inner
359
+ }
360
+
361
+ // ports normalises the engine's Ports array for the UI.
362
+ //
363
+ // A dual-stack publish shows up twice — once on 0.0.0.0 and once on :: — for
364
+ // what is one mapping as far as anybody reading the dashboard is concerned, so
365
+ // entries are collapsed on (public, private, proto). The surviving IP is kept
366
+ // only when every binding for that mapping is to a specific address: a
367
+ // wildcard bind tells the browser nothing it does not already know, whereas a
368
+ // 127.0.0.1-only publish is worth showing because the link will not work from
369
+ // another machine.
370
+ func ports(meta containerMeta) []Port {
371
+ if len(meta.Ports) == 0 {
372
+ return nil
373
+ }
374
+ type key struct {
375
+ public, private int
376
+ proto string
377
+ }
378
+ seen := make(map[key]int, len(meta.Ports))
379
+ out := make([]Port, 0, len(meta.Ports))
380
+ for _, p := range meta.Ports {
381
+ if p.PrivatePort == 0 {
382
+ continue
383
+ }
384
+ proto := p.Type
385
+ if proto == "" {
386
+ proto = "tcp"
387
+ }
388
+ k := key{p.PublicPort, p.PrivatePort, proto}
389
+ if i, ok := seen[k]; ok {
390
+ if isWildcard(p.IP) {
391
+ out[i].IP = ""
392
+ }
393
+ continue
394
+ }
395
+ seen[k] = len(out)
396
+ ip := p.IP
397
+ if isWildcard(ip) {
398
+ ip = ""
399
+ }
400
+ out = append(out, Port{Public: p.PublicPort, Private: p.PrivatePort, Type: proto, IP: ip})
401
+ }
402
+
403
+ // Published ports first — those are the ones you can actually click — then
404
+ // ascending, so the order is stable across ticks regardless of how the
405
+ // daemon happened to list them.
406
+ sort.Slice(out, func(i, j int) bool {
407
+ a, b := out[i], out[j]
408
+ if (a.Public == 0) != (b.Public == 0) {
409
+ return b.Public == 0
410
+ }
411
+ if a.Public != b.Public {
412
+ return a.Public < b.Public
413
+ }
414
+ if a.Private != b.Private {
415
+ return a.Private < b.Private
416
+ }
417
+ return a.Type < b.Type
418
+ })
419
+ return out
420
+ }
421
+
422
+ // isWildcard reports whether the daemon bound every address rather than a
423
+ // chosen one. An empty IP means the same thing on an exposed-only port.
424
+ func isWildcard(ip string) bool {
425
+ return ip == "" || ip == "0.0.0.0" || ip == "::" || ip == "[::]"
426
+ }
427
+
428
+ /* ── sampling ───────────────────────────────────────────────────────────── */
429
+
430
+ func (c *Client) one(ctx context.Context, meta containerMeta) *Container {
431
+ var s statsJSON
432
+ if err := c.get(ctx, "/containers/"+meta.ID+"/stats?stream=false&one-shot=true", &s); err != nil {
433
+ return nil
434
+ }
435
+ used, limit := memory(&s)
436
+ full, name, project := identify(meta)
437
+
438
+ out := &Container{
439
+ ID: shortID(meta.ID),
440
+ Name: name,
441
+ FullName: full,
442
+ Project: project,
443
+ Image: meta.Image,
444
+ State: meta.State,
445
+ Status: meta.Status,
446
+ Health: health(meta.Status),
447
+ Created: meta.Created,
448
+ CPU: c.cpuPercent(meta.ID, &s),
449
+ MemUsed: used,
450
+ MemLimit: limit,
451
+ PIDs: s.PidsStats.Current,
452
+ Net: c.network(meta.ID, &s, time.Now()),
453
+ Blkio: blockIO(&s),
454
+ Ports: ports(meta),
455
+ }
456
+ if limit > 0 {
457
+ out.MemPercent = math.Round(float64(used)/float64(limit)*100*100) / 100
458
+ }
459
+ return out
460
+ }
461
+
462
+ // Sample runs one full pass: list containers, then fetch stats for the running
463
+ // ones concurrently.
464
+ func (c *Client) Sample(ctx context.Context) Result {
465
+ c.mu.Lock()
466
+ if time.Now().Before(c.absentUntil) {
467
+ r := c.absent
468
+ c.mu.Unlock()
469
+ return r
470
+ }
471
+ c.mu.Unlock()
472
+
473
+ var listing []containerMeta
474
+ if err := c.get(ctx, "/containers/json?all=true", &listing); err != nil {
475
+ r := Result{Error: errString(err), Containers: []Container{}}
476
+ if absent(err) {
477
+ r.Unavailable = true
478
+ c.mu.Lock()
479
+ c.absent, c.absentUntil = r, time.Now().Add(retryAbsent)
480
+ c.mu.Unlock()
481
+ }
482
+ return r
483
+ }
484
+
485
+ var running []containerMeta
486
+ for _, meta := range listing {
487
+ if meta.State == "running" {
488
+ running = append(running, meta)
489
+ }
490
+ }
491
+
492
+ results := make([]*Container, len(running))
493
+ sem := make(chan struct{}, max(1, c.cfg.DockerConcurrency))
494
+ var wg sync.WaitGroup
495
+ for i, meta := range running {
496
+ wg.Add(1)
497
+ go func(i int, meta containerMeta) {
498
+ defer wg.Done()
499
+ sem <- struct{}{}
500
+ defer func() { <-sem }()
501
+ results[i] = c.one(ctx, meta)
502
+ }(i, meta)
503
+ }
504
+ wg.Wait()
505
+
506
+ containers := make([]Container, 0, len(listing))
507
+ for _, r := range results {
508
+ if r != nil {
509
+ containers = append(containers, *r)
510
+ }
511
+ }
512
+
513
+ // Keep stopped containers visible but without stats, so a crashed service
514
+ // is obvious at a glance rather than silently missing from the list.
515
+ for _, meta := range listing {
516
+ if meta.State == "running" {
517
+ continue
518
+ }
519
+ full, name, project := identify(meta)
520
+ containers = append(containers, Container{
521
+ ID: shortID(meta.ID), Name: name, FullName: full, Project: project,
522
+ Image: meta.Image, State: meta.State, Status: meta.Status,
523
+ Created: meta.Created, Ports: ports(meta),
524
+ })
525
+ }
526
+
527
+ c.forgetStopped(running)
528
+
529
+ sort.SliceStable(containers, func(i, j int) bool {
530
+ a, b := containers[i], containers[j]
531
+ if (a.State == "running") != (b.State == "running") {
532
+ return a.State == "running"
533
+ }
534
+ if a.CPU != b.CPU {
535
+ return a.CPU > b.CPU
536
+ }
537
+ return a.Name < b.Name
538
+ })
539
+
540
+ return Result{Containers: containers, Running: len(running), Total: len(listing)}
541
+ }
542
+
543
+ // forgetStopped drops baselines for containers that are gone, so the maps do
544
+ // not grow without bound on a host that churns through short-lived jobs.
545
+ func (c *Client) forgetStopped(running []containerMeta) {
546
+ live := make(map[string]bool, len(running))
547
+ for _, meta := range running {
548
+ live[meta.ID] = true
549
+ }
550
+ c.mu.Lock()
551
+ defer c.mu.Unlock()
552
+ for id := range c.prevCPU {
553
+ if !live[id] {
554
+ delete(c.prevCPU, id)
555
+ }
556
+ }
557
+ for id := range c.prevNet {
558
+ if !live[id] {
559
+ delete(c.prevNet, id)
560
+ }
561
+ }
562
+ }
563
+
564
+ /* ── small helpers ──────────────────────────────────────────────────────── */
565
+
566
+ func shortID(id string) string {
567
+ if len(id) > 12 {
568
+ return id[:12]
569
+ }
570
+ return id
571
+ }
572
+
573
+ func trimLeadingSlash(s string) string {
574
+ for len(s) > 0 && s[0] == '/' {
575
+ s = s[1:]
576
+ }
577
+ return s
578
+ }
579
+
580
+ func trimTrailingParen(s string) string {
581
+ for len(s) > 0 && s[len(s)-1] == ')' {
582
+ s = s[:len(s)-1]
583
+ }
584
+ return s
585
+ }
586
+
587
+ func lastIndexByte(s string, b byte) int {
588
+ for i := len(s) - 1; i >= 0; i-- {
589
+ if s[i] == b {
590
+ return i
591
+ }
592
+ }
593
+ return -1
594
+ }
595
+
596
+ func equalFold(a, b string) bool {
597
+ if len(a) != len(b) {
598
+ return false
599
+ }
600
+ for i := 0; i < len(a); i++ {
601
+ ca, cb := a[i], b[i]
602
+ if 'A' <= ca && ca <= 'Z' {
603
+ ca += 'a' - 'A'
604
+ }
605
+ if 'A' <= cb && cb <= 'Z' {
606
+ cb += 'a' - 'A'
607
+ }
608
+ if ca != cb {
609
+ return false
610
+ }
611
+ }
612
+ return true
613
+ }
614
+
615
+ // absent reports a failure to reach any daemon at all, as opposed to one that
616
+ // answered badly.
617
+ func absent(err error) bool {
618
+ var op *net.OpError
619
+ return errors.As(err, &op) && op.Op == "dial"
620
+ }
621
+
622
+ // errString keeps the "Type: message" shape the dashboard already renders for
623
+ // Docker failures.
624
+ func errString(err error) string {
625
+ return fmt.Sprintf("%T: %v", err, err)
626
+ }