@@ -10,6 +10,8 @@ import (
1010 "os/exec"
1111 goruntime "runtime"
1212 "strings"
13+ "sync"
14+ "time"
1315
1416 "github.com/GeiserX/CashPilot-Desktop/internal/catalog"
1517 "github.com/docker/docker/api/types/container"
@@ -286,51 +288,168 @@ func (p *DockerProvider) List(ctx context.Context) ([]ContainerInfo, error) {
286288 if err != nil {
287289 return nil , err
288290 }
289- out := make ([]ContainerInfo , 0 , len (containers ))
290- for _ , c := range containers {
291- slug := c .Labels [LabelService ]
292- name := ""
293- if len (c .Names ) > 0 {
294- name = strings .TrimPrefix (c .Names [0 ], "/" )
295- }
296- cpu , mem := p .stats (ctx , cli , c .ID , c .State == "running" )
297- out = append (out , ContainerInfo {
298- Slug : slug ,
299- ContainerID : c .ID ,
300- Name : name ,
301- Image : c .Image ,
302- Status : c .State ,
303- CPUPercent : cpu ,
304- MemoryMB : mem ,
305- })
291+ return p .statsForContainers (ctx , cli , containers ), nil
292+ }
293+
294+ // statsForContainers builds the ContainerInfo list, sampling every container's CPU
295+ // and memory concurrently. stats() waits cpuSampleInterval between its two samples,
296+ // so a serial loop would make this O(N * interval); with one goroutine per container
297+ // the added latency stays ~one interval regardless of N. Each goroutine owns its own
298+ // out[i] slot, so no locking is needed. It takes a statsClient (not *client.Client)
299+ // so the concurrency and mapping can be unit-tested with a fake, no Docker daemon.
300+ func (p * DockerProvider ) statsForContainers (ctx context.Context , cli statsClient , containers []container.Summary ) []ContainerInfo {
301+ out := make ([]ContainerInfo , len (containers ))
302+ var wg sync.WaitGroup
303+ for i := range containers {
304+ wg .Add (1 )
305+ go func (i int ) {
306+ defer wg .Done ()
307+ c := containers [i ]
308+ cpu , mem := p .stats (ctx , cli , c .ID , c .State == "running" )
309+ out [i ] = toContainerInfo (c , cpu , mem )
310+ }(i )
311+ }
312+ wg .Wait ()
313+ return out
314+ }
315+
316+ // toContainerInfo maps a Docker container summary plus its sampled CPU% and memory
317+ // into a ContainerInfo. Split out so the field mapping is unit-testable.
318+ func toContainerInfo (c container.Summary , cpu , mem float64 ) ContainerInfo {
319+ name := ""
320+ if len (c .Names ) > 0 {
321+ name = strings .TrimPrefix (c .Names [0 ], "/" )
322+ }
323+ return ContainerInfo {
324+ Slug : c .Labels [LabelService ],
325+ ContainerID : c .ID ,
326+ Name : name ,
327+ Image : c .Image ,
328+ Status : c .State ,
329+ CPUPercent : cpu ,
330+ MemoryMB : mem ,
306331 }
307- return out , nil
308332}
309333
310- func (p * DockerProvider ) stats (ctx context.Context , cli * client.Client , containerID string , running bool ) (float64 , float64 ) {
334+ // cpuSampleInterval is the delay between the two container-stats samples used to
335+ // compute a live CPU percentage. Docker's one-shot stats endpoint zeroes
336+ // PreCPUStats, so a single sample makes cpuDelta the container's entire lifetime
337+ // CPU time and systemDelta the entire system CPU time — a lifetime average, not
338+ // current load. Two time-separated samples give a true "CPU% right now".
339+ const cpuSampleInterval = 1 * time .Second
340+
341+ // containerSample holds the raw counters read from one ContainerStatsOneShot call.
342+ type containerSample struct {
343+ cpuTotal uint64 // CPUStats.CPUUsage.TotalUsage
344+ systemCPU uint64 // CPUStats.SystemUsage
345+ onlineCPUs float64 // CPUStats.OnlineCPUs, falling back to len(PercpuUsage)
346+ memoryMB float64 // docker-stats-style memory (Usage minus inactive_file)
347+ }
348+
349+ // statsClient is the small subset of *client.Client that the sampling code needs.
350+ // Narrowing it to an interface lets stats(), sampleStats() and statsForContainers()
351+ // be unit-tested with a fake that returns canned stats, with no Docker daemon.
352+ type statsClient interface {
353+ ContainerStatsOneShot (ctx context.Context , containerID string ) (container.StatsResponseReader , error )
354+ }
355+
356+ // stats returns the container's current CPU percentage and memory in MB. It reads
357+ // two stats samples cpuSampleInterval apart and derives the percentage from the
358+ // delta between them; a single one-shot sample would report a meaningless lifetime
359+ // average (see cpuSampleInterval). If ctx is cancelled during the wait it returns
360+ // 0 CPU with sample A's memory, which is still a valid reading.
361+ func (p * DockerProvider ) stats (ctx context.Context , cli statsClient , containerID string , running bool ) (float64 , float64 ) {
311362 if ! running {
312363 return 0 , 0
313364 }
365+ a , ok := sampleStats (ctx , cli , containerID )
366+ if ! ok {
367+ return 0 , 0
368+ }
369+ select {
370+ case <- ctx .Done ():
371+ return 0 , a .memoryMB
372+ case <- time .After (cpuSampleInterval ):
373+ }
374+ b , ok := sampleStats (ctx , cli , containerID )
375+ if ! ok {
376+ return 0 , a .memoryMB
377+ }
378+ return combineSamples (a , b )
379+ }
380+
381+ // sampleStats reads one ContainerStatsOneShot sample and extracts its counters via
382+ // sampleFromResponse. ok is false if the sample cannot be read or decoded.
383+ func sampleStats (ctx context.Context , cli statsClient , containerID string ) (containerSample , bool ) {
314384 reader , err := cli .ContainerStatsOneShot (ctx , containerID )
315385 if err != nil {
316- return 0 , 0
386+ return containerSample {}, false
317387 }
318388 defer reader .Body .Close ()
319389 var stats container.StatsResponse
320390 if err := json .NewDecoder (reader .Body ).Decode (& stats ); err != nil {
321- return 0 , 0
391+ return containerSample {}, false
322392 }
323- memoryMB := float64 (stats .MemoryStats .Usage ) / 1024 / 1024
324- cpuDelta := float64 (stats .CPUStats .CPUUsage .TotalUsage - stats .PreCPUStats .CPUUsage .TotalUsage )
325- systemDelta := float64 (stats .CPUStats .SystemUsage - stats .PreCPUStats .SystemUsage )
393+ return sampleFromResponse (stats ), true
394+ }
395+
396+ // sampleFromResponse extracts the counters needed to compute CPU% and memory from an
397+ // already-decoded stats response. It is pure (no IO), so the extraction — including
398+ // the OnlineCPUs==0 → len(PercpuUsage) fallback and the inactive_file memory
399+ // adjustment via memoryMB — is unit-testable without a Docker daemon.
400+ func sampleFromResponse (stats container.StatsResponse ) containerSample {
326401 onlineCPUs := float64 (stats .CPUStats .OnlineCPUs )
327402 if onlineCPUs == 0 {
328403 onlineCPUs = float64 (len (stats .CPUStats .CPUUsage .PercpuUsage ))
329404 }
405+ return containerSample {
406+ cpuTotal : stats .CPUStats .CPUUsage .TotalUsage ,
407+ systemCPU : stats .CPUStats .SystemUsage ,
408+ onlineCPUs : onlineCPUs ,
409+ memoryMB : memoryMB (stats .MemoryStats ),
410+ }
411+ }
412+
413+ // combineSamples derives the current CPU percentage and memory (MB) from two samples
414+ // taken cpuSampleInterval apart: a is the earlier sample, b the later one. CPU% comes
415+ // from the A→B delta (see cpuPercent, which applies the guards) and memory from the
416+ // more recent sample b. It is pure so the two-sample combination is unit-testable
417+ // without a Docker daemon.
418+ func combineSamples (a , b containerSample ) (float64 , float64 ) {
419+ return cpuPercent (a .cpuTotal , b .cpuTotal , a .systemCPU , b .systemCPU , b .onlineCPUs ), b .memoryMB
420+ }
421+
422+ // cpuPercent computes the Docker-style CPU percentage from two samples:
423+ //
424+ // (cpuDelta / systemDelta) * onlineCPUs * 100
425+ //
426+ // cpuDelta and systemDelta are the differences between the current (cur) and
427+ // previous (pre) counters. Deltas are computed in float64 so that a counter which
428+ // appears to move backwards produces a non-positive delta and trips the guard
429+ // instead of underflowing an unsigned subtraction. It returns 0 when either delta
430+ // is non-positive or onlineCPUs is non-positive.
431+ //
432+ // NOTE: passing a single one-shot sample (pre counters = 0) reproduces the original
433+ // bug — cpuDelta becomes lifetime CPU and systemDelta lifetime system time, so the
434+ // result is a lifetime average that does not reflect current load.
435+ func cpuPercent (preTotal , curTotal , preSystem , curSystem uint64 , onlineCPUs float64 ) float64 {
436+ cpuDelta := float64 (curTotal ) - float64 (preTotal )
437+ systemDelta := float64 (curSystem ) - float64 (preSystem )
330438 if systemDelta <= 0 || cpuDelta <= 0 || onlineCPUs <= 0 {
331- return 0 , memoryMB
439+ return 0
440+ }
441+ return (cpuDelta / systemDelta ) * onlineCPUs * 100
442+ }
443+
444+ // memoryMB converts Docker's MemoryStats to megabytes the way `docker stats` does.
445+ // MemoryStats.Usage includes reclaimable page cache, so inactive_file is subtracted
446+ // when the key is present and not larger than Usage; otherwise raw Usage is used.
447+ func memoryMB (mem container.MemoryStats ) float64 {
448+ usage := mem .Usage
449+ if inactive , ok := mem .Stats ["inactive_file" ]; ok && inactive <= usage {
450+ usage -= inactive
332451 }
333- return ( cpuDelta / systemDelta ) * onlineCPUs * 100 , memoryMB
452+ return float64 ( usage ) / 1024 / 1024
334453}
335454
336455func dockerClient () (* client.Client , error ) {
0 commit comments