@yuuki824/kanshi 0.1.1 → 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.
- package/.env.example +1 -1
- package/Dockerfile +24 -19
- package/README.md +137 -34
- package/docker-compose.yml +13 -3
- package/go.mod +3 -0
- package/internal/config/config.go +140 -0
- package/internal/dockerstats/dockerstats.go +501 -0
- package/internal/server/server.go +372 -0
- package/internal/storage/storage.go +586 -0
- package/internal/vitals/vitals.go +614 -0
- package/main.go +76 -0
- package/package.json +5 -3
- package/web/app.js +167 -8
- package/web/index.html +10 -0
- package/web/style.css +27 -0
- package/web/treemap.js +21 -63
- package/app/__init__.py +0 -0
- package/app/config.py +0 -72
- package/app/dockerstats.py +0 -207
- package/app/main.py +0 -174
- package/app/storage.py +0 -220
- package/app/vitals.py +0 -170
- package/requirements.txt +0 -4
|
@@ -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
|
+
}
|