diff --git a/pkg/commands/container.go b/pkg/commands/container.go index 44544812..033f8ce2 100644 --- a/pkg/commands/container.go +++ b/pkg/commands/container.go @@ -4,18 +4,14 @@ import ( "context" "fmt" "os/exec" - "sort" - "strconv" "strings" "github.com/docker/docker/api/types/container" - "github.com/samber/lo" "github.com/sasha-s/go-deadlock" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "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/i18n" @@ -34,13 +30,13 @@ type Container struct { OneOff bool ProjectName string ID string - Container types.Container + Container dockerTypes.Container Client *client.Client OSCommand *OSCommand Config *config.AppConfig Log *logrus.Entry StatHistory []*RecordedStats - Details types.ContainerJSON + Details dockerTypes.ContainerJSON MonitoringStats bool DockerCommand LimitedDockerCommand Tr *i18n.TranslationSet @@ -48,126 +44,8 @@ type Container struct { StatsMutex deadlock.Mutex } -// TODO: move this stuff into a presentation layer - -func (c *Container) DisplayPorts() string { - portStrings := lo.Map(c.Container.Ports, func(port types.Port, _ int) string { - if port.PublicPort == 0 { - return fmt.Sprintf("%d/%s", port.PrivatePort, port.Type) - } - - // docker ps will show '0.0.0.0:80->80/tcp' but we'll show - // '80->80/tcp' instead to save space (unless the IP is something other than - // 0.0.0.0) - ipString := "" - if port.IP != "0.0.0.0" { - ipString = port.IP + ":" - } - return fmt.Sprintf("%s%d->%d/%s", ipString, port.PublicPort, port.PrivatePort, port.Type) - }) - - // sorting because the order of the ports is not deterministic - // and we don't want to have them constantly swapping - sort.Strings(portStrings) - - return strings.Join(portStrings, ", ") -} - -// GetDisplayStatus returns the colored status of the container -func (c *Container) GetDisplayStatus() string { - return utils.ColoredString(c.Container.State, c.GetColor()) -} - -// GetDisplayStatus returns the exit code if the container has exited, and the health status if the container is running (and has a health check) -func (c *Container) GetDisplaySubstatus() string { - if !c.DetailsLoaded() { - return "" - } - - switch c.Container.State { - case "exited": - return utils.ColoredString( - fmt.Sprintf("(%s)", strconv.Itoa(c.Details.State.ExitCode)), c.GetColor(), - ) - case "running": - return c.getHealthStatus() - default: - return "" - } -} - -func (c *Container) getHealthStatus() string { - if !c.DetailsLoaded() { - return "" - } - - healthStatusColorMap := map[string]color.Attribute{ - "healthy": color.FgGreen, - "unhealthy": color.FgRed, - "starting": color.FgYellow, - } - - if c.Details.State.Health == nil { - return "" - } - healthStatus := c.Details.State.Health.Status - if healthStatusColor, ok := healthStatusColorMap[healthStatus]; ok { - return utils.ColoredString(fmt.Sprintf("(%s)", healthStatus), healthStatusColor) - } - return "" -} - -// GetDisplayCPUPerc colors the cpu percentage based on how extreme it is -func (c *Container) GetDisplayCPUPerc() string { - stats, ok := c.getLastStats() - if !ok { - return "" - } - - percentage := stats.DerivedStats.CPUPercentage - formattedPercentage := fmt.Sprintf("%.2f%%", stats.DerivedStats.CPUPercentage) - - var clr color.Attribute - if percentage > 90 { - clr = color.FgRed - } else if percentage > 50 { - clr = color.FgYellow - } else { - clr = color.FgWhite - } - - return utils.ColoredString(formattedPercentage, clr) -} - -// GetColor Container color -func (c *Container) GetColor() color.Attribute { - switch c.Container.State { - case "exited": - // This means the colour may be briefly yellow and then switch to red upon starting - // Not sure what a better alternative is. - if !c.DetailsLoaded() || c.Details.State.ExitCode == 0 { - return color.FgYellow - } - return color.FgRed - case "created": - return color.FgCyan - case "running": - return color.FgGreen - case "paused": - return color.FgYellow - case "dead": - return color.FgRed - case "restarting": - return color.FgBlue - case "removing": - return color.FgMagenta - default: - return color.FgWhite - } -} - // Remove removes the container -func (c *Container) Remove(options types.ContainerRemoveOptions) error { +func (c *Container) Remove(options dockerTypes.ContainerRemoveOptions) error { c.Log.Warn(fmt.Sprintf("removing container %s", c.Name)) if err := c.Client.ContainerRemove(context.Background(), c.ID, options); err != nil { if strings.Contains(err.Error(), "Stop the container before attempting removal or force remove") { @@ -250,7 +128,7 @@ func (c *DockerCommand) PruneContainers() error { } // Inspect returns details about the container -func (c *Container) Inspect() (types.ContainerJSON, error) { +func (c *Container) Inspect() (dockerTypes.ContainerJSON, error) { return c.Client.ContainerInspect(context.Background(), c.ID) } diff --git a/pkg/commands/container_stats.go b/pkg/commands/container_stats.go index 5caab8d9..3579743b 100644 --- a/pkg/commands/container_stats.go +++ b/pkg/commands/container_stats.go @@ -168,7 +168,7 @@ func (s *ContainerStats) CalculateContainerMemoryUsage() float64 { // RenderStats returns a string containing the rendered stats of the container func (c *Container) RenderStats(viewWidth int) (string, error) { - stats, ok := c.getLastStats() + stats, ok := c.GetLastStats() if !ok { return "", nil } @@ -225,7 +225,7 @@ func (c *Container) eraseOldHistory() { } } -func (c *Container) getLastStats() (*RecordedStats, bool) { +func (c *Container) GetLastStats() (*RecordedStats, bool) { c.StatsMutex.Lock() defer c.StatsMutex.Unlock() history := c.StatHistory diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 06551afb..75a916fc 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/docker/docker/client" "github.com/imdario/mergo" "github.com/jesseduffield/lazydocker/pkg/commands/ssh" @@ -189,7 +189,7 @@ func (c *DockerCommand) GetContainers(existingContainers []*Container) ([]*Conta c.ContainerMutex.Lock() defer c.ContainerMutex.Unlock() - containers, err := c.Client.ContainerList(context.Background(), types.ContainerListOptions{All: true}) + containers, err := c.Client.ContainerList(context.Background(), dockerTypes.ContainerListOptions{All: true}) if err != nil { return nil, err } diff --git a/pkg/commands/image.go b/pkg/commands/image.go index ae8d29ee..5e29f198 100644 --- a/pkg/commands/image.go +++ b/pkg/commands/image.go @@ -7,7 +7,7 @@ import ( "github.com/docker/docker/api/types/image" "github.com/samber/lo" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" "github.com/fatih/color" @@ -20,7 +20,7 @@ type Image struct { Name string Tag string ID string - Image types.ImageSummary + Image dockerTypes.ImageSummary Client *client.Client OSCommand *OSCommand Log *logrus.Entry @@ -28,7 +28,7 @@ type Image struct { } // Remove removes the image -func (i *Image) Remove(options types.ImageRemoveOptions) error { +func (i *Image) Remove(options dockerTypes.ImageRemoveOptions) error { if _, err := i.Client.ImageRemove(context.Background(), i.ID, options); err != nil { return err } @@ -94,7 +94,7 @@ func (i *Image) RenderHistory() (string, error) { // RefreshImages returns a slice of docker images func (c *DockerCommand) RefreshImages() ([]*Image, error) { - images, err := c.Client.ImageList(context.Background(), types.ImageListOptions{}) + images, err := c.Client.ImageList(context.Background(), dockerTypes.ImageListOptions{}) if err != nil { return nil, err } diff --git a/pkg/commands/service.go b/pkg/commands/service.go index fe16d75c..599eb597 100644 --- a/pkg/commands/service.go +++ b/pkg/commands/service.go @@ -5,7 +5,7 @@ import ( "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/sirupsen/logrus" ) @@ -21,7 +21,7 @@ type Service struct { } // Remove removes the service's containers -func (s *Service) Remove(options types.ContainerRemoveOptions) error { +func (s *Service) Remove(options dockerTypes.ContainerRemoveOptions) error { return s.Container.Remove(options) } diff --git a/pkg/commands/volume.go b/pkg/commands/volume.go index a527b121..e49736b8 100644 --- a/pkg/commands/volume.go +++ b/pkg/commands/volume.go @@ -3,7 +3,7 @@ package commands import ( "context" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" "github.com/sirupsen/logrus" @@ -12,7 +12,7 @@ import ( // Volume : A docker Volume type Volume struct { Name string - Volume *types.Volume + Volume *dockerTypes.Volume Client *client.Client OSCommand *OSCommand Log *logrus.Entry diff --git a/pkg/gui/container_logs.go b/pkg/gui/container_logs.go index dc1cc895..4759a1f9 100644 --- a/pkg/gui/container_logs.go +++ b/pkg/gui/container_logs.go @@ -8,7 +8,7 @@ import ( "os/signal" "time" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/docker/docker/pkg/stdcopy" "github.com/fatih/color" "github.com/jesseduffield/lazydocker/pkg/commands" @@ -107,7 +107,7 @@ func (gui *Gui) promptToReturn() { } func (gui *Gui) writeContainerLogs(container *commands.Container, ctx context.Context, writer io.Writer) error { - readCloser, err := gui.DockerCommand.Client.ContainerLogs(ctx, container.ID, types.ContainerLogsOptions{ + readCloser, err := gui.DockerCommand.Client.ContainerLogs(ctx, container.ID, dockerTypes.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, Timestamps: gui.Config.UserConfig.Logs.Timestamps, diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index aad842c7..3781f772 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -6,12 +6,14 @@ import ( "strings" "time" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/fatih/color" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/gui/panels" + "github.com/jesseduffield/lazydocker/pkg/gui/presentation" + "github.com/jesseduffield/lazydocker/pkg/gui/types" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/samber/lo" ) @@ -88,18 +90,7 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container] return true }, - GetDisplayStrings: func(container *commands.Container) []string { - image := strings.TrimPrefix(container.Container.Image, "sha256:") - - return []string{ - container.GetDisplayStatus(), - container.GetDisplaySubstatus(), - container.Name, - container.GetDisplayCPUPerc(), - utils.ColoredString(image, color.FgMagenta), - utils.ColoredString(container.DisplayPorts(), color.FgYellow), - } - }, + GetDisplayStrings: presentation.GetContainerDisplayStrings, } } @@ -318,7 +309,7 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error { return nil } - handleMenuPress := func(configOptions types.ContainerRemoveOptions) error { + handleMenuPress := func(configOptions dockerTypes.ContainerRemoveOptions) error { return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { if err := container.Remove(configOptions); err != nil { if commands.HasErrorCode(err, commands.MustStopContainer) { @@ -335,14 +326,14 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error { }) } - menuItems := []*MenuItem{ + menuItems := []*types.MenuItem{ { LabelColumns: []string{gui.Tr.Remove, "docker rm " + container.ID[1:10]}, - OnPress: func() error { return handleMenuPress(types.ContainerRemoveOptions{}) }, + OnPress: func() error { return handleMenuPress(dockerTypes.ContainerRemoveOptions{}) }, }, { LabelColumns: []string{gui.Tr.RemoveWithVolumes, "docker rm --volumes " + container.ID[1:10]}, - OnPress: func() error { return handleMenuPress(types.ContainerRemoveOptions{RemoveVolumes: true}) }, + OnPress: func() error { return handleMenuPress(dockerTypes.ContainerRemoveOptions{RemoveVolumes: true}) }, }, } @@ -500,7 +491,7 @@ func (gui *Gui) handleRemoveContainers() error { return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmRemoveContainers, func(g *gocui.Gui, v *gocui.View) error { return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { for _, container := range gui.Panels.Containers.List.GetAllItems() { - if err := container.Remove(types.ContainerRemoveOptions{Force: true}); err != nil { + if err := container.Remove(dockerTypes.ContainerRemoveOptions{Force: true}); err != nil { gui.Log.Error(err) } } diff --git a/pkg/gui/custom_commands.go b/pkg/gui/custom_commands.go index 091f586b..4580abd5 100644 --- a/pkg/gui/custom_commands.go +++ b/pkg/gui/custom_commands.go @@ -4,12 +4,13 @@ import ( "github.com/fatih/color" "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" + "github.com/jesseduffield/lazydocker/pkg/gui/types" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/samber/lo" ) func (gui *Gui) createCommandMenu(customCommands []config.CustomCommand, commandObject commands.CommandObject, title string, waitingStatus string) error { - menuItems := lo.Map(customCommands, func(command config.CustomCommand, _ int) *MenuItem { + menuItems := lo.Map(customCommands, func(command config.CustomCommand, _ int) *types.MenuItem { resolvedCommand := utils.ApplyTemplate(command.Command, commandObject) onPress := func() error { @@ -31,7 +32,7 @@ func (gui *Gui) createCommandMenu(customCommands []config.CustomCommand, command }) } - return &MenuItem{ + return &types.MenuItem{ LabelColumns: []string{ command.Name, utils.ColoredString(utils.WithShortSha(resolvedCommand), color.FgCyan), diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index bc2f8e30..26ca44a0 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/go-errors/errors" @@ -16,6 +16,7 @@ import ( "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/gui/panels" + "github.com/jesseduffield/lazydocker/pkg/gui/types" "github.com/jesseduffield/lazydocker/pkg/i18n" "github.com/jesseduffield/lazydocker/pkg/tasks" "github.com/sasha-s/go-deadlock" @@ -55,7 +56,7 @@ type Panels struct { Containers *panels.SideListPanel[*commands.Container] Images *panels.SideListPanel[*commands.Image] Volumes *panels.SideListPanel[*commands.Volume] - Menu *panels.SideListPanel[*MenuItem] + Menu *panels.SideListPanel[*types.MenuItem] } type Mutexes struct { @@ -309,7 +310,7 @@ func (gui *Gui) listenForEvents(ctx context.Context, refresh func()) { outer: for { - messageChan, errChan := gui.DockerCommand.Client.Events(context.Background(), types.EventsOptions{}) + messageChan, errChan := gui.DockerCommand.Client.Events(context.Background(), dockerTypes.EventsOptions{}) if errorCount > 0 { select { diff --git a/pkg/gui/images_panel.go b/pkg/gui/images_panel.go index 001b1574..1c503d22 100644 --- a/pkg/gui/images_panel.go +++ b/pkg/gui/images_panel.go @@ -5,12 +5,14 @@ import ( "strings" "time" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/fatih/color" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/gui/panels" + "github.com/jesseduffield/lazydocker/pkg/gui/presentation" + "github.com/jesseduffield/lazydocker/pkg/gui/types" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/samber/lo" ) @@ -60,13 +62,7 @@ func (gui *Gui) getImagesPanel() *panels.SideListPanel[*commands.Image] { return a.ID < b.ID }, - GetDisplayStrings: func(image *commands.Image) []string { - return []string{ - image.Name, - image.Tag, - utils.FormatDecimalBytes(int(image.Image.Size)), - } - }, + GetDisplayStrings: presentation.GetImageDisplayStrings, } } @@ -126,7 +122,7 @@ func (gui *Gui) handleImagesRemoveMenu(g *gocui.Gui, v *gocui.View) error { type removeImageOption struct { description string command string - configOptions types.ImageRemoveOptions + configOptions dockerTypes.ImageRemoveOptions } image, err := gui.Panels.Images.GetSelectedItem() @@ -141,27 +137,27 @@ func (gui *Gui) handleImagesRemoveMenu(g *gocui.Gui, v *gocui.View) error { { description: gui.Tr.Remove, command: "docker image rm " + shortSha, - configOptions: types.ImageRemoveOptions{PruneChildren: true, Force: false}, + configOptions: dockerTypes.ImageRemoveOptions{PruneChildren: true, Force: false}, }, { description: gui.Tr.RemoveWithoutPrune, command: "docker image rm --no-prune " + shortSha, - configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: false}, + configOptions: dockerTypes.ImageRemoveOptions{PruneChildren: false, Force: false}, }, { description: gui.Tr.RemoveWithForce, command: "docker image rm --force " + shortSha, - configOptions: types.ImageRemoveOptions{PruneChildren: true, Force: true}, + configOptions: dockerTypes.ImageRemoveOptions{PruneChildren: true, Force: true}, }, { description: gui.Tr.RemoveWithoutPruneWithForce, command: "docker image rm --no-prune --force " + shortSha, - configOptions: types.ImageRemoveOptions{PruneChildren: false, Force: true}, + configOptions: dockerTypes.ImageRemoveOptions{PruneChildren: false, Force: true}, }, } - menuItems := lo.Map(options, func(option *removeImageOption, _ int) *MenuItem { - return &MenuItem{ + menuItems := lo.Map(options, func(option *removeImageOption, _ int) *types.MenuItem { + return &types.MenuItem{ LabelColumns: []string{ option.description, color.New(color.FgRed).Sprint(option.command), diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 76f57c2a..c1390108 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -2,40 +2,28 @@ package gui import ( "github.com/jesseduffield/lazydocker/pkg/gui/panels" + "github.com/jesseduffield/lazydocker/pkg/gui/presentation" + "github.com/jesseduffield/lazydocker/pkg/gui/types" "github.com/jesseduffield/lazydocker/pkg/utils" ) -type MenuItem struct { - Label string - - // alternative to Label. Allows specifying columns which will be auto-aligned - LabelColumns []string - - OnPress func() error - - // Only applies when Label is used - OpensMenu bool -} - type CreateMenuOptions struct { Title string - Items []*MenuItem + Items []*types.MenuItem HideCancel bool } -func (gui *Gui) getMenuPanel() *panels.SideListPanel[*MenuItem] { - return &panels.SideListPanel[*MenuItem]{ - ListPanel: panels.ListPanel[*MenuItem]{ - List: panels.NewFilteredList[*MenuItem](), +func (gui *Gui) getMenuPanel() *panels.SideListPanel[*types.MenuItem] { + return &panels.SideListPanel[*types.MenuItem]{ + ListPanel: panels.ListPanel[*types.MenuItem]{ + List: panels.NewFilteredList[*types.MenuItem](), View: gui.Views.Menu, }, - NoItemsMessage: "", - Gui: gui.intoInterface(), - OnClick: gui.onMenuPress, - Sort: nil, - GetDisplayStrings: func(menuItem *MenuItem) []string { - return menuItem.LabelColumns - }, + NoItemsMessage: "", + Gui: gui.intoInterface(), + OnClick: gui.onMenuPress, + Sort: nil, + GetDisplayStrings: presentation.GetMenuItemDisplayStrings, OnRerender: func() error { return gui.resizePopupPanel(gui.Views.Menu) }, @@ -47,7 +35,7 @@ func (gui *Gui) getMenuPanel() *panels.SideListPanel[*MenuItem] { } } -func (gui *Gui) onMenuPress(menuItem *MenuItem) error { +func (gui *Gui) onMenuPress(menuItem *types.MenuItem) error { if err := gui.handleMenuClose(); err != nil { return err } @@ -71,7 +59,7 @@ func (gui *Gui) handleMenuPress() error { func (gui *Gui) Menu(opts CreateMenuOptions) error { if !opts.HideCancel { // this is mutative but I'm okay with that for now - opts.Items = append(opts.Items, &MenuItem{ + opts.Items = append(opts.Items, &types.MenuItem{ LabelColumns: []string{gui.Tr.Cancel}, OnPress: func() error { return nil diff --git a/pkg/gui/options_menu_panel.go b/pkg/gui/options_menu_panel.go index 9c3edc17..a7dca6d3 100644 --- a/pkg/gui/options_menu_panel.go +++ b/pkg/gui/options_menu_panel.go @@ -4,6 +4,7 @@ import ( "github.com/samber/lo" "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazydocker/pkg/gui/types" ) func (gui *Gui) getBindings(v *gocui.View) []*Binding { @@ -55,8 +56,8 @@ func (gui *Gui) handleCreateOptionsMenu(g *gocui.Gui, v *gocui.View) error { return nil } - menuItems := lo.Map(gui.getBindings(v), func(binding *Binding, _ int) *MenuItem { - return &MenuItem{ + menuItems := lo.Map(gui.getBindings(v), func(binding *Binding, _ int) *types.MenuItem { + return &types.MenuItem{ LabelColumns: []string{binding.GetKey(), binding.Description}, OnPress: func() error { if binding.Key == nil { diff --git a/pkg/gui/presentation/containers.go b/pkg/gui/presentation/containers.go new file mode 100644 index 00000000..36c5ea09 --- /dev/null +++ b/pkg/gui/presentation/containers.go @@ -0,0 +1,143 @@ +package presentation + +import ( + "fmt" + "sort" + "strconv" + "strings" + + dockerTypes "github.com/docker/docker/api/types" + "github.com/fatih/color" + "github.com/jesseduffield/lazydocker/pkg/commands" + "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/samber/lo" +) + +func GetContainerDisplayStrings(container *commands.Container) []string { + image := strings.TrimPrefix(container.Container.Image, "sha256:") + + return []string{ + getContainerDisplayStatus(container), + getContainerDisplaySubstatus(container), + container.Name, + getDisplayCPUPerc(container), + utils.ColoredString(image, color.FgMagenta), + utils.ColoredString(displayPorts(container), color.FgYellow), + } +} + +func displayPorts(c *commands.Container) string { + portStrings := lo.Map(c.Container.Ports, func(port dockerTypes.Port, _ int) string { + if port.PublicPort == 0 { + return fmt.Sprintf("%d/%s", port.PrivatePort, port.Type) + } + + // docker ps will show '0.0.0.0:80->80/tcp' but we'll show + // '80->80/tcp' instead to save space (unless the IP is something other than + // 0.0.0.0) + ipString := "" + if port.IP != "0.0.0.0" { + ipString = port.IP + ":" + } + return fmt.Sprintf("%s%d->%d/%s", ipString, port.PublicPort, port.PrivatePort, port.Type) + }) + + // sorting because the order of the ports is not deterministic + // and we don't want to have them constantly swapping + sort.Strings(portStrings) + + return strings.Join(portStrings, ", ") +} + +// getContainerDisplayStatus returns the colored status of the container +func getContainerDisplayStatus(c *commands.Container) string { + return utils.ColoredString(c.Container.State, getContainerColor(c)) +} + +// GetDisplayStatus returns the exit code if the container has exited, and the health status if the container is running (and has a health check) +func getContainerDisplaySubstatus(c *commands.Container) string { + if !c.DetailsLoaded() { + return "" + } + + switch c.Container.State { + case "exited": + return utils.ColoredString( + fmt.Sprintf("(%s)", strconv.Itoa(c.Details.State.ExitCode)), getContainerColor(c), + ) + case "running": + return getHealthStatus(c) + default: + return "" + } +} + +func getHealthStatus(c *commands.Container) string { + if !c.DetailsLoaded() { + return "" + } + + healthStatusColorMap := map[string]color.Attribute{ + "healthy": color.FgGreen, + "unhealthy": color.FgRed, + "starting": color.FgYellow, + } + + if c.Details.State.Health == nil { + return "" + } + healthStatus := c.Details.State.Health.Status + if healthStatusColor, ok := healthStatusColorMap[healthStatus]; ok { + return utils.ColoredString(fmt.Sprintf("(%s)", healthStatus), healthStatusColor) + } + return "" +} + +// getDisplayCPUPerc colors the cpu percentage based on how extreme it is +func getDisplayCPUPerc(c *commands.Container) string { + stats, ok := c.GetLastStats() + if !ok { + return "" + } + + percentage := stats.DerivedStats.CPUPercentage + formattedPercentage := fmt.Sprintf("%.2f%%", stats.DerivedStats.CPUPercentage) + + var clr color.Attribute + if percentage > 90 { + clr = color.FgRed + } else if percentage > 50 { + clr = color.FgYellow + } else { + clr = color.FgWhite + } + + return utils.ColoredString(formattedPercentage, clr) +} + +// getContainerColor Container color +func getContainerColor(c *commands.Container) color.Attribute { + switch c.Container.State { + case "exited": + // This means the colour may be briefly yellow and then switch to red upon starting + // Not sure what a better alternative is. + if !c.DetailsLoaded() || c.Details.State.ExitCode == 0 { + return color.FgYellow + } + return color.FgRed + case "created": + return color.FgCyan + case "running": + return color.FgGreen + case "paused": + return color.FgYellow + case "dead": + return color.FgRed + case "restarting": + return color.FgBlue + case "removing": + return color.FgMagenta + default: + return color.FgWhite + } +} diff --git a/pkg/gui/presentation/images.go b/pkg/gui/presentation/images.go new file mode 100644 index 00000000..a420a85d --- /dev/null +++ b/pkg/gui/presentation/images.go @@ -0,0 +1,14 @@ +package presentation + +import ( + "github.com/jesseduffield/lazydocker/pkg/commands" + "github.com/jesseduffield/lazydocker/pkg/utils" +) + +func GetImageDisplayStrings(image *commands.Image) []string { + return []string{ + image.Name, + image.Tag, + utils.FormatDecimalBytes(int(image.Image.Size)), + } +} diff --git a/pkg/gui/presentation/menu_items.go b/pkg/gui/presentation/menu_items.go new file mode 100644 index 00000000..6febd261 --- /dev/null +++ b/pkg/gui/presentation/menu_items.go @@ -0,0 +1,7 @@ +package presentation + +import "github.com/jesseduffield/lazydocker/pkg/gui/types" + +func GetMenuItemDisplayStrings(menuItem *types.MenuItem) []string { + return menuItem.LabelColumns +} diff --git a/pkg/gui/presentation/projects.go b/pkg/gui/presentation/projects.go new file mode 100644 index 00000000..44d396c9 --- /dev/null +++ b/pkg/gui/presentation/projects.go @@ -0,0 +1,7 @@ +package presentation + +import "github.com/jesseduffield/lazydocker/pkg/commands" + +func GetProjectDisplayStrings(project *commands.Project) []string { + return []string{project.Name} +} diff --git a/pkg/gui/presentation/services.go b/pkg/gui/presentation/services.go new file mode 100644 index 00000000..954c2968 --- /dev/null +++ b/pkg/gui/presentation/services.go @@ -0,0 +1,28 @@ +package presentation + +import ( + "github.com/fatih/color" + "github.com/jesseduffield/lazydocker/pkg/commands" + "github.com/jesseduffield/lazydocker/pkg/utils" +) + +func GetServiceDisplayStrings(service *commands.Service) []string { + if service.Container == nil { + return []string{ + utils.ColoredString("none", color.FgBlue), + "", + service.Name, + "", + "", + } + } + + container := service.Container + return []string{ + getContainerDisplayStatus(container), + getContainerDisplaySubstatus(container), + service.Name, + getDisplayCPUPerc(container), + utils.ColoredString(displayPorts(container), color.FgYellow), + } +} diff --git a/pkg/gui/presentation/volumes.go b/pkg/gui/presentation/volumes.go new file mode 100644 index 00000000..c6b90eb1 --- /dev/null +++ b/pkg/gui/presentation/volumes.go @@ -0,0 +1,7 @@ +package presentation + +import "github.com/jesseduffield/lazydocker/pkg/commands" + +func GetVolumeDisplayStrings(volume *commands.Volume) []string { + return []string{volume.Volume.Driver, volume.Name} +} diff --git a/pkg/gui/project_panel.go b/pkg/gui/project_panel.go index c3a192f8..b7c9138c 100644 --- a/pkg/gui/project_panel.go +++ b/pkg/gui/project_panel.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/gui/panels" + "github.com/jesseduffield/lazydocker/pkg/gui/presentation" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/jesseduffield/yaml" ) @@ -63,9 +64,7 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] { Sort: func(a *commands.Project, b *commands.Project) bool { return false }, - GetDisplayStrings: func(project *commands.Project) []string { - return []string{project.Name} - }, + GetDisplayStrings: presentation.GetProjectDisplayStrings, // It doesn't make sense to filter a list of only one item. DisableFilter: true, } diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go index 09a04068..d89d2eb5 100644 --- a/pkg/gui/services_panel.go +++ b/pkg/gui/services_panel.go @@ -9,6 +9,8 @@ import ( "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/gui/panels" + "github.com/jesseduffield/lazydocker/pkg/gui/presentation" + "github.com/jesseduffield/lazydocker/pkg/gui/types" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/samber/lo" ) @@ -70,26 +72,7 @@ func (gui *Gui) getServicesPanel() *panels.SideListPanel[*commands.Service] { return a.Name < b.Name }, - GetDisplayStrings: func(service *commands.Service) []string { - if service.Container == nil { - return []string{ - utils.ColoredString("none", color.FgBlue), - "", - service.Name, - "", - "", - } - } - - cont := service.Container - return []string{ - cont.GetDisplayStatus(), - cont.GetDisplaySubstatus(), - service.Name, - cont.GetDisplayCPUPerc(), - utils.ColoredString(cont.DisplayPorts(), color.FgYellow), - } - }, + GetDisplayStrings: presentation.GetServiceDisplayStrings, Hide: func() bool { return !gui.DockerCommand.InDockerComposeProject }, @@ -174,8 +157,8 @@ func (gui *Gui) handleServiceRemoveMenu(g *gocui.Gui, v *gocui.View) error { }, } - menuItems := lo.Map(options, func(option *commandOption, _ int) *MenuItem { - return &MenuItem{ + menuItems := lo.Map(options, func(option *commandOption, _ int) *types.MenuItem { + return &types.MenuItem{ LabelColumns: option.getDisplayStrings(), OnPress: func() error { return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { @@ -355,8 +338,8 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error { }, } - menuItems := lo.Map(options, func(option *commandOption, _ int) *MenuItem { - return &MenuItem{ + menuItems := lo.Map(options, func(option *commandOption, _ int) *types.MenuItem { + return &types.MenuItem{ LabelColumns: option.getDisplayStrings(), OnPress: option.onPress, } @@ -427,8 +410,8 @@ func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error { }, } - menuItems := lo.Map(options, func(option *commandOption, _ int) *MenuItem { - return &MenuItem{ + menuItems := lo.Map(options, func(option *commandOption, _ int) *types.MenuItem { + return &types.MenuItem{ LabelColumns: option.getDisplayStrings(), OnPress: option.onPress, } diff --git a/pkg/gui/sort_container_test.go b/pkg/gui/sort_container_test.go index ed194888..e4aff5a0 100644 --- a/pkg/gui/sort_container_test.go +++ b/pkg/gui/sort_container_test.go @@ -4,7 +4,7 @@ import ( "sort" "testing" - "github.com/docker/docker/api/types" + dockerTypes "github.com/docker/docker/api/types" "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/stretchr/testify/assert" ) @@ -14,28 +14,28 @@ func sampleContainers() []*commands.Container { { ID: "1", Name: "1", - Container: types.Container{ + Container: dockerTypes.Container{ State: "exited", }, }, { ID: "2", Name: "2", - Container: types.Container{ + Container: dockerTypes.Container{ State: "running", }, }, { ID: "3", Name: "3", - Container: types.Container{ + Container: dockerTypes.Container{ State: "running", }, }, { ID: "4", Name: "4", - Container: types.Container{ + Container: dockerTypes.Container{ State: "created", }, }, @@ -47,28 +47,28 @@ func expectedPerStatusContainers() []*commands.Container { { ID: "2", Name: "2", - Container: types.Container{ + Container: dockerTypes.Container{ State: "running", }, }, { ID: "3", Name: "3", - Container: types.Container{ + Container: dockerTypes.Container{ State: "running", }, }, { ID: "1", Name: "1", - Container: types.Container{ + Container: dockerTypes.Container{ State: "exited", }, }, { ID: "4", Name: "4", - Container: types.Container{ + Container: dockerTypes.Container{ State: "created", }, }, @@ -80,28 +80,28 @@ func expectedLegacySortedContainers() []*commands.Container { { ID: "1", Name: "1", - Container: types.Container{ + Container: dockerTypes.Container{ State: "exited", }, }, { ID: "2", Name: "2", - Container: types.Container{ + Container: dockerTypes.Container{ State: "running", }, }, { ID: "3", Name: "3", - Container: types.Container{ + Container: dockerTypes.Container{ State: "running", }, }, { ID: "4", Name: "4", - Container: types.Container{ + Container: dockerTypes.Container{ State: "created", }, }, diff --git a/pkg/gui/types/types.go b/pkg/gui/types/types.go new file mode 100644 index 00000000..3fd596f3 --- /dev/null +++ b/pkg/gui/types/types.go @@ -0,0 +1,13 @@ +package types + +type MenuItem struct { + Label string + + // alternative to Label. Allows specifying columns which will be auto-aligned + LabelColumns []string + + OnPress func() error + + // Only applies when Label is used + OpensMenu bool +} diff --git a/pkg/gui/volumes_panel.go b/pkg/gui/volumes_panel.go index fc2274e4..fb02383f 100644 --- a/pkg/gui/volumes_panel.go +++ b/pkg/gui/volumes_panel.go @@ -8,6 +8,8 @@ import ( "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/gui/panels" + "github.com/jesseduffield/lazydocker/pkg/gui/presentation" + "github.com/jesseduffield/lazydocker/pkg/gui/types" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/samber/lo" ) @@ -46,9 +48,7 @@ func (gui *Gui) getVolumesPanel() *panels.SideListPanel[*commands.Volume] { } return a.Name < b.Name }, - GetDisplayStrings: func(volume *commands.Volume) []string { - return []string{volume.Volume.Driver, volume.Name} - }, + GetDisplayStrings: presentation.GetVolumeDisplayStrings, } } @@ -130,8 +130,8 @@ func (gui *Gui) handleVolumesRemoveMenu(g *gocui.Gui, v *gocui.View) error { }, } - menuItems := lo.Map(options, func(option *removeVolumeOption, _ int) *MenuItem { - return &MenuItem{ + menuItems := lo.Map(options, func(option *removeVolumeOption, _ int) *types.MenuItem { + return &types.MenuItem{ LabelColumns: []string{option.description, color.New(color.FgRed).Sprint(option.command)}, OnPress: func() error { return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {