configurable graphs

pull/1/head
Jesse Duffield 5 years ago
parent 5bf1a40d2f
commit 28b488b740

@ -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]
}

@ -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())
}
}
}

@ -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
// check if we already data stored against the container
for _, existingContainer := range existingContainers {
if existingContainer.ID == container.ID {
newContainer = existingContainer
break
}
}
// 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
// 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

@ -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",
},
},
},
}
}

@ -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)
}
}
})
}

@ -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
}

@ -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
}

Loading…
Cancel
Save