diff --git a/pkg/commands/container.go b/pkg/commands/container.go index 7ded44e9..fc9482fa 100644 --- a/pkg/commands/container.go +++ b/pkg/commands/container.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker/client" "github.com/fatih/color" "github.com/go-errors/errors" + "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/sirupsen/logrus" "golang.org/x/xerrors" @@ -28,10 +29,21 @@ type Container struct { DisplayString string Client *client.Client OSCommand *OSCommand + Config *config.AppConfig Log *logrus.Entry - Stats ContainerCliStat - StatHistory []ContainerCliStat + CLIStats ContainerCliStat // for realtime we use the CLI, for long-term we use the client + StatHistory []RecordedStats Details Details + MonitoringStats bool +} +type RecordedStats struct { + ClientStats ContainerStats + DerivedStats DerivedStats +} + +type DerivedStats struct { + CPUPercentage float64 + MemoryPercentage float64 } type Details struct { @@ -246,7 +258,7 @@ func (c *Container) GetDisplayStrings(isFocused bool) []string { // GetDisplayCPUPerc colors the cpu percentage based on how extreme it is func (c *Container) GetDisplayCPUPerc() string { - stats := c.GetStats() + stats := c.CLIStats if stats.CPUPerc == "" { return "" @@ -341,11 +353,3 @@ func (c *Container) Attach() (*exec.Cmd, error) { func (c *Container) Top() (types.ContainerProcessList, error) { return c.Client.ContainerTop(context.Background(), c.ID, []string{}) } - -// GetStats gets the container's most recent stats -func (c *Container) GetStats() ContainerCliStat { - if len(c.StatHistory) == 0 { - return ContainerCliStat{} - } - return c.StatHistory[len(c.StatHistory)-1] -} diff --git a/pkg/commands/container_stats.go b/pkg/commands/container_stats.go index cfc47179..62aa7f51 100644 --- a/pkg/commands/container_stats.go +++ b/pkg/commands/container_stats.go @@ -4,11 +4,16 @@ import ( "encoding/json" "fmt" "math" + "reflect" + "strconv" + "strings" "time" "github.com/carlosms/asciigraph" "github.com/fatih/color" + "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/mcuadros/go-lookup" ) // ContainerStats autogenerated at https://mholt.github.io/json-to-go/ @@ -143,6 +148,7 @@ func (s *ContainerStats) CalculateContainerCPUPercentage() float64 { // CalculateContainerMemoryUsage calculates the memory usage of the container as a percent of total available memory func (s *ContainerStats) CalculateContainerMemoryUsage() float64 { + value := float64(s.MemoryStats.Usage*100) / float64(s.MemoryStats.Limit) if math.IsNaN(value) { return 0 @@ -151,44 +157,34 @@ func (s *ContainerStats) CalculateContainerMemoryUsage() float64 { } // RenderStats returns a string containing the rendered stats of the container -func (s *ContainerStats) RenderStats(viewWidth int, cpuUsageHistory []float64, memoryUsageHistory []float64) (string, error) { - memoryGraph := asciigraph.Plot( - memoryUsageHistory, - asciigraph.Height(10), - asciigraph.Width(viewWidth-10), - asciigraph.Min(0), - asciigraph.Max(100), - asciigraph.Caption( - fmt.Sprintf( - "%.2f%% Memory (%s/%s)", - memoryUsageHistory[len(memoryUsageHistory)-1], - utils.FormatBinaryBytes(s.MemoryStats.Usage), - utils.FormatBinaryBytes(int(s.MemoryStats.Limit)), - ), - ), - ) +func (c *Container) RenderStats(viewWidth int) (string, error) { + history := c.StatHistory + if len(history) == 0 { + return "", nil + } + currentStats := history[len(history)-1] - cpuGraph := asciigraph.Plot( - cpuUsageHistory, - asciigraph.Height(10), - asciigraph.Width(viewWidth-10), - asciigraph.Min(0), - asciigraph.Max(100), - asciigraph.Caption(fmt.Sprintf("%.2f%% CPU", cpuUsageHistory[len(cpuUsageHistory)-1])), - ) + graphSpecs := c.Config.UserConfig.Stats.Graphs + graphs := make([]string, len(graphSpecs)) + for i, spec := range graphSpecs { + graph, err := c.PlotGraph(spec, viewWidth-10) + if err != nil { + return "", err + } + graphs[i] = utils.ColoredString(graph, utils.GetColorAttribute(spec.Color)) + } - pidsCount := fmt.Sprintf("PIDs: %d", s.PidsStats.Current) - dataReceived := fmt.Sprintf("Traffic received: %s", utils.FormatDecimalBytes(s.Networks.Eth0.RxBytes)) - dataSent := fmt.Sprintf("Traffic sent: %s", utils.FormatDecimalBytes(s.Networks.Eth0.TxBytes)) + pidsCount := fmt.Sprintf("PIDs: %d", currentStats.ClientStats.PidsStats.Current) + dataReceived := fmt.Sprintf("Traffic received: %s", utils.FormatDecimalBytes(currentStats.ClientStats.Networks.Eth0.RxBytes)) + dataSent := fmt.Sprintf("Traffic sent: %s", utils.FormatDecimalBytes(currentStats.ClientStats.Networks.Eth0.TxBytes)) - originalJSON, err := json.MarshalIndent(s, "", " ") + originalJSON, err := json.MarshalIndent(currentStats, "", " ") if err != nil { return "", err } - contents := fmt.Sprintf("\n\n%s\n\n%s\n\n%s\n\n%s\n%s\n\n%s", - utils.ColoredString(cpuGraph, color.FgCyan), - utils.ColoredString(memoryGraph, color.FgGreen), + contents := fmt.Sprintf("\n\n%s\n\n%s\n\n%s\n%s\n\n%s", + utils.ColoredString(strings.Join(graphs, "\n\n"), color.FgGreen), pidsCount, dataReceived, dataSent, @@ -197,3 +193,68 @@ func (s *ContainerStats) RenderStats(viewWidth int, cpuUsageHistory []float64, m return contents, nil } + +// PlotGraph returns the plotted graph based on the graph spec and the stat history +func (c *Container) PlotGraph(spec config.GraphConfig, width int) (string, error) { + data := make([]float64, len(c.StatHistory)) + for i, stats := range c.StatHistory { + value, err := lookup.LookupString(stats, spec.StatPath) + if err != nil { + return "", err + } + floatValue, err := getFloat(value.Interface()) + if err != nil { + return "", err + } + data[i] = floatValue + } + + return asciigraph.Plot( + data, + asciigraph.Height(spec.Height), + asciigraph.Width(width), + asciigraph.Min(spec.Min), + asciigraph.Max(spec.Max), + asciigraph.Caption(spec.Caption), + ), nil +} + +// from Dave C's answer at https://stackoverflow.com/questions/20767724/converting-unknown-interface-to-float64-in-golang +func getFloat(unk interface{}) (float64, error) { + floatType := reflect.TypeOf(float64(0)) + stringType := reflect.TypeOf("") + + switch i := unk.(type) { + case float64: + return i, nil + case float32: + return float64(i), nil + case int64: + return float64(i), nil + case int32: + return float64(i), nil + case int: + return float64(i), nil + case uint64: + return float64(i), nil + case uint32: + return float64(i), nil + case uint: + return float64(i), nil + case string: + return strconv.ParseFloat(i, 64) + default: + v := reflect.ValueOf(unk) + v = reflect.Indirect(v) + if v.Type().ConvertibleTo(floatType) { + fv := v.Convert(floatType) + return fv.Float(), nil + } else if v.Type().ConvertibleTo(stringType) { + sv := v.Convert(stringType) + s := sv.String() + return strconv.ParseFloat(s, 64) + } else { + return math.NaN(), fmt.Errorf("Can't convert %v to float64", v.Type()) + } + } +} diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index f6416df5..43bdc703 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -8,6 +8,7 @@ import ( "sort" "strings" "sync" + "time" "github.com/acarl005/stripansi" "github.com/docker/docker/api/types" @@ -53,9 +54,13 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Localize }, nil } -// MonitorContainerStats monitors a stream of container stats and updates the containers as each new stats object is received func (c *DockerCommand) MonitorContainerStats() { + go c.MonitorCLIContainerStats() + go c.MonitorClientContainerStats() +} +// MonitorCLIContainerStats monitors a stream of container stats and updates the containers as each new stats object is received +func (c *DockerCommand) MonitorCLIContainerStats() { command := `docker stats --all --no-trunc --format '{{json .}}'` cmd := c.OSCommand.RunCustomCommand(command) @@ -80,7 +85,7 @@ func (c *DockerCommand) MonitorContainerStats() { c.ContainerMutex.Lock() for _, container := range c.Containers { if container.ID == stats.ID { - container.StatHistory = append(container.StatHistory, stats) + container.CLIStats = stats } } c.ContainerMutex.Unlock() @@ -91,6 +96,52 @@ func (c *DockerCommand) MonitorContainerStats() { return } +func (c *DockerCommand) MonitorClientContainerStats() { + // periodically loop through running containers and see if we need to create a monitor goroutine for any + // every second we check if we need to spawn a new goroutine + for range time.Tick(time.Second) { + for _, container := range c.Containers { + if !container.MonitoringStats { + go c.createClientStatMonitor(container) + } + } + } +} + +func (c *DockerCommand) createClientStatMonitor(container *Container) { + container.MonitoringStats = true + stream, err := c.Client.ContainerStats(context.Background(), container.ID, true) + if err != nil { + c.ErrorChan <- err + return + } + + defer stream.Body.Close() + + scanner := bufio.NewScanner(stream.Body) + for scanner.Scan() { + data := scanner.Bytes() + var stats ContainerStats + json.Unmarshal(data, &stats) + + recordedStats := RecordedStats{ + ClientStats: stats, + DerivedStats: DerivedStats{ + CPUPercentage: stats.CalculateContainerCPUPercentage(), + MemoryPercentage: stats.CalculateContainerMemoryUsage(), + }, + } + + c.ContainerMutex.Lock() + // TODO: for now we never truncate the recorded stats, and we should + container.StatHistory = append(container.StatHistory, recordedStats) + c.ContainerMutex.Unlock() + } + + container.MonitoringStats = false + return +} + // GetContainersAndServices returns a slice of docker containers func (c *DockerCommand) GetContainersAndServices() error { c.ServiceMutex.Lock() @@ -147,7 +198,7 @@ func (c *DockerCommand) GetContainers() ([]*Container, error) { c.ContainerMutex.Lock() defer c.ContainerMutex.Unlock() - currentContainers := c.Containers + existingContainers := c.Containers containers, err := c.Client.ContainerList(context.Background(), types.ContainerListOptions{All: true}) if err != nil { @@ -157,26 +208,35 @@ func (c *DockerCommand) GetContainers() ([]*Container, error) { ownContainers := make([]*Container, len(containers)) for i, container := range containers { - ownContainers[i] = &Container{ - ID: container.ID, - Name: strings.TrimLeft(container.Names[0], "/"), - ServiceName: container.Labels["com.docker.compose.service"], - ServiceID: container.Labels["com.docker.compose.config-hash"], - ProjectName: container.Labels["com.docker.compose.project"], - ContainerNumber: container.Labels["com.docker.compose.container"], - Container: container, - Client: c.Client, - OSCommand: c.OSCommand, - Log: c.Log, - } + var newContainer *Container - // bring across old stats information - for _, currentContainer := range currentContainers { - if currentContainer.ID == container.ID { - ownContainers[i].StatHistory = currentContainer.StatHistory // might need to use pointers here in case we're deep copying everything - ownContainers[i].Details = currentContainer.Details + // check if we already data stored against the container + for _, existingContainer := range existingContainers { + if existingContainer.ID == container.ID { + newContainer = existingContainer + break } } + + // initialise the container if it's completely new + if newContainer == nil { + newContainer = &Container{ + ID: container.ID, + Client: c.Client, + OSCommand: c.OSCommand, + Log: c.Log, + Config: c.Config, + } + } + + newContainer.Container = container + newContainer.Name = strings.TrimLeft(container.Names[0], "/") + newContainer.ServiceName = container.Labels["com.docker.compose.service"] + newContainer.ServiceID = container.Labels["com.docker.compose.config-hash"] + newContainer.ProjectName = container.Labels["com.docker.compose.project"] + newContainer.ContainerNumber = container.Labels["com.docker.compose.container"] + + ownContainers[i] = newContainer } return ownContainers, nil diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 6d2a294c..8b7468c6 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -83,6 +83,7 @@ type UserConfig struct { CommandTemplates CommandTemplatesConfig `yaml:"commandTemplates,omitempty"` OS OSConfig `yaml:"oS,omitempty"` Update UpdateConfig `yaml:"update,omitempty"` + Stats StatsConfig `yaml:"stats,omitempty"` } type ThemeConfig struct { @@ -114,6 +115,20 @@ type UpdateConfig struct { Method string `yaml:"method,omitempty"` } +// GraphConfig specifies how to make a graph of recorded container stats +type GraphConfig struct { + Min float64 `yaml:"min,omitempty"` + Max float64 `yaml:"max,omitempty"` + Height int `yaml:"height,omitempty"` + Caption string `yaml:"caption,omitempty"` + StatPath string `yaml:"statPath,omitempty"` + Color string `yaml:"color,omitempty"` +} + +type StatsConfig struct { + Graphs []GraphConfig +} + // GetDefaultConfig returns the application default configuration func GetDefaultConfig() UserConfig { return UserConfig{ @@ -139,6 +154,26 @@ func GetDefaultConfig() UserConfig { Update: UpdateConfig{ Method: "never", }, + Stats: StatsConfig{ + Graphs: []GraphConfig{ + { + Min: 0, + Max: 100, + Height: 10, + Caption: "CPU (%)", + StatPath: "DerivedStats.CPUPercentage", + Color: "blue", + }, + { + Min: 0, + Max: 100, + Height: 10, + Caption: "Memory (%)", + StatPath: "DerivedStats.MemoryPercentage", + Color: "green", + }, + }, + }, } } diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index cb6cd9d1..046a4250 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -2,10 +2,10 @@ package gui import ( "bufio" - "context" "encoding/json" "fmt" "os/exec" + "time" "github.com/docker/docker/api/types" "github.com/fatih/color" @@ -123,46 +123,22 @@ func (gui *Gui) renderContainerStats(mainView *gocui.View, container *commands.C mainView.Autoscroll = false mainView.Title = "Stats" - stream, err := gui.DockerCommand.Client.ContainerStats(context.Background(), container.ID, true) - if err != nil { - return err - } - return gui.T.NewTask(func(stop chan struct{}) { - defer stream.Body.Close() - - cpuUsageHistory := []float64{} - memoryUsageHistory := []float64{} - scanner := bufio.NewScanner(stream.Body) - for scanner.Scan() { - data := scanner.Bytes() - var stats commands.ContainerStats - json.Unmarshal(data, &stats) - - cpuUsageHistory = append(cpuUsageHistory, stats.CalculateContainerCPUPercentage()) - if len(cpuUsageHistory) >= 20 { - cpuUsageHistory = cpuUsageHistory[1:] - } - - memoryUsageHistory = append(memoryUsageHistory, stats.CalculateContainerMemoryUsage()) - if len(memoryUsageHistory) >= 20 { - memoryUsageHistory = memoryUsageHistory[1:] - } - - width, _ := mainView.Size() - - contents, err := stats.RenderStats(width, cpuUsageHistory, memoryUsageHistory) - if err != nil { - gui.createErrorPanel(gui.g, err.Error()) - } - + tickChan := time.NewTicker(time.Second) + for { select { case <-stop: return - default: - } + case <-tickChan.C: + width, _ := mainView.Size() - gui.reRenderString(gui.g, "main", contents) + contents, err := container.RenderStats(width) + if err != nil { + gui.createErrorPanel(gui.g, err.Error()) + } + + gui.reRenderString(gui.g, "main", contents) + } } }) } diff --git a/pkg/gui/theme.go b/pkg/gui/theme.go index 62c2c5a9..9f06b31f 100644 --- a/pkg/gui/theme.go +++ b/pkg/gui/theme.go @@ -2,36 +2,14 @@ package gui import ( "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazydocker/pkg/utils" ) -// GetAttribute gets the gocui color attribute from the string -func (gui *Gui) GetAttribute(key string) gocui.Attribute { - colorMap := map[string]gocui.Attribute{ - "default": gocui.ColorDefault, - "black": gocui.ColorBlack, - "red": gocui.ColorRed, - "green": gocui.ColorGreen, - "yellow": gocui.ColorYellow, - "blue": gocui.ColorBlue, - "magenta": gocui.ColorMagenta, - "cyan": gocui.ColorCyan, - "white": gocui.ColorWhite, - "bold": gocui.AttrBold, - "reverse": gocui.AttrReverse, - "underline": gocui.AttrUnderline, - } - value, present := colorMap[key] - if present { - return value - } - return gocui.ColorWhite -} - // GetColor bitwise OR's a list of attributes obtained via the given keys func (gui *Gui) GetColor(keys []string) gocui.Attribute { var attribute gocui.Attribute for _, key := range keys { - attribute = attribute | gui.GetAttribute(key) + attribute = attribute | utils.GetGocuiAttribute(key) } return attribute } diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 1d04699e..977175e2 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -12,6 +12,7 @@ import ( "time" "github.com/go-errors/errors" + "github.com/jesseduffield/gocui" "github.com/fatih/color" ) @@ -296,3 +297,48 @@ func ApplyTemplate(str string, object interface{}) string { template.Must(template.New("").Parse(str)).Execute(&buf, object) return buf.String() } + +// GetGocuiAttribute gets the gocui color attribute from the string +func GetGocuiAttribute(key string) gocui.Attribute { + colorMap := map[string]gocui.Attribute{ + "default": gocui.ColorDefault, + "black": gocui.ColorBlack, + "red": gocui.ColorRed, + "green": gocui.ColorGreen, + "yellow": gocui.ColorYellow, + "blue": gocui.ColorBlue, + "magenta": gocui.ColorMagenta, + "cyan": gocui.ColorCyan, + "white": gocui.ColorWhite, + "bold": gocui.AttrBold, + "reverse": gocui.AttrReverse, + "underline": gocui.AttrUnderline, + } + value, present := colorMap[key] + if present { + return value + } + return gocui.ColorWhite +} + +// GetColorAttribute gets the color attribute from the string +func GetColorAttribute(key string) color.Attribute { + colorMap := map[string]color.Attribute{ + "default": color.FgWhite, + "black": color.FgBlack, + "red": color.FgRed, + "green": color.FgGreen, + "yellow": color.FgYellow, + "blue": color.FgBlue, + "magenta": color.FgMagenta, + "cyan": color.FgCyan, + "white": color.FgWhite, + "bold": color.Bold, + "underline": color.Underline, + } + value, present := colorMap[key] + if present { + return value + } + return color.FgWhite +}