You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
550 lines
16 KiB
Go
550 lines
16 KiB
Go
package gui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
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/tasks"
|
|
"github.com/jesseduffield/lazydocker/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container] {
|
|
// Standalone containers are containers which are either one-off containers, or whose service is not part of this docker-compose context.
|
|
isStandaloneContainer := func(container *commands.Container) bool {
|
|
if container.OneOff || container.ServiceName == "" {
|
|
return true
|
|
}
|
|
|
|
return !lo.SomeBy(gui.Panels.Services.List.GetAllItems(), func(service *commands.Service) bool {
|
|
return service.Name == container.ServiceName
|
|
})
|
|
}
|
|
|
|
return &panels.SideListPanel[*commands.Container]{
|
|
ContextState: &panels.ContextState[*commands.Container]{
|
|
GetMainTabs: func() []panels.MainTab[*commands.Container] {
|
|
return []panels.MainTab[*commands.Container]{
|
|
{
|
|
Key: "logs",
|
|
Title: gui.Tr.LogsTitle,
|
|
Render: gui.renderContainerLogsToMain,
|
|
},
|
|
{
|
|
Key: "stats",
|
|
Title: gui.Tr.StatsTitle,
|
|
Render: gui.renderContainerStats,
|
|
},
|
|
{
|
|
Key: "env",
|
|
Title: gui.Tr.EnvTitle,
|
|
Render: gui.renderContainerEnv,
|
|
},
|
|
{
|
|
Key: "config",
|
|
Title: gui.Tr.ConfigTitle,
|
|
Render: gui.renderContainerConfig,
|
|
},
|
|
{
|
|
Key: "top",
|
|
Title: gui.Tr.TopTitle,
|
|
Render: gui.renderContainerTop,
|
|
},
|
|
}
|
|
},
|
|
GetItemContextCacheKey: func(container *commands.Container) string {
|
|
// Including the container state in the cache key so that if the container
|
|
// restarts we re-read the logs. In the past we've had some glitchiness
|
|
// where a container restarts but the new logs don't get read.
|
|
// Note that this might be jarring if we have a lot of logs and the container
|
|
// restarts a lot, so let's keep an eye on it.
|
|
return "containers-" + container.ID + "-" + container.Container.State
|
|
},
|
|
},
|
|
ListPanel: panels.ListPanel[*commands.Container]{
|
|
List: panels.NewFilteredList[*commands.Container](),
|
|
View: gui.Views.Containers,
|
|
},
|
|
NoItemsMessage: gui.Tr.NoContainers,
|
|
Gui: gui.intoInterface(),
|
|
// sortedContainers returns containers sorted by state if c.SortContainersByState is true (follows 1- running, 2- exited, 3- created)
|
|
// and sorted by name if c.SortContainersByState is false
|
|
Sort: func(a *commands.Container, b *commands.Container) bool {
|
|
return sortContainers(a, b, gui.Config.UserConfig.Gui.LegacySortContainers)
|
|
},
|
|
Filter: func(container *commands.Container) bool {
|
|
// Note that this is O(N*M) time complexity where N is the number of services
|
|
// and M is the number of containers. We expect N to be small but M may be large,
|
|
// so we will need to keep an eye on this.
|
|
if !gui.Config.UserConfig.Gui.ShowAllContainers && !isStandaloneContainer(container) {
|
|
return false
|
|
}
|
|
|
|
if !gui.State.ShowExitedContainers && container.Container.State == "exited" {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
},
|
|
GetTableCells: presentation.GetContainerDisplayStrings,
|
|
}
|
|
}
|
|
|
|
var containerStates = map[string]int{
|
|
"running": 1,
|
|
"exited": 2,
|
|
"created": 3,
|
|
}
|
|
|
|
func sortContainers(a *commands.Container, b *commands.Container, legacySort bool) bool {
|
|
if legacySort {
|
|
return a.Name < b.Name
|
|
}
|
|
|
|
stateLeft := containerStates[a.Container.State]
|
|
stateRight := containerStates[b.Container.State]
|
|
if stateLeft == stateRight {
|
|
return a.Name < b.Name
|
|
}
|
|
|
|
return containerStates[a.Container.State] < containerStates[b.Container.State]
|
|
}
|
|
|
|
func (gui *Gui) renderContainerEnv(container *commands.Container) tasks.TaskFunc {
|
|
return gui.NewSimpleRenderStringTask(func() string { return gui.containerEnv(container) })
|
|
}
|
|
|
|
func (gui *Gui) containerEnv(container *commands.Container) string {
|
|
if !container.DetailsLoaded() {
|
|
return gui.Tr.WaitingForContainerInfo
|
|
}
|
|
|
|
if len(container.Details.Config.Env) == 0 {
|
|
return gui.Tr.NothingToDisplay
|
|
}
|
|
|
|
envVarsList := lo.Map(container.Details.Config.Env, func(envVar string, _ int) []string {
|
|
splitEnv := strings.SplitN(envVar, "=", 2)
|
|
key := splitEnv[0]
|
|
value := ""
|
|
if len(splitEnv) > 1 {
|
|
value = splitEnv[1]
|
|
}
|
|
return []string{
|
|
utils.ColoredString(key+":", color.FgGreen),
|
|
utils.ColoredString(value, color.FgYellow),
|
|
}
|
|
})
|
|
|
|
output, err := utils.RenderTable(envVarsList)
|
|
if err != nil {
|
|
gui.Log.Error(err)
|
|
return gui.Tr.CannotDisplayEnvVariables
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
func (gui *Gui) renderContainerConfig(container *commands.Container) tasks.TaskFunc {
|
|
return gui.NewSimpleRenderStringTask(func() string { return gui.containerConfigStr(container) })
|
|
}
|
|
|
|
func (gui *Gui) containerConfigStr(container *commands.Container) string {
|
|
if !container.DetailsLoaded() {
|
|
return gui.Tr.WaitingForContainerInfo
|
|
}
|
|
|
|
padding := 10
|
|
output := ""
|
|
output += utils.WithPadding("ID: ", padding) + container.ID + "\n"
|
|
output += utils.WithPadding("Name: ", padding) + container.Name + "\n"
|
|
output += utils.WithPadding("Image: ", padding) + container.Details.Config.Image + "\n"
|
|
output += utils.WithPadding("Command: ", padding) + strings.Join(append([]string{container.Details.Path}, container.Details.Args...), " ") + "\n"
|
|
output += utils.WithPadding("Labels: ", padding) + utils.FormatMap(padding, container.Details.Config.Labels)
|
|
output += "\n"
|
|
|
|
output += utils.WithPadding("Mounts: ", padding)
|
|
if len(container.Details.Mounts) > 0 {
|
|
output += "\n"
|
|
for _, mount := range container.Details.Mounts {
|
|
if mount.Type == "volume" {
|
|
output += fmt.Sprintf("%s%s %s\n", strings.Repeat(" ", padding), utils.ColoredString(string(mount.Type)+":", color.FgYellow), mount.Name)
|
|
} else {
|
|
output += fmt.Sprintf("%s%s %s:%s\n", strings.Repeat(" ", padding), utils.ColoredString(string(mount.Type)+":", color.FgYellow), mount.Source, mount.Destination)
|
|
}
|
|
}
|
|
} else {
|
|
output += "none\n"
|
|
}
|
|
|
|
output += utils.WithPadding("Ports: ", padding)
|
|
if len(container.Details.NetworkSettings.Ports) > 0 {
|
|
output += "\n"
|
|
for k, v := range container.Details.NetworkSettings.Ports {
|
|
for _, host := range v {
|
|
output += fmt.Sprintf("%s%s %s\n", strings.Repeat(" ", padding), utils.ColoredString(host.HostPort+":", color.FgYellow), k)
|
|
}
|
|
}
|
|
} else {
|
|
output += "none\n"
|
|
}
|
|
|
|
data, err := utils.MarshalIntoYaml(&container.Details)
|
|
if err != nil {
|
|
return fmt.Sprintf("Error marshalling container details: %v", err)
|
|
}
|
|
|
|
output += fmt.Sprintf("\nFull details:\n\n%s", utils.ColoredYamlString(string(data)))
|
|
|
|
return output
|
|
}
|
|
|
|
func (gui *Gui) renderContainerStats(container *commands.Container) tasks.TaskFunc {
|
|
return gui.NewTickerTask(TickerTaskOpts{
|
|
Func: func(ctx context.Context, notifyStopped chan struct{}) {
|
|
contents, err := presentation.RenderStats(gui.Config.UserConfig, container, gui.Views.Main.Width())
|
|
if err != nil {
|
|
_ = gui.createErrorPanel(err.Error())
|
|
}
|
|
|
|
gui.reRenderStringMain(contents)
|
|
},
|
|
Duration: time.Second,
|
|
Before: func(ctx context.Context) { gui.clearMainView() },
|
|
Wrap: false, // wrapping looks bad here so we're overriding the config value
|
|
Autoscroll: false,
|
|
})
|
|
}
|
|
|
|
func (gui *Gui) renderContainerTop(container *commands.Container) tasks.TaskFunc {
|
|
return gui.NewTickerTask(TickerTaskOpts{
|
|
Func: func(ctx context.Context, notifyStopped chan struct{}) {
|
|
contents, err := container.RenderTop(ctx)
|
|
if err != nil {
|
|
gui.RenderStringMain(err.Error())
|
|
}
|
|
|
|
gui.reRenderStringMain(contents)
|
|
},
|
|
Duration: time.Second,
|
|
Before: func(ctx context.Context) { gui.clearMainView() },
|
|
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
|
|
Autoscroll: false,
|
|
})
|
|
}
|
|
|
|
func (gui *Gui) refreshContainersAndServices() error {
|
|
if gui.Views.Containers == nil {
|
|
// if the containersView hasn't been instantiated yet we just return
|
|
return nil
|
|
}
|
|
|
|
// keep track of current service selected so that we can reposition our cursor if it moves position in the list
|
|
originalSelectedLineIdx := gui.Panels.Services.SelectedIdx
|
|
selectedService, isServiceSelected := gui.Panels.Services.List.TryGet(originalSelectedLineIdx)
|
|
|
|
containers, services, err := gui.DockerCommand.RefreshContainersAndServices(
|
|
gui.Panels.Services.List.GetAllItems(),
|
|
gui.Panels.Containers.List.GetAllItems(),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
gui.Panels.Services.SetItems(services)
|
|
gui.Panels.Containers.SetItems(containers)
|
|
|
|
// see if our selected service has moved
|
|
if isServiceSelected {
|
|
for i, service := range gui.Panels.Services.List.GetItems() {
|
|
if service.ID == selectedService.ID {
|
|
if i == originalSelectedLineIdx {
|
|
break
|
|
}
|
|
gui.Panels.Services.SetSelectedLineIdx(i)
|
|
gui.Panels.Services.Refocus()
|
|
}
|
|
}
|
|
}
|
|
|
|
return gui.renderContainersAndServices()
|
|
}
|
|
|
|
func (gui *Gui) renderContainersAndServices() error {
|
|
if gui.DockerCommand.InDockerComposeProject {
|
|
if err := gui.Panels.Services.RerenderList(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := gui.Panels.Containers.RerenderList(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) handleHideStoppedContainers(g *gocui.Gui, v *gocui.View) error {
|
|
gui.State.ShowExitedContainers = !gui.State.ShowExitedContainers
|
|
|
|
return gui.Panels.Containers.RerenderList()
|
|
}
|
|
|
|
func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error {
|
|
container, err := gui.Panels.Containers.GetSelectedItem()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
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) {
|
|
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.MustForceToRemoveContainer, func(g *gocui.Gui, v *gocui.View) error {
|
|
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
|
|
configOptions.Force = true
|
|
return container.Remove(configOptions)
|
|
})
|
|
}, nil)
|
|
}
|
|
return gui.createErrorPanel(err.Error())
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
menuItems := []*types.MenuItem{
|
|
{
|
|
LabelColumns: []string{gui.Tr.Remove, "docker rm " + container.ID[1:10]},
|
|
OnPress: func() error { return handleMenuPress(dockerTypes.ContainerRemoveOptions{}) },
|
|
},
|
|
{
|
|
LabelColumns: []string{gui.Tr.RemoveWithVolumes, "docker rm --volumes " + container.ID[1:10]},
|
|
OnPress: func() error { return handleMenuPress(dockerTypes.ContainerRemoveOptions{RemoveVolumes: true}) },
|
|
},
|
|
}
|
|
|
|
return gui.Menu(CreateMenuOptions{
|
|
Title: "",
|
|
Items: menuItems,
|
|
})
|
|
}
|
|
|
|
func (gui *Gui) PauseContainer(container *commands.Container) error {
|
|
return gui.WithWaitingStatus(gui.Tr.PausingStatus, func() (err error) {
|
|
if container.Details.State.Paused {
|
|
err = container.Unpause()
|
|
} else {
|
|
err = container.Pause()
|
|
}
|
|
|
|
if err != nil {
|
|
return gui.createErrorPanel(err.Error())
|
|
}
|
|
|
|
return gui.refreshContainersAndServices()
|
|
})
|
|
}
|
|
|
|
func (gui *Gui) handleContainerPause(g *gocui.Gui, v *gocui.View) error {
|
|
container, err := gui.Panels.Containers.GetSelectedItem()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
return gui.PauseContainer(container)
|
|
}
|
|
|
|
func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error {
|
|
container, err := gui.Panels.Containers.GetSelectedItem()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.StopContainer, func(g *gocui.Gui, v *gocui.View) error {
|
|
return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error {
|
|
if err := container.Stop(); err != nil {
|
|
return gui.createErrorPanel(err.Error())
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}, nil)
|
|
}
|
|
|
|
func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error {
|
|
container, err := gui.Panels.Containers.GetSelectedItem()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error {
|
|
if err := container.Restart(); err != nil {
|
|
return gui.createErrorPanel(err.Error())
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (gui *Gui) handleContainerAttach(g *gocui.Gui, v *gocui.View) error {
|
|
container, err := gui.Panels.Containers.GetSelectedItem()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
c, err := container.Attach()
|
|
if err != nil {
|
|
return gui.createErrorPanel(err.Error())
|
|
}
|
|
|
|
return gui.runSubprocess(c)
|
|
}
|
|
|
|
func (gui *Gui) handlePruneContainers() error {
|
|
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmPruneContainers, func(g *gocui.Gui, v *gocui.View) error {
|
|
return gui.WithWaitingStatus(gui.Tr.PruningStatus, func() error {
|
|
err := gui.DockerCommand.PruneContainers()
|
|
if err != nil {
|
|
return gui.createErrorPanel(err.Error())
|
|
}
|
|
return nil
|
|
})
|
|
}, nil)
|
|
}
|
|
|
|
func (gui *Gui) handleContainerViewLogs(g *gocui.Gui, v *gocui.View) error {
|
|
container, err := gui.Panels.Containers.GetSelectedItem()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
gui.renderLogsToStdout(container)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) handleContainersExecShell(g *gocui.Gui, v *gocui.View) error {
|
|
container, err := gui.Panels.Containers.GetSelectedItem()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
return gui.containerExecShell(container)
|
|
}
|
|
|
|
func (gui *Gui) containerExecShell(container *commands.Container) error {
|
|
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{
|
|
Container: container,
|
|
})
|
|
|
|
// TODO: use SDK
|
|
resolvedCommand := utils.ApplyTemplate("docker exec -it {{ .Container.ID }} /bin/sh -c 'eval $(grep ^$(id -un): /etc/passwd | cut -d : -f 7-)'", commandObject)
|
|
// attach and return the subprocess error
|
|
cmd := gui.OSCommand.ExecutableFromString(resolvedCommand)
|
|
return gui.runSubprocess(cmd)
|
|
}
|
|
|
|
func (gui *Gui) handleContainersCustomCommand(g *gocui.Gui, v *gocui.View) error {
|
|
container, err := gui.Panels.Containers.GetSelectedItem()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{
|
|
Container: container,
|
|
})
|
|
|
|
customCommands := gui.Config.UserConfig.CustomCommands.Containers
|
|
|
|
return gui.createCustomCommandMenu(customCommands, commandObject)
|
|
}
|
|
|
|
func (gui *Gui) handleStopContainers() error {
|
|
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmStopContainers, func(g *gocui.Gui, v *gocui.View) error {
|
|
return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error {
|
|
for _, container := range gui.Panels.Containers.List.GetAllItems() {
|
|
if err := container.Stop(); err != nil {
|
|
gui.Log.Error(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}, nil)
|
|
}
|
|
|
|
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(dockerTypes.ContainerRemoveOptions{Force: true}); err != nil {
|
|
gui.Log.Error(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}, nil)
|
|
}
|
|
|
|
func (gui *Gui) handleContainersBulkCommand(g *gocui.Gui, v *gocui.View) error {
|
|
baseBulkCommands := []config.CustomCommand{
|
|
{
|
|
Name: gui.Tr.StopAllContainers,
|
|
InternalFunction: gui.handleStopContainers,
|
|
},
|
|
{
|
|
Name: gui.Tr.RemoveAllContainers,
|
|
InternalFunction: gui.handleRemoveContainers,
|
|
},
|
|
{
|
|
Name: gui.Tr.PruneContainers,
|
|
InternalFunction: gui.handlePruneContainers,
|
|
},
|
|
}
|
|
|
|
bulkCommands := append(baseBulkCommands, gui.Config.UserConfig.BulkCommands.Containers...)
|
|
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{})
|
|
|
|
return gui.createBulkCommandMenu(bulkCommands, commandObject)
|
|
}
|
|
|
|
// Open first port in browser
|
|
func (gui *Gui) handleContainersOpenInBrowserCommand(g *gocui.Gui, v *gocui.View) error {
|
|
container, err := gui.Panels.Containers.GetSelectedItem()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
return gui.openContainerInBrowser(container)
|
|
}
|
|
|
|
func (gui *Gui) openContainerInBrowser(container *commands.Container) error {
|
|
// skip if no any ports
|
|
if len(container.Container.Ports) == 0 {
|
|
return nil
|
|
}
|
|
// skip if the first port is not published
|
|
port := container.Container.Ports[0]
|
|
if port.IP == "" {
|
|
return nil
|
|
}
|
|
ip := port.IP
|
|
if ip == "0.0.0.0" {
|
|
ip = "localhost"
|
|
}
|
|
link := fmt.Sprintf("http://%s:%d/", ip, port.PublicPort)
|
|
return gui.OSCommand.OpenLink(link)
|
|
}
|