@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.
- package/.env.example +34 -0
- package/Dockerfile +53 -0
- package/LICENSE +661 -0
- package/README.md +219 -0
- package/bin/kanshi.js +63 -0
- package/docker-compose.yml +84 -0
- package/go.mod +3 -0
- package/internal/access/access.go +275 -0
- package/internal/access/listeners.go +229 -0
- package/internal/config/config.go +256 -0
- package/internal/config/defaults_other.go +5 -0
- package/internal/config/defaults_windows.go +4 -0
- package/internal/config/envfile.go +147 -0
- package/internal/dockerstats/dial.go +32 -0
- package/internal/dockerstats/dial_other.go +13 -0
- package/internal/dockerstats/dial_windows.go +62 -0
- package/internal/dockerstats/dockerstats.go +626 -0
- package/internal/roots/roots.go +175 -0
- package/internal/roots/roots_linux.go +132 -0
- package/internal/roots/roots_windows.go +74 -0
- package/internal/server/access.go +155 -0
- package/internal/server/server.go +523 -0
- package/internal/server/static.go +165 -0
- package/internal/storage/cache.go +283 -0
- package/internal/storage/dir_linux.go +177 -0
- package/internal/storage/dir_windows.go +244 -0
- package/internal/storage/statat_fast.go +21 -0
- package/internal/storage/statat_other.go +11 -0
- package/internal/storage/storage.go +598 -0
- package/internal/storage/sysnum_amd64.go +7 -0
- package/internal/storage/sysnum_generic.go +7 -0
- package/internal/storage/walk.go +342 -0
- package/internal/vitals/vitals.go +246 -0
- package/internal/vitals/vitals_linux.go +548 -0
- package/internal/vitals/vitals_windows.go +286 -0
- package/main.go +129 -0
- package/main_other.go +11 -0
- package/main_windows.go +29 -0
- package/package.json +49 -0
- package/web/app.js +835 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon.svg +6 -0
- package/web/index.html +151 -0
- package/web/style.css +356 -0
- 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
|
+
}
|