Skip to content

Commit 9ce4905

Browse files
committed
Release v0.2.0 - Major improvements
New Features: - Real-time CPU and memory statistics with parallel fetching - Container logs viewer with vim-style scrolling (j/k, g/G, PgUp/PgDn) - Instant startup - containers load immediately, stats populate in background - Preserve expand/collapse state across refreshes Performance: - Parallel stats collection (10s → 2.4s for 5 containers) - Non-blocking initial load for instant UI feedback - Optimized refresh cycle UI/UX: - Aligned action names with Docker conventions (Down, Remove) - Fixed column alignment and selection width issues - Stable expand/collapse state during refreshes - Better logs navigation with line counter Code Quality: - Refactored view mode system (Main/Menu/Logs) - Improved stats parsing with custom structs - Better error handling and resource cleanup - Code cleanup (TrimPrefix optimization)
1 parent 4cee170 commit 9ce4905

File tree

6 files changed

+325
-57
lines changed

6 files changed

+325
-57
lines changed

README.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,18 @@ NAME STATUS
104104
## Actions
105105

106106
### Project-level Actions
107-
- Restart All - Restart all containers in the project
108-
- Stop All - Stop all running containers (containers remain, ports held)
109-
- Stop & Remove All - Stop and remove all containers (frees ports, **keeps volumes**)
110-
- Start All - Start all stopped containers in the project
107+
- Restart All - Restart all containers (`docker compose restart`)
108+
- Stop All - Stop all running containers (`docker compose stop`)
109+
- Down - Stop and remove all containers (`docker compose down`, **keeps volumes**)
110+
- Start All - Start all stopped containers (`docker compose start`)
111111

112112
### Container-level Actions
113-
- Restart - Restart the container
114-
- Stop - Stop the container (keeps it, holds port)
115-
- Stop & Remove - Stop and remove the container (frees port, **keeps volumes**)
113+
- Restart - Restart the container (`docker restart`)
114+
- Stop - Stop the container (`docker stop`)
115+
- Remove - Remove the container (`docker rm`, **keeps volumes**)
116+
- Logs - View container logs (last 1000 lines, scrollable)
116117

117-
**Note:** Removing containers is safe - your data in volumes is always preserved. To remove volumes, use `docker volume rm` or `docker compose down --volumes` from the terminal.
118+
**Note:** All operations preserve volumes by default. To remove volumes, use `docker volume rm` or `docker compose down --volumes` from the terminal.
118119

119120
## How It Works
120121

@@ -200,8 +201,8 @@ fi
200201
## Roadmap
201202

202203
- [x] List mode for non-interactive use (`--list` / `-l`)
203-
- [ ] Real-time CPU/Memory statistics
204-
- [ ] Log viewer in split pane
204+
- [x] Real-time CPU/Memory statistics
205+
- [x] Log viewer with scrolling
205206
- [ ] Container inspect view
206207
- [ ] Exec into container
207208
- [ ] Filter/search functionality

docker/client.go

Lines changed: 136 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package docker
22

33
import (
44
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
58
"strings"
69
"time"
710

@@ -46,42 +49,139 @@ func (c *Client) Close() error {
4649
}
4750

4851
func (c *Client) ListContainers() ([]ContainerInfo, error) {
52+
return c.ListContainersWithStats(true)
53+
}
54+
55+
func (c *Client) ListContainersWithStats(includeStats bool) ([]ContainerInfo, error) {
4956
// Only list running containers (equivalent to `docker ps` without -a)
5057
containers, err := c.cli.ContainerList(c.ctx, container.ListOptions{All: false})
5158
if err != nil {
5259
return nil, err
5360
}
5461

55-
var result []ContainerInfo
56-
for _, ctr := range containers {
57-
// Get container stats for CPU and memory
58-
// Note: For now, we'll use placeholder values for stats
59-
// In the future, we can implement real-time stats collection
60-
var cpuPerc, memPerc float64 = 0.0, 0.0
61-
var memUsage string = "N/A"
62-
63-
name := ctr.Names[0]
64-
if strings.HasPrefix(name, "/") {
65-
name = name[1:]
66-
}
62+
// Build initial result without stats
63+
result := make([]ContainerInfo, len(containers))
64+
type statsResult struct {
65+
index int
66+
cpuPerc float64
67+
memPerc float64
68+
memUsage string
69+
}
70+
statsChan := make(chan statsResult, len(containers))
71+
72+
// Fetch stats in parallel for running containers
73+
runningCount := 0
74+
for i, ctr := range containers {
75+
name := strings.TrimPrefix(ctr.Names[0], "/")
6776

68-
result = append(result, ContainerInfo{
77+
result[i] = ContainerInfo{
6978
ID: ctr.ID[:12],
7079
Name: name,
7180
Image: ctr.Image,
7281
State: ctr.State,
7382
Status: ctr.Status,
74-
CPUPerc: cpuPerc,
75-
MemPerc: memPerc,
76-
MemUsage: memUsage,
83+
CPUPerc: 0.0,
84+
MemPerc: 0.0,
85+
MemUsage: "N/A",
7786
CreatedAt: time.Unix(ctr.Created, 0),
7887
Labels: ctr.Labels,
79-
})
88+
}
89+
90+
if ctr.State == "running" && includeStats {
91+
runningCount++
92+
go func(idx int, containerID string) {
93+
cpu, mem, usage := c.getContainerStats(containerID)
94+
statsChan <- statsResult{
95+
index: idx,
96+
cpuPerc: cpu,
97+
memPerc: mem,
98+
memUsage: usage,
99+
}
100+
}(i, ctr.ID)
101+
}
102+
}
103+
104+
// Collect stats results (only if requested)
105+
if includeStats {
106+
for i := 0; i < runningCount; i++ {
107+
stats := <-statsChan
108+
result[stats.index].CPUPerc = stats.cpuPerc
109+
result[stats.index].MemPerc = stats.memPerc
110+
result[stats.index].MemUsage = stats.memUsage
111+
}
80112
}
81113

82114
return result, nil
83115
}
84116

117+
// Stats structures for parsing Docker stats JSON
118+
type statsResponse struct {
119+
CPUStats struct {
120+
CPUUsage struct {
121+
TotalUsage uint64 `json:"total_usage"`
122+
} `json:"cpu_usage"`
123+
SystemUsage uint64 `json:"system_cpu_usage"`
124+
OnlineCPUs uint32 `json:"online_cpus"`
125+
} `json:"cpu_stats"`
126+
PreCPUStats struct {
127+
CPUUsage struct {
128+
TotalUsage uint64 `json:"total_usage"`
129+
} `json:"cpu_usage"`
130+
SystemUsage uint64 `json:"system_cpu_usage"`
131+
} `json:"precpu_stats"`
132+
MemoryStats struct {
133+
Usage uint64 `json:"usage"`
134+
Limit uint64 `json:"limit"`
135+
} `json:"memory_stats"`
136+
}
137+
138+
func (c *Client) getContainerStats(containerID string) (cpuPerc, memPerc float64, memUsage string) {
139+
// Get a single stats snapshot (stream=false)
140+
stats, err := c.cli.ContainerStats(c.ctx, containerID, false)
141+
if err != nil {
142+
return 0.0, 0.0, "N/A"
143+
}
144+
defer stats.Body.Close()
145+
146+
// Decode the stats
147+
var v statsResponse
148+
if err := json.NewDecoder(stats.Body).Decode(&v); err != nil && err != io.EOF {
149+
return 0.0, 0.0, "N/A"
150+
}
151+
152+
// Calculate CPU percentage
153+
cpuDelta := float64(v.CPUStats.CPUUsage.TotalUsage - v.PreCPUStats.CPUUsage.TotalUsage)
154+
systemDelta := float64(v.CPUStats.SystemUsage - v.PreCPUStats.SystemUsage)
155+
onlineCPUs := float64(v.CPUStats.OnlineCPUs)
156+
157+
if systemDelta > 0.0 && cpuDelta > 0.0 {
158+
cpuPerc = (cpuDelta / systemDelta) * onlineCPUs * 100.0
159+
}
160+
161+
// Calculate memory percentage
162+
if v.MemoryStats.Limit > 0 {
163+
memPerc = (float64(v.MemoryStats.Usage) / float64(v.MemoryStats.Limit)) * 100.0
164+
}
165+
166+
// Format memory usage
167+
memUsage = formatBytes(v.MemoryStats.Usage) + " / " + formatBytes(v.MemoryStats.Limit)
168+
169+
return cpuPerc, memPerc, memUsage
170+
}
171+
172+
func formatBytes(bytes uint64) string {
173+
const unit = 1024
174+
if bytes < unit {
175+
return "0 B"
176+
}
177+
div, exp := uint64(unit), 0
178+
for n := bytes / unit; n >= unit; n /= unit {
179+
div *= unit
180+
exp++
181+
}
182+
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
183+
}
184+
85185
func (c *Client) RestartContainer(containerID string) error {
86186
timeout := 10
87187
return c.cli.ContainerRestart(c.ctx, containerID, container.StopOptions{Timeout: &timeout})
@@ -103,3 +203,22 @@ func (c *Client) RemoveContainer(containerID string) error {
103203
})
104204
}
105205

206+
func (c *Client) GetContainerLogs(containerID string, tail int) (string, error) {
207+
options := container.LogsOptions{
208+
ShowStdout: true,
209+
ShowStderr: true,
210+
Tail: fmt.Sprintf("%d", tail),
211+
}
212+
213+
logs, err := c.cli.ContainerLogs(c.ctx, containerID, options)
214+
if err != nil {
215+
return "", err
216+
}
217+
defer logs.Close()
218+
219+
// Read all logs
220+
buf := make([]byte, 1024*1024) // 1MB buffer
221+
n, _ := logs.Read(buf)
222+
223+
return string(buf[:n]), nil
224+
}

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func main() {
2121

2222
// Version flag
2323
if *version {
24-
fmt.Println("dtop v0.1.0")
24+
fmt.Println("dtop v0.2.0")
2525
fmt.Println("Docker container monitor - https://github.com/ekinertac/dtop")
2626
return
2727
}

ui/logs.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package ui
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
func (m Model) renderLogs() string {
9+
var b strings.Builder
10+
11+
// Title
12+
title := fmt.Sprintf("dtop - Logs: %s", m.logsContainer)
13+
b.WriteString(titleStyle.Render(title))
14+
b.WriteString("\n\n")
15+
16+
// Split logs into lines
17+
lines := strings.Split(m.logsContent, "\n")
18+
19+
// Calculate visible height
20+
visibleHeight := m.height - 4 // Title + blank + footer + blank
21+
22+
// Clamp scroll position
23+
maxScroll := len(lines) - visibleHeight
24+
if maxScroll < 0 {
25+
maxScroll = 0
26+
}
27+
if m.logsScroll > maxScroll {
28+
m.logsScroll = maxScroll
29+
}
30+
31+
// Render visible lines
32+
end := m.logsScroll + visibleHeight
33+
if end > len(lines) {
34+
end = len(lines)
35+
}
36+
37+
for i := m.logsScroll; i < end; i++ {
38+
b.WriteString(lines[i])
39+
b.WriteString("\n")
40+
}
41+
42+
// Fill remaining space
43+
renderedLines := end - m.logsScroll
44+
for i := renderedLines; i < visibleHeight; i++ {
45+
b.WriteString("\n")
46+
}
47+
48+
// Footer with scroll indicator
49+
footer := fmt.Sprintf("Lines %d-%d of %d", m.logsScroll+1, end, len(lines))
50+
b.WriteString(helpStyle.Render(footer))
51+
b.WriteString(" ")
52+
b.WriteString(helpStyle.Render("↑↓/PgUp/PgDn/g/G:scroll q/esc:back"))
53+
54+
return b.String()
55+
}
56+

0 commit comments

Comments
 (0)