package main import ( "errors" "flag" "fmt" "os" "sort" "strconv" "strings" "sync" "syscall" "time" "github.com/gdamore/tcell/v2" ) var version = "dev" type view int const ( viewList view = iota viewProcs viewText viewHelp ) var sortNames = []string{"CPU", "MEM", "NET", "IO", "PIDS", "NAME", "IMAGE", "STATE"} var psortNames = []string{"CPU", "MEM", "TIME", "PID", "USER", "COMMAND"} var signals = []struct { name string sig syscall.Signal }{ {"SIGTERM", syscall.SIGTERM}, {"SIGKILL", syscall.SIGKILL}, {"SIGINT", syscall.SIGINT}, {"SIGHUP", syscall.SIGHUP}, {"SIGQUIT", syscall.SIGQUIT}, {"SIGUSR1", syscall.SIGUSR1}, {"SIGUSR2", syscall.SIGUSR2}, {"SIGSTOP", syscall.SIGSTOP}, {"SIGCONT", syscall.SIGCONT}, } // modal is a yes/no question (onYes), an info box (neither) or a picker (items/onPick). type modal struct { title string lines []string onYes func() items []string pick int onPick func(int) } // snapMsg carries a finished background collection to the UI goroutine. type snapMsg struct { cs []Container err error } type app struct { scr tcell.Screen cfg config dk *Docker info Info // container list snap []Container byID map[string]Container err error loading bool rows []Container sel, top int sortBy int filter string all bool tree bool fullCmd bool openName string // container to open once the first snapshot arrives (command-line argument) // process view of a.cur cur Container insp *Inspect procs []Proc prows []Proc remote *Top // daemon is not on this host: docker top's own table procErr error pprev map[int]uint64 pat time.Time users map[int]string psel, ptop int psortBy int pfilter string ptree bool // text view: details or logs of a.cur text []string textErr error textTitle string textLoad func() ([]string, error) textFrom view ttop int tleft int follow bool showEnv bool view view helpFrom view searching bool modal *modal busy bool mu sync.Mutex status string } var ( stDefault = tcell.StyleDefault stHdr = tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorGreen) stSel = tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorTeal) stKey = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) stFn = tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorTeal) stLabel = tcell.StyleDefault.Foreground(tcell.ColorTeal).Bold(true) stDim = tcell.StyleDefault.Foreground(tcell.ColorGray) stWarn = tcell.StyleDefault.Foreground(tcell.ColorYellow).Bold(true) stErr = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) stGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) stYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) stMagenta = tcell.StyleDefault.Foreground(tcell.ColorFuchsia) stRed = tcell.StyleDefault.Foreground(tcell.ColorRed) ) func indexOf(names []string, name string) int { for i, n := range names { if n == strings.ToUpper(name) { return i } } return 0 } func main() { cfg := loadConfig() flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: dockertop [-d SECONDS] [-s COLUMN] [-t] [-r] [--version] [CONTAINER]\n\nhtop-style view of Docker containers and the processes inside them.\nCONTAINER (name or ID prefix) opens straight into its process view. Press h inside for keys.\n\n") flag.PrintDefaults() } delay := flag.Float64("d", cfg.Refresh, "refresh interval in seconds") sortFlag := flag.String("s", cfg.Sort, "sort column: "+strings.Join(sortNames, ", ")) tree := flag.Bool("t", cfg.Tree, "group containers by compose project") running := flag.Bool("r", !cfg.All, "show running containers only") ver := flag.Bool("version", false, "print the version and exit") flag.Parse() if *ver { fmt.Println("dockertop", version) return } if *delay <= 0 || flag.NArg() > 1 { flag.Usage() os.Exit(2) } cfg.Refresh = *delay dk, err := newDocker() if err != nil { fmt.Fprintln(os.Stderr, "dockertop:", err) os.Exit(1) } info, err := dk.info() if err != nil { fmt.Fprintf(os.Stderr, "dockertop: cannot reach the Docker daemon at %s: %v%s\n", dk.Host, err, permHint(err)) os.Exit(1) } scr, err := tcell.NewScreen() if err == nil { err = scr.Init() } if err != nil { fmt.Fprintln(os.Stderr, "dockertop:", err) os.Exit(1) } a := &app{scr: scr, cfg: cfg, dk: dk, info: info, sortBy: indexOf(sortNames, *sortFlag), psortBy: indexOf(psortNames, cfg.ProcSort), tree: *tree, all: !*running, ptree: cfg.ProcTree, fullCmd: cfg.FullCmd, openName: flag.Arg(0)} a.refresh() events := make(chan tcell.Event, 64) go func() { for { ev := scr.PollEvent() if ev == nil { return } events <- ev } }() tick := time.NewTicker(time.Duration(cfg.Refresh * float64(time.Second))) loop: for { a.draw() select { case ev := <-events: if a.handle(ev) { break loop } case <-tick.C: a.tick() } } scr.Fini() cfg.Sort, cfg.ProcSort, cfg.All, cfg.Tree, cfg.ProcTree, cfg.FullCmd = sortNames[a.sortBy], psortNames[a.psortBy], a.all, a.tree, a.ptree, a.fullCmd if err := cfg.save(); err != nil { fmt.Fprintln(os.Stderr, "dockertop: saving config:", err) } } func (a *app) tick() { a.refresh() switch a.view { case viewProcs: a.sampleProcs() case viewText: a.loadText() } } // refresh starts a background collection unless one is in flight; the // result comes back as a snapMsg event. func (a *app) refresh() { if a.loading { return } a.loading = true prev := a.byID go func() { cs, err := a.dk.collect(prev) a.scr.PostEvent(tcell.NewEventInterrupt(snapMsg{cs, err})) }() } func (a *app) apply(m snapMsg) { a.loading = false a.err = m.err if m.err != nil { return } first := a.snap == nil a.snap = m.cs a.byID = make(map[string]Container, len(m.cs)) for _, c := range m.cs { a.byID[c.ID] = c } if c, ok := a.byID[a.cur.ID]; ok { a.cur = c } a.rebuild() if first { a.refresh() // CPU and rates need a second sample; don't make the user wait a whole interval if a.openName != "" { a.openByName() } } } func (a *app) openByName() { n := a.openName a.openName = "" for _, c := range a.snap { if c.Name == n || strings.HasPrefix(c.ID, n) { a.openProcs(c) return } } a.notice("No such container", fmt.Sprintf("No container is named %q or has an ID starting with it.", n)) } // ---- container list ---- func stateRank(s string) int { return strings.Index("running restarting paused created exited dead", s) } func (a *app) less(x, y Container) bool { var d float64 switch sortNames[a.sortBy] { case "CPU": d = x.CPU - y.CPU case "MEM": d = float64(x.Mem) - float64(y.Mem) case "NET": d = x.NetRx + x.NetTx - y.NetRx - y.NetTx case "IO": d = x.BlkR + x.BlkW - y.BlkR - y.BlkW case "PIDS": d = float64(x.Pids) - float64(y.Pids) case "IMAGE": if x.Image != y.Image { return x.Image < y.Image } case "STATE": if x.State != y.State { return stateRank(x.State) < stateRank(y.State) } } if d != 0 { return d > 0 } return x.Name < y.Name } func (a *app) visible(c Container) bool { if !a.all && c.State != "running" && c.State != "paused" && c.State != "restarting" { return false } f := strings.ToLower(a.filter) return f == "" || strings.Contains(strings.ToLower(c.Name+" "+c.ID[:12]+" "+c.Image+" "+c.Project+" "+c.Status), f) } func (a *app) rebuild() { selID := "" if a.sel < len(a.rows) { selID = a.rows[a.sel].ID } var vis []Container for _, c := range a.snap { if a.visible(c) { vis = append(vis, c) } } sort.SliceStable(vis, func(i, j int) bool { return a.less(vis[i], vis[j]) }) a.rows = vis if a.tree { a.rows = groupRows(vis, a.less) } a.sel = 0 for i, c := range a.rows { if c.ID == selID { a.sel = i } } } // groupRows puts containers under one summary row per compose project. // cs is already sorted; containers outside any project stay top level. func groupRows(cs []Container, less func(x, y Container) bool) []Container { kids := map[string][]Container{} var tops []Container for _, c := range cs { if c.Project == "" { tops = append(tops, c) continue } if kids[c.Project] == nil { tops = append(tops, Container{ID: "project:" + c.Project, Name: c.Project, Project: c.Project, Group: true, State: "exited"}) } kids[c.Project] = append(kids[c.Project], c) } for i := range tops { g := &tops[i] if !g.Group { continue } up := 0 for _, c := range kids[g.Project] { g.CPU += c.CPU g.Mem += c.Mem g.Pids += c.Pids g.NetRx, g.NetTx, g.BlkR, g.BlkW = g.NetRx+c.NetRx, g.NetTx+c.NetTx, g.BlkR+c.BlkR, g.BlkW+c.BlkW g.HasStats, g.HasRate = g.HasStats || c.HasStats, g.HasRate || c.HasRate if c.State == "running" { up++ g.State = "running" } } g.Status = fmt.Sprintf("%d/%d running", up, len(kids[g.Project])) } sort.SliceStable(tops, func(i, j int) bool { return less(tops[i], tops[j]) }) var out []Container for _, t := range tops { out = append(out, t) k := kids[t.Project] if !t.Group { continue } for i, c := range k { c.Tree = "├─ " if i == len(k)-1 { c.Tree = "└─ " } out = append(out, c) } } return out } // ---- process view ---- func (a *app) openProcs(c Container) { if c.Group { return } a.cur, a.view = c, viewProcs a.psel, a.ptop, a.pfilter, a.pprev, a.procs, a.remote = 0, 0, "", nil, nil, nil a.insp, _ = a.dk.inspect(c.ID) a.users = nil if a.insp != nil { a.users = containerUsers(a.insp.State.Pid) } a.sampleProcs() // CPU% needs a second reading: take one shortly instead of a whole interval later. time.AfterFunc(700*time.Millisecond, func() { a.scr.PostEvent(tcell.NewEventInterrupt("procs")) }) } func (a *app) sampleProcs() { if a.cur.State != "running" && a.cur.State != "paused" { a.procs, a.remote, a.procErr = nil, nil, fmt.Errorf("container is %s", a.cur.State) a.rebuildProcs() return } t, err := a.dk.top(a.cur.ID) a.procErr = err if err != nil { a.procs, a.remote = nil, nil a.rebuildProcs() return } col := -1 for i, h := range t.Titles { if h == "PID" { col = i } } var pids []int for _, row := range t.Processes { if col >= 0 && col < len(row) { if pid, err := strconv.Atoi(row[col]); err == nil { pids = append(pids, pid) } } } if len(pids) > 0 && !isLocal(pids[0], a.cur.ID) { a.procs, a.remote = nil, t return } a.remote = nil now := time.Now() a.procs = readProcs(pids, a.pprev, now.Sub(a.pat).Seconds()) a.pprev, a.pat = make(map[int]uint64, len(a.procs)), now for i := range a.procs { p := &a.procs[i] a.pprev[p.PID] = p.Ticks p.User = a.users[p.UID] switch { case p.User != "": case p.UID == 0: p.User = "root" default: p.User = strconv.Itoa(p.UID) } } a.rebuildProcs() } func (a *app) memPct(p Proc) float64 { if a.cur.MemLimit == 0 { return 0 } return float64(p.RSS) * 100 / float64(a.cur.MemLimit) } func (a *app) pless(x, y Proc) bool { switch psortNames[a.psortBy] { case "MEM": return x.RSS > y.RSS case "TIME": return x.Ticks > y.Ticks case "PID": return x.PID < y.PID case "USER": return x.User < y.User case "COMMAND": return x.Comm < y.Comm } if x.CPU != y.CPU { return x.CPU > y.CPU } return x.PID < y.PID } func (a *app) pvisible(p Proc) bool { f := strings.ToLower(a.pfilter) return f == "" || strings.Contains(strings.ToLower(p.Cmd+" "+p.Comm+" "+p.User+" "+strconv.Itoa(p.PID)), f) } func (a *app) rebuildProcs() { selPID := -1 if a.psel < len(a.prows) { selPID = a.prows[a.psel].PID } a.prows = a.prows[:0] if a.ptree { a.buildTree() } else { for _, p := range a.procs { if a.pvisible(p) { a.prows = append(a.prows, p) } } sort.SliceStable(a.prows, func(i, j int) bool { return a.pless(a.prows[i], a.prows[j]) }) } a.psel = 0 for i, p := range a.prows { if p.PID == selPID { a.psel = i } } } // buildTree lays processes out by parent, htop style. A process is shown if it // or any descendant passes the filter, so ancestors stay for context. func (a *app) buildTree() { kids := map[int][]Proc{} present := map[int]bool{} for _, p := range a.procs { present[p.PID] = true kids[p.PPID] = append(kids[p.PPID], p) } for _, k := range kids { sort.SliceStable(k, func(i, j int) bool { return a.pless(k[i], k[j]) }) } keep := map[int]bool{} var mark func(p Proc) bool mark = func(p Proc) bool { ok := a.pvisible(p) for _, c := range kids[p.PID] { if mark(c) { ok = true } } keep[p.PID] = ok return ok } var walk func(p Proc, prefix, branch string) walk = func(p Proc, prefix, branch string) { p.Tree = prefix + branch a.prows = append(a.prows, p) var shown []Proc for _, c := range kids[p.PID] { if keep[c.PID] { shown = append(shown, c) } } childPrefix := prefix if branch == "├─ " { childPrefix += "│ " } else if branch == "└─ " { childPrefix += " " } for i, c := range shown { b := "├─ " if i == len(shown)-1 { b = "└─ " } walk(c, childPrefix, b) } } var roots []Proc for _, p := range a.procs { if !present[p.PPID] || p.PPID == p.PID { roots = append(roots, p) } } sort.SliceStable(roots, func(i, j int) bool { return a.pless(roots[i], roots[j]) }) for _, r := range roots { if mark(r) { walk(r, "", "") } } } // ---- text views: details and logs ---- func (a *app) openText(c Container, title string, logs bool) { if c.Group { return } a.cur, a.textTitle, a.textFrom, a.view = c, title, a.view, viewText a.ttop, a.tleft, a.follow, a.showEnv = 0, 0, logs, false insp, err := a.dk.inspect(c.ID) if err != nil { a.text, a.textErr, a.textLoad = nil, err, nil return } if logs { a.textLoad = func() ([]string, error) { return a.dk.logs(c.ID, insp.Config.Tty, 2000) } } else { a.textLoad = func() ([]string, error) { i, err := a.dk.inspect(c.ID) if err != nil { return nil, err } return i.details(a.cur, a.showEnv), nil } } a.loadText() } func (a *app) loadText() { if a.textLoad == nil { return } t, err := a.textLoad() a.textErr = err if err == nil { a.text = t } } func (a *app) textAvail() int { _, h := a.scr.Size() return max(h-3, 1) } // ---- input ---- func (a *app) handle(ev tcell.Event) (quit bool) { switch ev := ev.(type) { case *tcell.EventResize: a.scr.Sync() case *tcell.EventInterrupt: switch d := ev.Data().(type) { case snapMsg: a.apply(d) case string: if d == "done" { a.busy = false a.refresh() if a.view == viewProcs { a.sampleProcs() } } else if d == "procs" && a.view == viewProcs { a.sampleProcs() } } case *tcell.EventKey: return a.key(ev) } return false } func (a *app) modalKey(ev *tcell.EventKey) { m := a.modal switch { case m.items != nil: switch ev.Key() { case tcell.KeyUp: m.pick = max(m.pick-1, 0) case tcell.KeyDown: m.pick = min(m.pick+1, len(m.items)-1) case tcell.KeyEnter: a.modal = nil m.onPick(m.pick) case tcell.KeyEscape: a.modal = nil case tcell.KeyRune: if ev.Rune() == 'q' { a.modal = nil } } case m.onYes != nil && (ev.Rune() == 'y' || ev.Rune() == 'Y'): a.modal = nil m.onYes() case m.onYes == nil || ev.Key() == tcell.KeyEscape || ev.Rune() == 'n' || ev.Rune() == 'N' || ev.Rune() == 'q': a.modal = nil } } func (a *app) search(ev *tcell.EventKey) { f, rebuild := &a.filter, a.rebuild if a.view == viewProcs { f, rebuild = &a.pfilter, a.rebuildProcs } switch ev.Key() { case tcell.KeyEscape: *f, a.searching = "", false case tcell.KeyEnter: a.searching = false case tcell.KeyBackspace, tcell.KeyBackspace2: if r := []rune(*f); len(r) > 0 { *f = string(r[:len(r)-1]) } case tcell.KeyRune: *f += string(ev.Rune()) } rebuild() } func (a *app) back() { switch a.view { case viewText: a.view = a.textFrom case viewHelp: a.view = a.helpFrom default: a.view = viewList } } func (a *app) key(ev *tcell.EventKey) bool { if ev.Key() == tcell.KeyCtrlC { return true } if a.modal != nil { a.modalKey(ev) return false } if a.searching { a.search(ev) return false } if a.view == viewHelp { a.back() return false } v, n, pos := a.view, len(a.rows), &a.sel switch v { case viewProcs: n, pos = len(a.prows), &a.psel if a.remote != nil { n = len(a.remote.Processes) } case viewText: n, pos = len(a.text)-a.textAvail()+1, &a.ttop } _, h := a.scr.Size() pg := max(h-8, 1) switch ev.Key() { case tcell.KeyUp: *pos-- case tcell.KeyDown: *pos++ case tcell.KeyPgUp: *pos -= pg case tcell.KeyPgDn: *pos += pg case tcell.KeyHome: *pos = 0 case tcell.KeyEnd: *pos = n - 1 case tcell.KeyLeft: a.tleft = max(a.tleft-8, 0) case tcell.KeyRight: a.tleft += 8 case tcell.KeyEnter: if a.view == viewList && a.sel < len(a.rows) { a.openProcs(a.rows[a.sel]) return false } case tcell.KeyEscape: a.back() return false case tcell.KeyRune: if a.rune(ev.Rune()) { return true } } if v == viewText && a.view == viewText { n = len(a.text) - a.textAvail() + 1 a.follow = *pos >= n-1 } *pos = max(0, min(*pos, n-1)) return false } func (a *app) rune(r rune) (quit bool) { c, ok := a.current() switch r { case 'q': if a.view == viewList { return true } a.back() case '?', 'h': a.helpFrom, a.view = a.view, viewHelp case 'c': a.fullCmd = !a.fullCmd case 'e': if a.view == viewText && a.textTitle == "Details" { a.showEnv = !a.showEnv a.loadText() } } if a.view == viewText { return false } switch r { case '/': a.searching = true case 's': if a.view == viewProcs { a.psortBy = (a.psortBy + 1) % len(psortNames) a.rebuildProcs() } else { a.sortBy = (a.sortBy + 1) % len(sortNames) a.rebuild() } case 'N', 'C', 'M': name := map[rune]string{'N': "NAME", 'C': "CPU", 'M': "MEM"}[r] if a.view == viewProcs { if name == "NAME" { name = "COMMAND" } a.psortBy = indexOf(psortNames, name) a.rebuildProcs() } else { a.sortBy = indexOf(sortNames, name) a.rebuild() } case 't': if a.view == viewProcs { a.ptree = !a.ptree a.rebuildProcs() } else { a.tree = !a.tree a.rebuild() } case 'a': if a.view == viewList { a.all = !a.all a.rebuild() } } if !ok { return false } switch r { case 'd': a.openText(c, "Details", false) case 'l': a.openText(c, "Logs", true) case 'x': a.askStartStop(c) case 'r': a.askAction(c, "Restart", "restart", "Stop and start "+c.Name+" again?") case 'p': if c.State == "paused" { a.askAction(c, "Unpause", "unpause", "Resume every process in "+c.Name+"?") } else { a.askAction(c, "Pause", "pause", "Freeze every process in "+c.Name+" (cgroup freezer)?") } case 'k': if a.view == viewProcs { a.askSignalProc() } else { a.askKill(c) } } return false } // current is the container actions apply to: the selected row, or the one being viewed. func (a *app) current() (Container, bool) { if a.view != viewList { return a.cur, true } if a.sel < len(a.rows) && !a.rows[a.sel].Group { return a.rows[a.sel], true } return Container{}, false } func (a *app) notice(title string, lines ...string) { a.modal = &modal{title: title, lines: lines} } func (a *app) setStatus(s string) { a.mu.Lock() a.status = s a.mu.Unlock() a.scr.PostEvent(tcell.NewEventInterrupt(nil)) } func (a *app) runJob(fn func() error) { if a.busy { a.notice("Busy", "Another action is still running.") return } a.busy = true go func() { if err := fn(); err != nil { a.setStatus("Error: " + err.Error()) } a.scr.PostEvent(tcell.NewEventInterrupt("done")) }() } func (a *app) askAction(c Container, title, call, question string) { a.modal = &modal{title: title, lines: []string{question}, onYes: func() { a.runJob(func() error { a.setStatus(title + " " + c.Name + "...") start := time.Now() if err := a.dk.action(c.ID, call); err != nil { return err } a.setStatus(fmt.Sprintf("%s %s: done in %s.", title, c.Name, time.Since(start).Round(100*time.Millisecond))) return nil }) }} } func (a *app) askStartStop(c Container) { switch c.State { case "running", "paused", "restarting": a.askAction(c, "Stop", "stop", "Stop "+c.Name+"? (SIGTERM, then SIGKILL after the stop timeout, 10s by default)") default: a.askAction(c, "Start", "start", "Start "+c.Name+"?") } } func signalItems() []string { items := make([]string, len(signals)) for i, s := range signals { items[i] = fmt.Sprintf("%2d %s", int(s.sig), s.name) } return items } func (a *app) askKill(c Container) { a.modal = &modal{title: "Send signal to container " + c.Name + " (its main process)", items: signalItems(), onPick: func(i int) { a.runJob(func() error { if err := a.dk.action(c.ID, "kill?signal="+signals[i].name); err != nil { return err } a.setStatus(fmt.Sprintf("Sent %s to %s.", signals[i].name, c.Name)) return nil }) }} } func (a *app) askSignalProc() { if a.remote != nil || a.psel >= len(a.prows) { a.notice("Not available", "Processes can only be signalled when the Docker daemon runs on this machine.") return } p := a.prows[a.psel] a.modal = &modal{title: fmt.Sprintf("Send signal to %s (host PID %d)", p.Comm, p.PID), items: signalItems(), onPick: func(i int) { if err := syscall.Kill(p.PID, signals[i].sig); err != nil { a.notice("Signal failed", err.Error()+permHint(err)) return } a.setStatus(fmt.Sprintf("Sent %s to %s (%d).", signals[i].name, p.Comm, p.PID)) }} } func permHint(err error) string { if errors.Is(err, os.ErrPermission) || errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EACCES) { return " (try: sudo dockertop, or join the docker group)" } return "" } // ---- drawing ---- func (a *app) put(x, y int, s string, st tcell.Style) int { w, _ := a.scr.Size() for _, r := range s { if x >= w { break } a.scr.SetContent(x, y, r, nil, st) x++ } return x } func (a *app) fill(y int, st tcell.Style) { w, _ := a.scr.Size() for x := 0; x < w; x++ { a.scr.SetContent(x, y, ' ', nil, st) } } type seg struct { frac float64 st tcell.Style } // bar draws an htop style Label[||||| text] of the given total width. func (a *app) bar(x, y, width int, label string, segs []seg, text string) { x = a.put(x, y, label, stLabel) x = a.put(x, y, "[", stDefault) inner := width - len(label) - 2 if inner < 4 { return } pos := 0 for _, s := range segs { n := min(int(s.frac*float64(inner)+0.5), inner-pos) for i := 0; i < n; i++ { a.scr.SetContent(x+pos, y, '|', nil, s.st) pos++ } } if len(text) < inner { a.put(x+inner-len(text), y, text, stDim) } a.put(x+inner, y, "]", stDefault) } // bars draws two bars side by side, or stacked on a narrow terminal. Returns the next line. func (a *app) bars(y, w int, l1 string, s1 []seg, t1, l2 string, s2 []seg, t2 string) int { if w >= 100 { bw := (w - 2) / 2 a.bar(1, y, bw, l1, s1, t1) a.bar(1+bw+1, y, bw, l2, s2, t2) return y + 1 } a.bar(1, y, w-2, l1, s1, t1) a.bar(1, y+1, w-2, l2, s2, t2) return y + 2 } func cpuStyle(p float64) tcell.Style { switch { case p >= 90: return stRed case p >= 50: return stYellow } return stGreen } func (a *app) drawStatus(y int) { a.mu.Lock() status := a.status a.mu.Unlock() st := stWarn if a.err != nil { status, st = "Error: "+a.err.Error(), stErr } else if strings.HasPrefix(status, "Error") { st = stErr } a.put(1, y, status, st) } func (a *app) drawHeader(w int) int { var cpu float64 var mem uint64 var rx, tx, br, bw float64 count := map[string]int{} for _, c := range a.snap { cpu += c.CPU mem += c.Mem rx, tx, br, bw = rx+c.NetRx, tx+c.NetTx, br+c.BlkR, bw+c.BlkW count[c.State]++ } ncpu := float64(max(a.info.NCPU, 1)) y := a.bars(0, w, "CPU", []seg{{cpu / ncpu / 100, cpuStyle(cpu / ncpu)}}, fmt.Sprintf("%.1f%% of %d cores", cpu, a.info.NCPU), "Mem", []seg{{float64(mem) / float64(max(a.info.MemTotal, 1)), stGreen}}, human(mem)+"/"+human(a.info.MemTotal)) x := a.put(1, y, fmt.Sprintf("Containers %d: ", len(a.snap)), stDefault) x = a.put(x, y, fmt.Sprintf("%d running ", count["running"]), stGreen) x = a.put(x, y, fmt.Sprintf("%d paused ", count["paused"]), stYellow) x = a.put(x, y, fmt.Sprintf("%d stopped ", count["exited"]+count["created"]+count["dead"]), stDim) if n := count["restarting"]; n > 0 { x = a.put(x, y, fmt.Sprintf("%d restarting ", n), stMagenta) } a.put(x+1, y, fmt.Sprintf("Docker %s on %s (%s, cgroup v%s)", a.info.ServerVersion, a.info.Name, a.info.OperatingSystem, a.info.CgroupVersion), stDim) y++ line := fmt.Sprintf("Net rx %s/s tx %s/s Block read %s/s write %s/s sort: %s", human(uint64(rx)), human(uint64(tx)), human(uint64(br)), human(uint64(bw)), sortNames[a.sortBy]) if a.tree { line += " tree" } if !a.all { line += " running only (a: show all)" } if a.filter != "" { line += " filter: " + a.filter } a.put(1, y, line, stDefault) a.drawStatus(y + 1) return y + 3 } func (a *app) draw() { a.scr.Clear() w, h := a.scr.Size() switch a.view { case viewHelp: a.drawHelp() case viewProcs: a.drawProcs(w, h) case viewText: a.drawText(w, h) default: a.drawList(w, h) } a.drawBottom(w, h) if a.modal != nil { a.drawModal(w, h) } a.scr.Show() } func statusStyle(c Container) tcell.Style { switch { case c.Group && c.State == "running": return stLabel case c.Group: return stDim case strings.Contains(c.Status, "unhealthy"), c.State == "dead", c.State == "exited" && !strings.HasPrefix(c.Status, "Exited (0)"): return stRed case strings.Contains(c.Status, "starting"), c.State == "paused": return stYellow case c.State == "restarting": return stMagenta case c.State != "running": return stDim } return stDefault } // cell formats a stats value: blank without stats, "-" before the first rate. func cell(c Container, rate bool, s string) string { switch { case !c.HasStats: return "" case rate && !c.HasRate: return "-" } return s } func (a *app) drawList(w, h int) { if a.snap == nil { a.put(1, 0, "Reading containers from "+a.dk.Host+"...", stDim) a.drawStatus(1) return } y := a.drawHeader(w) last := "IMAGE" if a.fullCmd { last = "COMMAND" } a.fill(y, stHdr) a.put(0, y, fmt.Sprintf(" %-30s %6s %7s %5s %7s %7s %7s %7s %5s %-28s %s", "NAME", "CPU%", "MEM", "MEM%", "NET RX", "NET TX", "READ", "WRITE", "PIDS", "STATUS", last), stHdr) y++ avail := h - 1 - y if avail < 1 { return } if a.sel < a.top { a.top = a.sel } if a.sel >= a.top+avail { a.top = a.sel - avail + 1 } a.top = max(0, min(a.top, len(a.rows)-avail)) if len(a.rows) == 0 { a.put(1, y, "No containers match.", stDim) } for i := a.top; i < len(a.rows) && i < a.top+avail; i++ { c := a.rows[i] sel := i == a.sel st := stDefault if c.Group { st = stLabel } pick := func(s tcell.Style) tcell.Style { if sel { return stSel } return s } if sel { a.fill(y, stSel) } x := a.put(0, y, " ", pick(st)) x = a.put(x, y, c.Tree, pick(stDim)) x = a.put(x, y, fmt.Sprintf("%-*.*s ", 30-len([]rune(c.Tree)), 30-len([]rune(c.Tree)), c.Name), pick(st)) x = a.put(x, y, fmt.Sprintf("%6s ", cell(c, true, fmt.Sprintf("%.1f", c.CPU))), pick(cpuStyle(c.CPU))) x = a.put(x, y, fmt.Sprintf("%7s ", cell(c, false, human(c.Mem))), pick(st)) mp := "" if c.MemLimit > 0 { mp = fmt.Sprintf("%.1f", c.MemPct()) } x = a.put(x, y, fmt.Sprintf("%5s ", cell(c, false, mp)), pick(cpuStyle(c.MemPct()))) x = a.put(x, y, fmt.Sprintf("%7s %7s %7s %7s %5s ", cell(c, true, human(uint64(c.NetRx))), cell(c, true, human(uint64(c.NetTx))), cell(c, true, human(uint64(c.BlkR))), cell(c, true, human(uint64(c.BlkW))), cell(c, false, strconv.FormatUint(c.Pids, 10))), pick(st)) x = a.put(x, y, fmt.Sprintf("%-28.28s ", c.Status), pick(statusStyle(c))) lastv := c.Image if a.fullCmd { lastv = c.Command } a.put(x, y, lastv, pick(stDim)) y++ } } func (a *app) drawProcs(w, h int) { c := a.cur x := a.put(1, 0, c.Name, stLabel) x = a.put(x+2, 0, c.Image, stDim) a.put(x+2, 0, c.Status, statusStyle(c)) online := c.Online if online == 0 { online = a.info.NCPU } limit, limTxt := float64(max(online, 1))*100, fmt.Sprintf("%d cores", online) if a.insp != nil && a.insp.CPULimit() > 0 { limit, limTxt = a.insp.CPULimit(), fmt.Sprintf("limit %.2f CPUs", a.insp.CPULimit()/100) } memFrac := 0.0 if c.MemLimit > 0 { memFrac = float64(c.Mem) / float64(c.MemLimit) } y := a.bars(1, w, "CPU", []seg{{c.CPU / limit, cpuStyle(c.CPU * 100 / limit)}}, fmt.Sprintf("%.1f%% of %s", c.CPU, limTxt), "Mem", []seg{{memFrac, cpuStyle(memFrac * 100)}}, human(c.Mem)+"/"+human(c.MemLimit)) thr, run := 0, 0 for _, p := range a.procs { thr += p.Threads if p.State == 'R' { run++ } } pl := "unlimited" if c.PidsLimit > 0 && c.PidsLimit < 1<<62 { pl = strconv.FormatUint(c.PidsLimit, 10) } line := fmt.Sprintf("Tasks %d, %d thr; %d running pids %d/%s Net rx %s/s tx %s/s Block read %s/s write %s/s", len(a.procs), thr, run, c.Pids, pl, human(uint64(c.NetRx)), human(uint64(c.NetTx)), human(uint64(c.BlkR)), human(uint64(c.BlkW))) if a.insp != nil && !a.insp.State.StartedAt.IsZero() && c.State == "running" { line += " Uptime " + ago(time.Since(a.insp.State.StartedAt)) } a.put(1, y, line, stDefault) y++ line = "sort: " + psortNames[a.psortBy] if a.ptree { line += " tree" } if a.pfilter != "" { line += " filter: " + a.pfilter } x = a.put(1, y, line, stDim) if a.procErr != nil { a.put(x+3, y, "cannot list processes: "+a.procErr.Error(), stErr) } else if a.remote != nil { a.put(x+3, y, "Docker runs on another host: showing its ps output (CPU% is a lifetime average)", stWarn) } y++ a.drawStatus(y) y += 2 if a.remote != nil { a.drawRemote(y, h) return } a.fill(y, stHdr) a.put(0, y, fmt.Sprintf("%8s %-9s %3s %3s %6s %6s S %5s %5s %9s %s", "PID", "USER", "PRI", "NI", "VIRT", "RES", "CPU%", "MEM%", "TIME+", "Command"), stHdr) y++ avail := h - 1 - y if avail < 1 { return } if a.psel < a.ptop { a.ptop = a.psel } if a.psel >= a.ptop+avail { a.ptop = a.psel - avail + 1 } a.ptop = max(0, min(a.ptop, len(a.prows)-avail)) for i := a.ptop; i < len(a.prows) && i < a.ptop+avail; i++ { p := a.prows[i] sel := i == a.psel pick := func(s tcell.Style) tcell.Style { if sel { return stSel } return s } if sel { a.fill(y, stSel) } cpu := "-" if p.HasCPU { cpu = fmt.Sprintf("%.1f", p.CPU) } sst := stDefault switch p.State { case 'R': sst = stGreen case 'D': sst = stRed case 'Z', 'T', 't': sst = stYellow } x := a.put(0, y, fmt.Sprintf("%8d %-9.9s %3d %3d %6s %6s ", p.PID, p.User, p.Pri, p.Nice, human(p.Virt), human(p.RSS)), pick(stDefault)) x = a.put(x, y, string(p.State)+" ", pick(sst)) x = a.put(x, y, fmt.Sprintf("%5s ", cpu), pick(cpuStyle(p.CPU))) x = a.put(x, y, fmt.Sprintf("%5.1f %9s ", a.memPct(p), cpuTime(p.Ticks)), pick(stDefault)) x = a.put(x, y, p.Tree, pick(stDim)) cmd := p.Comm if a.fullCmd { cmd = p.Cmd } a.put(x, y, cmd, pick(stDefault)) y++ } } // drawRemote shows docker top's table as-is, columns padded to fit. func (a *app) drawRemote(y, h int) { t := a.remote wid := make([]int, len(t.Titles)) for i, s := range t.Titles { wid[i] = len(s) } for _, r := range t.Processes { for i := range min(len(r), len(wid)) { wid[i] = max(wid[i], len(r[i])) } } row := func(cols []string) string { var b strings.Builder for i, s := range cols { if i < len(wid)-1 { fmt.Fprintf(&b, "%-*s ", wid[i], s) } else { b.WriteString(s) } } return b.String() } a.fill(y, stHdr) a.put(0, y, row(t.Titles), stHdr) y++ avail := h - 1 - y a.ptop = max(0, min(a.ptop, a.psel, len(t.Processes)-avail)) if a.psel >= a.ptop+avail { a.ptop = a.psel - avail + 1 } for i := a.ptop; i < len(t.Processes) && i < a.ptop+avail; i++ { st := stDefault if i == a.psel { a.fill(y, stSel) st = stSel } a.put(0, y, row(t.Processes[i]), st) y++ } } func (a *app) drawText(w, h int) { x := a.put(1, 0, a.textTitle, stLabel) x = a.put(x+2, 0, a.cur.Name, stDefault) if a.textTitle == "Logs" { hint := "last 2000 lines, refreshed every tick" if a.follow { hint += ", following" } a.put(x+2, 0, hint, stDim) } if a.textErr != nil { a.put(1, 1, "Error: "+a.textErr.Error(), stErr) } else { a.drawStatus(1) } avail := a.textAvail() if a.follow { a.ttop = len(a.text) } a.ttop = max(0, min(a.ttop, len(a.text)-avail)) for i := 0; i < avail && a.ttop+i < len(a.text); i++ { l := []rune(a.text[a.ttop+i]) st := stDefault if len(l) > 0 && l[0] != ' ' && a.textTitle == "Details" { st = stLabel } if a.tleft < len(l) { a.put(0, 2+i, string(l[a.tleft:]), st) } } } func (a *app) drawHelp() { lines := []string{ "dockertop " + version + " - containers and the processes inside them", "", "Container list", " CPU% share of one core (100 = one full core), like docker stats and htop", " MEM/MEM% memory in use, page cache that can be dropped excluded; percent of the container's limit (or host RAM)", " NET RX/TX received and sent per second, all networks; READ/WRITE block I/O per second", " t tree: group containers under their compose project, with the project's totals", "", "Process view (Enter)", " htop for one container: every process in its cgroup, with host PIDs.", " CPU% is per core; MEM% is resident memory against the container's memory limit.", " USER comes from the container's own /etc/passwd when readable (root), else the numeric UID.", "", "Actions (each asks for confirmation)", " x stop a running container, start a stopped one", " r restart p pause / unpause", " k send a signal: to the container's main process in the list, to the selected process in the process view", "", "Keys", " Up/Down PgUp/PgDn Home/End move Enter processes d details (e: environment) l logs (Left/Right scroll)", " / search s cycle sort N/C/M sort by name/CPU/memory t tree a show stopped containers c command line h help q quit / back", "", "Settings (sorts, trees, a, c, refresh) are saved to " + configPath() + " on exit.", "Needs access to the Docker socket (docker group or root). Signalling processes and container user names need root.", "", "Press any key to return.", } for i, l := range lines { st := stDefault if i == 0 || (l != "" && l[0] != ' ') { st = stLabel } a.put(1, i, l, st) } } func (a *app) drawBottom(w, h int) { y := h - 1 a.fill(y, stDefault) if a.searching { f := a.filter if a.view == viewProcs { f = a.pfilter } a.put(0, y, "Search: "+f+"_", stWarn) return } var keys [][2]string switch a.view { case viewList: keys = [][2]string{{"h", "Help"}, {"/", "Search"}, {"s", "Sort"}, {"t", "Tree"}, {"a", "All"}, {"c", "Cmd"}, {"⏎", "Procs"}, {"d", "Details"}, {"l", "Logs"}, {"x", "Stop"}, {"r", "Restart"}, {"p", "Pause"}, {"k", "Kill"}, {"q", "Quit"}} case viewProcs: keys = [][2]string{{"q", "Back"}, {"/", "Search"}, {"s", "Sort"}, {"t", "Tree"}, {"c", "Cmd"}, {"k", "Signal"}, {"d", "Details"}, {"l", "Logs"}, {"x", "Stop"}, {"r", "Restart"}, {"p", "Pause"}} case viewText: keys = [][2]string{{"q", "Back"}, {"↑↓", "Scroll"}, {"←→", "Pan"}, {"End", "Follow"}} if a.textTitle == "Details" { keys = append(keys, [2]string{"e", "Env"}) } } if c, ok := a.current(); ok && a.view != viewText { for i := range keys { switch { case keys[i][0] == "x" && c.State != "running" && c.State != "paused" && c.State != "restarting": keys[i][1] = "Start" case keys[i][0] == "p" && c.State == "paused": keys[i][1] = "Unpause" } } } x := 0 for _, k := range keys { x = a.put(x, y, " "+k[0], stKey) x = a.put(x, y, fmt.Sprintf("%-7s", k[1]), stFn) } } func (a *app) drawModal(w, h int) { m := a.modal lines := append([]string{}, m.lines...) switch { case m.items != nil: lines = append(lines, m.items...) lines = append(lines, "", "[Enter] send [Esc] cancel") case m.onYes != nil: lines = append(lines, "", "[y] yes [n] no") default: lines = append(lines, "", "[Enter] ok") } bw := len(m.title) for _, l := range lines { bw = max(bw, len(l)) } bw = min(bw+4, w) bh := len(lines) + 2 x0, y0 := (w-bw)/2, (h-bh)/2 st := tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver) for y := y0; y < y0+bh; y++ { for x := x0; x < x0+bw; x++ { a.scr.SetContent(x, y, ' ', nil, st) } } a.put(x0+2, y0, m.title, st.Bold(true)) for i, l := range lines { ls := st if m.items != nil && i >= len(m.lines) && i-len(m.lines) == m.pick { ls = stSel for x := x0 + 1; x < x0+bw-1; x++ { a.scr.SetContent(x, y0+1+i, ' ', nil, ls) } } a.put(x0+2, y0+1+i, l, ls) } } // human formats a byte count: 512B, 1.5K, 3.2M, 1.25G. func human(b uint64) string { switch { case b < 1024: return fmt.Sprintf("%dB", b) case b < 1<<20: return fmt.Sprintf("%.1fK", float64(b)/(1<<10)) case b < 1<<30: return fmt.Sprintf("%.1fM", float64(b)/(1<<20)) case b < 1<<40: return fmt.Sprintf("%.2fG", float64(b)/(1<<30)) } return fmt.Sprintf("%.2fT", float64(b)/(1<<40)) }