From aa55ff1a8e679e1b825a2ff79e2efa7cda6d84a2 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 2 Jun 2019 00:28:47 +1000 Subject: [PATCH] more progress --- pkg/commands/container.go | 1 + pkg/commands/docker.go | 126 +++++++++++++---- pkg/commands/service.go | 62 +++++++++ pkg/config/app_config.go | 3 +- pkg/gui/containers_panel.go | 131 ++++++++++-------- pkg/gui/gui.go | 18 ++- pkg/gui/images_panel.go | 31 +++-- pkg/gui/keybindings.go | 38 ++++-- pkg/gui/services_panel.go | 262 ++++++++++++++++++++++++++++++++++++ pkg/gui/view_helpers.go | 7 +- pkg/i18n/english.go | 22 +-- pkg/tasks/tasks.go | 48 +++++++ 12 files changed, 618 insertions(+), 131 deletions(-) create mode 100644 pkg/commands/service.go create mode 100644 pkg/gui/services_panel.go create mode 100644 pkg/tasks/tasks.go diff --git a/pkg/commands/container.go b/pkg/commands/container.go index d29412a1..6c9f5a8c 100644 --- a/pkg/commands/container.go +++ b/pkg/commands/container.go @@ -18,6 +18,7 @@ import ( type Container struct { Name string ServiceName string + ServiceID string ContainerNumber string // might make this an int in the future if need be ProjectName string ID string diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index b55eccb8..8664eeb5 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -3,6 +3,7 @@ package commands import ( "context" "encoding/json" + "fmt" "strings" "github.com/docker/docker/api/types" @@ -10,16 +11,18 @@ import ( "github.com/docker/docker/client" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/i18n" + "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/sirupsen/logrus" ) // DockerCommand is our main git interface type DockerCommand struct { - Log *logrus.Entry - OSCommand *OSCommand - Tr *i18n.Localizer - Config config.AppConfigurer - Client *client.Client + Log *logrus.Entry + OSCommand *OSCommand + Tr *i18n.Localizer + Config config.AppConfigurer + Client *client.Client + InDockerComposeProject bool } // NewDockerCommand it runs git commands @@ -30,11 +33,12 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Localize } return &DockerCommand{ - Log: log, - OSCommand: osCommand, - Tr: tr, - Config: config, - Client: cli, + Log: log, + OSCommand: osCommand, + Tr: tr, + Config: config, + Client: cli, + InDockerComposeProject: true, // TODO: determine this at startup }, nil } @@ -72,7 +76,32 @@ func (c *DockerCommand) UpdateContainerStats(containers []*Container) ([]*Contai return containers, nil } -// GetContainers returns a slice of docker containers +// GetContainersAndServices returns a slice of docker containers +func (c *DockerCommand) GetContainersAndServices() ([]*Container, []*Service, error) { + + containers, err := c.GetContainers() + if err != nil { + return nil, nil, err + } + + services, err := c.GetServices() + if err != nil { + return nil, nil, err + } + + // find out which services have corresponding containers and assign them + for _, service := range services { + for _, container := range containers { + if container.ServiceID != "" && container.ServiceID == service.ID { + service.Container = container + } + } + } + + return containers, services, nil +} + +// GetContainers gets the docker containers func (c *DockerCommand) GetContainers() ([]*Container, error) { containers, err := c.Client.ContainerList(context.Background(), types.ContainerListOptions{All: true}) if err != nil { @@ -81,15 +110,12 @@ func (c *DockerCommand) GetContainers() ([]*Container, error) { ownContainers := make([]*Container, len(containers)) - ids := []string{} - for i, container := range containers { - ids = append(ids, container.ID) - 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, @@ -99,20 +125,7 @@ func (c *DockerCommand) GetContainers() ([]*Container, error) { } } - cmd := c.OSCommand.RunCustomCommand("docker inspect " + strings.Join(ids, " ")) - output, err := cmd.CombinedOutput() - if err != nil { - return nil, err - } - - var details []*Details - if err := json.Unmarshal(output, &details); err != nil { - return nil, err - } - - for i, container := range ownContainers { - container.Details = *details[i] - } + c.UpdateContainerDetails(ownContainers) // ownContainers, err = c.UpdateContainerStats(ownContainers) // if err != nil { @@ -122,6 +135,61 @@ func (c *DockerCommand) GetContainers() ([]*Container, error) { return ownContainers, nil } +// GetServices gets services +func (c *DockerCommand) GetServices() ([]*Service, error) { + if !c.InDockerComposeProject { + return nil, nil + } + + composeCommand := c.Config.GetUserConfig().GetString("commandTemplates.dockerCompose") + output, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --hash=*", composeCommand)) + if err != nil { + return nil, err + } + + // output looks like: + // service1 998d6d286b0499e0ff23d66302e720991a2asdkf9c30d0542034f610daf8a971 + // service2 asdld98asdklasd9bccd02438de0994f8e19cbe691feb3755336ec5ca2c55971 + + lines := utils.SplitLines(output) + services := make([]*Service, len(lines)) + for i, str := range lines { + arr := strings.Split(str, " ") + services[i] = &Service{ + Name: arr[0], + ID: arr[1], + } + } + + return services, nil +} + +// UpdateContainerDetails attaches the details returned from docker inspect to each of the containers +// this contains a bit more info than what you get from the go-docker client +func (c *DockerCommand) UpdateContainerDetails(containers []*Container) error { + ids := make([]string, len(containers)) + for i, container := range containers { + ids[i] = container.ID + } + + cmd := c.OSCommand.RunCustomCommand("docker inspect " + strings.Join(ids, " ")) + output, err := cmd.CombinedOutput() + if err != nil { + return err + } + + var details []*Details + if err := json.Unmarshal(output, &details); err != nil { + return err + } + + for i, container := range containers { + container.Details = *details[i] + } + + return nil +} + // GetImages returns a slice of docker images func (c *DockerCommand) GetImages() ([]*Image, error) { images, err := c.Client.ImageList(context.Background(), types.ImageListOptions{}) diff --git a/pkg/commands/service.go b/pkg/commands/service.go new file mode 100644 index 00000000..f36cf377 --- /dev/null +++ b/pkg/commands/service.go @@ -0,0 +1,62 @@ +package commands + +import ( + "os/exec" + + "github.com/docker/docker/api/types" + "github.com/fatih/color" + "github.com/jesseduffield/lazydocker/pkg/utils" + "github.com/sirupsen/logrus" +) + +// Service : A docker Service +type Service struct { + Name string + ID string + DisplayString string + OSCommand *OSCommand + Log *logrus.Entry + Container *Container +} + +// GetDisplayStrings returns the dispaly string of Container +func (s *Service) GetDisplayStrings(isFocused bool) []string { + + if s.Container == nil { + return []string{"", utils.ColoredString(s.Name, color.FgWhite)} + } + + cont := s.Container + return []string{utils.ColoredString(cont.Container.State, cont.GetColor()), utils.ColoredString(cont.Name, color.FgWhite), cont.Stats.CPUPerc} +} + +// Remove removes the service's containers +func (s *Service) Remove(options types.ContainerRemoveOptions) error { + return s.Container.Remove(options) +} + +// Stop stops the service's containers +func (s *Service) Stop() error { + templateString := s.OSCommand.Config.GetUserConfig().GetString("commandTemplates.stopService") + command := utils.ApplyTemplate(templateString, s) + return s.OSCommand.RunCommand(command) +} + +// Restart restarts the service +func (s *Service) Restart() error { + templateString := s.OSCommand.Config.GetUserConfig().GetString("commandTemplates.restartService") + command := utils.ApplyTemplate(templateString, s) + return s.OSCommand.RunCommand(command) +} + +// Attach attaches to the service +func (s *Service) Attach() *exec.Cmd { + // TODO: if you have a custom command for attaching to a service here is the place to use it + + return s.Container.Attach() +} + +// Top returns process information +func (s *Service) Top() (types.ContainerProcessList, error) { + return s.Container.Top() +} diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index e5b04489..646f9d4a 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -249,7 +249,8 @@ update: reporting: 'undetermined' # one of: 'on' | 'off' | 'undetermined' confirmOnQuit: false commandTemplates: - restartService: "docker-compose restart {{ .ServiceName }}" + restartService: "docker-compose restart {{ .Name }}" + dockerCompose: "apdev compose" `) } diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index b4122124..84f0bd13 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -81,24 +81,21 @@ func (gui *Gui) handleContainerSelect(g *gocui.Gui, v *gocui.View) error { mainView := gui.getMainView() - gui.State.Panels.Main.WriterID++ - writerID := gui.State.Panels.Main.WriterID - mainView.Clear() mainView.SetOrigin(0, 0) mainView.SetCursor(0, 0) switch gui.getContainerContexts()[gui.State.Panels.Containers.ContextIndex] { case "logs": - if err := gui.renderContainerLogs(mainView, container, writerID); err != nil { + if err := gui.renderContainerLogs(mainView, container); err != nil { return err } case "config": - if err := gui.renderContainerConfig(mainView, container, writerID); err != nil { + if err := gui.renderContainerConfig(mainView, container); err != nil { return err } case "stats": - if err := gui.renderContainerStats(mainView, container, writerID); err != nil { + if err := gui.renderContainerStats(mainView, container); err != nil { return err } default: @@ -108,7 +105,7 @@ func (gui *Gui) handleContainerSelect(g *gocui.Gui, v *gocui.View) error { return nil } -func (gui *Gui) renderContainerConfig(mainView *gocui.View, container *commands.Container, writerID int) error { +func (gui *Gui) renderContainerConfig(mainView *gocui.View, container *commands.Container) error { mainView.Autoscroll = false mainView.Title = "Config" @@ -116,12 +113,13 @@ func (gui *Gui) renderContainerConfig(mainView *gocui.View, container *commands. if err != nil { return err } - gui.renderString(gui.g, "main", string(data)) - return nil + return gui.T.NewTask(func(stop chan struct{}) { + gui.renderString(gui.g, "main", string(data)) + }) } -func (gui *Gui) renderContainerStats(mainView *gocui.View, container *commands.Container, writerID int) error { +func (gui *Gui) renderContainerStats(mainView *gocui.View, container *commands.Container) error { mainView.Autoscroll = false mainView.Title = "Stats" @@ -130,7 +128,9 @@ func (gui *Gui) renderContainerStats(mainView *gocui.View, container *commands.C return err } - go func() { + return gui.T.NewTask(func(stop chan struct{}) { + defer stream.Body.Close() + cpuUsageHistory := []float64{} memoryUsageHistory := []float64{} scanner := bufio.NewScanner(stream.Body) @@ -156,31 +156,28 @@ func (gui *Gui) renderContainerStats(mainView *gocui.View, container *commands.C gui.createErrorPanel(gui.g, err.Error()) } - if gui.State.Panels.Main.WriterID != writerID { - stream.Body.Close() + select { + case <-stop: return + default: } gui.reRenderString(gui.g, "main", contents) } - - stream.Body.Close() - }() - - return nil + }) } -func (gui *Gui) renderContainerLogs(mainView *gocui.View, container *commands.Container, writerID int) error { +func (gui *Gui) renderContainerLogs(mainView *gocui.View, container *commands.Container) error { mainView.Autoscroll = true mainView.Title = "Logs" if container.Details.Config.AttachStdin { - return gui.renderLogsForTTYContainer(mainView, container, writerID) + return gui.renderLogsForTTYContainer(mainView, container) } - return gui.renderLogsForRegularContainer(mainView, container, writerID) + return gui.renderLogsForRegularContainer(mainView, container) } -func (gui *Gui) renderLogsForRegularContainer(mainView *gocui.View, container *commands.Container, writerID int) error { +func (gui *Gui) renderLogsForRegularContainer(mainView *gocui.View, container *commands.Container) error { var cmd *exec.Cmd cmd = gui.OSCommand.RunCustomCommand("docker logs --since=60m --timestamps --follow " + container.ID) @@ -191,21 +188,29 @@ func (gui *Gui) renderLogsForRegularContainer(mainView *gocui.View, container *c return nil } -func (gui *Gui) runProcessWithLock(cmd *exec.Cmd) { +func (gui *Gui) obtainLock() { gui.State.MainProcessChan <- struct{}{} gui.State.MainProcessMutex.Lock() - cmd.Start() +} - go func() { - <-gui.State.MainProcessChan - cmd.Process.Kill() - }() - - cmd.Wait() +func (gui *Gui) releaseLock() { gui.State.MainProcessMutex.Unlock() } -func (gui *Gui) renderLogsForTTYContainer(mainView *gocui.View, container *commands.Container, writerID int) error { +func (gui *Gui) runProcessWithLock(cmd *exec.Cmd) { + gui.T.NewTask(func(stop chan struct{}) { + cmd.Start() + + go func() { + <-stop + cmd.Process.Kill() + }() + + cmd.Wait() + }) +} + +func (gui *Gui) renderLogsForTTYContainer(mainView *gocui.View, container *commands.Container) error { var cmd *exec.Cmd cmd = gui.OSCommand.RunCustomCommand("docker logs --since=60m --follow " + container.ID) @@ -219,6 +224,7 @@ func (gui *Gui) renderLogsForTTYContainer(mainView *gocui.View, container *comma s := bufio.NewScanner(r) s.Split(bufio.ScanLines) for s.Scan() { + // I might put a check on the stopped channel here. Would mean more code duplication though mainView.Write(append(s.Bytes(), '\n')) } } @@ -228,13 +234,13 @@ func (gui *Gui) renderLogsForTTYContainer(mainView *gocui.View, container *comma return nil } -func (gui *Gui) refreshContainers() error { +func (gui *Gui) refreshContainersAndServices() error { containersView := gui.getContainersView() if containersView == nil { // if the containersView hasn't been instantiated yet we just return return nil } - if err := gui.refreshStateContainers(); err != nil { + if err := gui.refreshStateContainersAndServices(); err != nil { return err } @@ -245,8 +251,15 @@ func (gui *Gui) refreshContainers() error { gui.State.Panels.Containers.SelectedLine = len(gui.State.Containers) - 1 } - gui.g.Update(func(g *gocui.Gui) error { + // doing the exact same thing for services + if len(gui.State.Services) > 0 && gui.State.Panels.Services.SelectedLine == -1 { + gui.State.Panels.Services.SelectedLine = 0 + } + if len(gui.State.Services)-1 < gui.State.Panels.Services.SelectedLine { + gui.State.Panels.Services.SelectedLine = len(gui.State.Services) - 1 + } + gui.g.Update(func(g *gocui.Gui) error { containersView.Clear() isFocused := gui.g.CurrentView().Name() == "containers" list, err := utils.RenderList(gui.State.Containers, utils.IsFocused(isFocused)) @@ -258,19 +271,37 @@ func (gui *Gui) refreshContainers() error { if containersView == g.CurrentView() { return gui.handleContainerSelect(g, containersView) } + + // doing the exact same thing for services + if !gui.DockerCommand.InDockerComposeProject { + return nil + } + servicesView := gui.getServicesView() + servicesView.Clear() + isFocused = gui.g.CurrentView().Name() == "services" + list, err = utils.RenderList(gui.State.Containers, utils.IsFocused(isFocused)) + if err != nil { + return err + } + fmt.Fprint(servicesView, list) + + if servicesView == g.CurrentView() { + return gui.handleContainerSelect(g, servicesView) + } return nil }) return nil } -func (gui *Gui) refreshStateContainers() error { - containers, err := gui.DockerCommand.GetContainers() +func (gui *Gui) refreshStateContainersAndServices() error { + containers, services, err := gui.DockerCommand.GetContainersAndServices() if err != nil { return err } gui.State.Containers = containers + gui.State.Services = services return nil } @@ -346,22 +377,19 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error { description: gui.Tr.SLocalize("remove"), command: "docker rm " + container.ID[1:10], configOptions: types.ContainerRemoveOptions{}, - runCommand: true, }, { description: gui.Tr.SLocalize("removeWithVolumes"), command: "docker rm --volumes " + container.ID[1:10], configOptions: types.ContainerRemoveOptions{RemoveVolumes: true}, - runCommand: true, }, { description: gui.Tr.SLocalize("cancel"), - runCommand: false, }, } handleMenuPress := func(index int) error { - if !options[index].runCommand { + if options[index].command == "" { return nil } configOptions := options[index].configOptions @@ -374,7 +402,7 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error { if err := container.Remove(configOptions); err != nil { return err } - return gui.refreshContainers() + return gui.refreshContainersAndServices() }, nil) } } else { @@ -382,7 +410,7 @@ func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error { } } - return gui.refreshContainers() + return gui.refreshContainersAndServices() } return gui.createMenu("", options, len(options), handleMenuPress) @@ -400,7 +428,7 @@ func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error { return gui.createErrorPanel(gui.g, err.Error()) } - return gui.refreshContainers() + return gui.refreshContainersAndServices() }) }, nil) @@ -417,7 +445,7 @@ func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error { return gui.createErrorPanel(gui.g, err.Error()) } - return gui.refreshContainers() + return gui.refreshContainersAndServices() }) } @@ -432,18 +460,3 @@ func (gui *Gui) handleContainerAttach(g *gocui.Gui, v *gocui.View) error { gui.SubProcess = c return gui.Errors.ErrSubProcess } - -func (gui *Gui) handleServiceRestart(g *gocui.Gui, v *gocui.View) error { - container, err := gui.getSelectedContainer(g) - if err != nil { - return nil - } - - return gui.WithWaitingStatus(gui.Tr.SLocalize("RestartingStatus"), func() error { - if err := container.RestartService(); err != nil { - return gui.createErrorPanel(gui.g, err.Error()) - } - - return gui.refreshContainers() - }) -} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index cae31cc0..b542f67a 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -23,6 +23,7 @@ import ( "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/i18n" + "github.com/jesseduffield/lazydocker/pkg/tasks" "github.com/jesseduffield/lazydocker/pkg/updates" "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/sirupsen/logrus" @@ -74,6 +75,12 @@ type Gui struct { Updater *updates.Updater statusManager *statusManager waitForIntro sync.WaitGroup + T *tasks.TaskManager +} + +type servicePanelState struct { + SelectedLine int + ContextIndex int // for specifying if you are looking at logs/stats/config/etc } type containerPanelState struct { @@ -87,7 +94,6 @@ type menuPanelState struct { type mainPanelState struct { ObjectKey string - WriterID int } type imagePanelState struct { @@ -96,6 +102,7 @@ type imagePanelState struct { } type panelStates struct { + Services *servicePanelState Containers *containerPanelState Menu *menuPanelState Main *mainPanelState @@ -103,6 +110,7 @@ type panelStates struct { } type guiState struct { + Services []*commands.Service Containers []*commands.Container Images []*commands.Image MenuItemCount int // can't store the actual list because it's of interface{} type @@ -123,11 +131,11 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand PreviousView: "containers", Platform: *oSCommand.Platform, Panels: &panelStates{ + Services: &servicePanelState{SelectedLine: -1, ContextIndex: 0}, Containers: &containerPanelState{SelectedLine: -1, ContextIndex: 0}, Images: &imagePanelState{SelectedLine: -1, ContextIndex: 0}, Menu: &menuPanelState{SelectedLine: 0}, Main: &mainPanelState{ - WriterID: 0, ObjectKey: "", }, }, @@ -151,6 +159,7 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand Tr: tr, Updater: updater, statusManager: &statusManager{}, + T: tasks.NewTaskManager(), } gui.GenerateSentinelErrors() @@ -229,9 +238,6 @@ func (gui *Gui) onFocusLost(v *gocui.View, newView *gocui.View) error { if v == nil { return nil } - if v.Name() == "containers" || v.Name() == "images" { - gui.State.Panels.Main.WriterID++ - } gui.Log.Info(v.Name() + " focus lost") return nil } @@ -512,7 +518,7 @@ func (gui *Gui) Run() error { gui.waitForIntro.Wait() gui.goEvery(time.Millisecond*50, gui.renderAppStatus) gui.goEvery(time.Millisecond*30, gui.reRenderMain) - gui.goEvery(time.Millisecond*500, gui.refreshContainers) + gui.goEvery(time.Millisecond*500, gui.refreshContainersAndServices) }() g.SetManager(gocui.ManagerFunc(gui.layout), gocui.ManagerFunc(gui.getFocusLayout())) diff --git a/pkg/gui/images_panel.go b/pkg/gui/images_panel.go index 8b943dd8..7b1f9ba2 100644 --- a/pkg/gui/images_panel.go +++ b/pkg/gui/images_panel.go @@ -78,16 +78,13 @@ func (gui *Gui) handleImageSelect(g *gocui.Gui, v *gocui.View) error { mainView := gui.getMainView() - gui.State.Panels.Main.WriterID++ - writerID := gui.State.Panels.Main.WriterID - mainView.Clear() mainView.SetOrigin(0, 0) mainView.SetCursor(0, 0) switch gui.getImageContexts()[gui.State.Panels.Images.ContextIndex] { case "config": - if err := gui.renderImageConfig(mainView, Image, writerID); err != nil { + if err := gui.renderImageConfig(mainView, Image); err != nil { return err } default: @@ -97,25 +94,27 @@ func (gui *Gui) handleImageSelect(g *gocui.Gui, v *gocui.View) error { return nil } -func (gui *Gui) renderImageConfig(mainView *gocui.View, image *commands.Image, writerID int) error { +func (gui *Gui) renderImageConfig(mainView *gocui.View, image *commands.Image) error { mainView.Autoscroll = false mainView.Wrap = false mainView.Title = "Config" - output := "" - output += utils.WithPadding("ID: ", 10) + image.Image.ID + "\n" - output += utils.WithPadding("Tags: ", 10) + utils.ColoredString(strings.Join(image.Image.RepoTags, ", "), color.FgGreen) + "\n" - output += utils.WithPadding("Size: ", 10) + utils.FormatDecimalBytes(int(image.Image.Size)) + "\n" - output += utils.WithPadding("Created: ", 10) + fmt.Sprintf("%v", time.Unix(image.Image.Created, 0).Format(time.RFC1123)) + "\n" + gui.T.NewTask(func(stop chan struct{}) { + output := "" + output += utils.WithPadding("ID: ", 10) + image.Image.ID + "\n" + output += utils.WithPadding("Tags: ", 10) + utils.ColoredString(strings.Join(image.Image.RepoTags, ", "), color.FgGreen) + "\n" + output += utils.WithPadding("Size: ", 10) + utils.FormatDecimalBytes(int(image.Image.Size)) + "\n" + output += utils.WithPadding("Created: ", 10) + fmt.Sprintf("%v", time.Unix(image.Image.Created, 0).Format(time.RFC1123)) + "\n" - history, err := image.RenderHistory() - if err != nil { - return err - } + history, err := image.RenderHistory() + if err != nil { + gui.Log.Error(err) + } - output += "\n\n" + history + output += "\n\n" + history - gui.renderString(gui.g, "main", output) + gui.renderString(gui.g, "main", output) + }) return nil } diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 4b365e79..ce666353 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -156,35 +156,56 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Key: 'd', Modifier: gocui.ModNone, Handler: gui.handleContainersRemoveMenu, - Description: gui.Tr.SLocalize("removeContainer"), + Description: gui.Tr.SLocalize("remove"), }, { ViewName: "containers", Key: 's', Modifier: gocui.ModNone, Handler: gui.handleContainerStop, - Description: gui.Tr.SLocalize("stopContainer"), + Description: gui.Tr.SLocalize("stop"), }, { ViewName: "containers", Key: 'r', Modifier: gocui.ModNone, Handler: gui.handleContainerRestart, - Description: gui.Tr.SLocalize("restartContainer"), + Description: gui.Tr.SLocalize("restart"), }, { ViewName: "containers", Key: 'a', Modifier: gocui.ModNone, Handler: gui.handleContainerAttach, - Description: gui.Tr.SLocalize("attachContainer"), + Description: gui.Tr.SLocalize("attach"), }, { - ViewName: "containers", - Key: 'R', + ViewName: "services", + Key: 'd', + Modifier: gocui.ModNone, + Handler: gui.handleServiceRemoveMenu, + Description: gui.Tr.SLocalize("removeService"), + }, + { + ViewName: "services", + Key: 's', + Modifier: gocui.ModNone, + Handler: gui.handleServiceStop, + Description: gui.Tr.SLocalize("stop"), + }, + { + ViewName: "services", + Key: 'r', Modifier: gocui.ModNone, Handler: gui.handleServiceRestart, - Description: gui.Tr.SLocalize("restartService"), + Description: gui.Tr.SLocalize("restart"), + }, + { + ViewName: "services", + Key: 'a', + Modifier: gocui.ModNone, + Handler: gui.handleServiceAttach, + Description: gui.Tr.SLocalize("attach"), }, { ViewName: "images", @@ -216,7 +237,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { } // TODO: add more views here - for _, viewName := range []string{"status", "containers", "images", "menu"} { + for _, viewName := range []string{"status", "services", "containers", "images", "menu"} { bindings = append(bindings, []*Binding{ {ViewName: viewName, Key: gocui.KeyTab, Modifier: gocui.ModNone, Handler: gui.nextView}, {ViewName: viewName, Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: gui.previousView}, @@ -232,6 +253,7 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { focus func(*gocui.Gui, *gocui.View) error }{ "menu": {prevLine: gui.handleMenuPrevLine, nextLine: gui.handleMenuNextLine, focus: gui.handleMenuSelect}, + "services": {prevLine: gui.handleServicesPrevLine, nextLine: gui.handleServicesNextLine, focus: gui.handleServicesFocus}, "containers": {prevLine: gui.handleContainersPrevLine, nextLine: gui.handleContainersNextLine, focus: gui.handleContainersFocus}, "images": {prevLine: gui.handleImagesPrevLine, nextLine: gui.handleImagesNextLine, focus: gui.handleImagesFocus}, } diff --git a/pkg/gui/services_panel.go b/pkg/gui/services_panel.go new file mode 100644 index 00000000..14e49ec1 --- /dev/null +++ b/pkg/gui/services_panel.go @@ -0,0 +1,262 @@ +package gui + +import ( + "encoding/json" + "fmt" + + "github.com/fatih/color" + "github.com/go-errors/errors" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazydocker/pkg/commands" + "github.com/jesseduffield/lazydocker/pkg/utils" +) + +// list panel functions + +func (gui *Gui) getServiceContexts() []string { + return []string{"logs", "config", "stats"} +} + +func (gui *Gui) getSelectedService(g *gocui.Gui) (*commands.Service, error) { + selectedLine := gui.State.Panels.Services.SelectedLine + if selectedLine == -1 { + return &commands.Service{}, errors.New("no service selected") + } + + return gui.State.Services[selectedLine], nil +} + +func (gui *Gui) handleServicesFocus(g *gocui.Gui, v *gocui.View) error { + if gui.popupPanelFocused() { + return nil + } + + cx, cy := v.Cursor() + _, oy := v.Origin() + + prevSelectedLine := gui.State.Panels.Services.SelectedLine + newSelectedLine := cy - oy + + if newSelectedLine > len(gui.State.Services)-1 || len(utils.Decolorise(gui.State.Services[newSelectedLine].Name)) < cx { + return gui.handleServiceSelect(gui.g, v) + } + + gui.State.Panels.Services.SelectedLine = newSelectedLine + + if prevSelectedLine == newSelectedLine && gui.currentViewName() == v.Name() { + return nil + } + + return gui.handleServiceSelect(gui.g, v) +} + +func (gui *Gui) handleServiceSelect(g *gocui.Gui, v *gocui.View) error { + if _, err := gui.g.SetCurrentView(v.Name()); err != nil { + return err + } + + service, err := gui.getSelectedService(g) + if err != nil { + return err + } + + key := service.ID + "-" + gui.getServiceContexts()[gui.State.Panels.Services.ContextIndex] + if gui.State.Panels.Main.ObjectKey == key { + return nil + } else { + gui.State.Panels.Main.ObjectKey = key + } + + if err := gui.focusPoint(0, gui.State.Panels.Services.SelectedLine, len(gui.State.Services), v); err != nil { + return err + } + + mainView := gui.getMainView() + + mainView.Clear() + mainView.SetOrigin(0, 0) + mainView.SetCursor(0, 0) + + switch gui.getServiceContexts()[gui.State.Panels.Services.ContextIndex] { + case "logs": + if err := gui.renderServiceLogs(mainView, service); err != nil { + return err + } + case "config": + if err := gui.renderServiceConfig(mainView, service); err != nil { + return err + } + case "stats": + if err := gui.renderServiceStats(mainView, service); err != nil { + return err + } + default: + return errors.New("Unknown context for services panel") + } + + return nil +} + +func (gui *Gui) renderServiceConfig(mainView *gocui.View, service *commands.Service) error { + mainView.Autoscroll = false + mainView.Title = "Config" + + gui.T.NewTask(func(stop chan struct{}) { + // TODO: actually show service config + data, err := json.MarshalIndent(&service.Container.Container, "", " ") + if err != nil { + gui.Log.Error(err) + return + } + gui.renderString(gui.g, "main", string(data)) + }) + + return nil +} + +func (gui *Gui) renderServiceStats(mainView *gocui.View, service *commands.Service) error { + return nil +} + +func (gui *Gui) renderServiceLogs(mainView *gocui.View, service *commands.Service) error { + return nil +} + +func (gui *Gui) handleServicesNextLine(g *gocui.Gui, v *gocui.View) error { + if gui.popupPanelFocused() { + return nil + } + + panelState := gui.State.Panels.Services + gui.changeSelectedLine(&panelState.SelectedLine, len(gui.State.Services), false) + + return gui.handleServiceSelect(gui.g, v) +} + +func (gui *Gui) handleServicesPrevLine(g *gocui.Gui, v *gocui.View) error { + if gui.popupPanelFocused() { + return nil + } + + panelState := gui.State.Panels.Services + gui.changeSelectedLine(&panelState.SelectedLine, len(gui.State.Services), true) + + return gui.handleServiceSelect(gui.g, v) +} + +func (gui *Gui) handleServicesPrevContext(g *gocui.Gui, v *gocui.View) error { + contexts := gui.getServiceContexts() + if gui.State.Panels.Services.ContextIndex >= len(contexts)-1 { + gui.State.Panels.Services.ContextIndex = 0 + } else { + gui.State.Panels.Services.ContextIndex++ + } + + gui.handleServiceSelect(gui.g, v) + + return nil +} + +func (gui *Gui) handleServicesNextContext(g *gocui.Gui, v *gocui.View) error { + contexts := gui.getServiceContexts() + if gui.State.Panels.Services.ContextIndex <= 0 { + gui.State.Panels.Services.ContextIndex = len(contexts) - 1 + } else { + gui.State.Panels.Services.ContextIndex-- + } + + gui.handleServiceSelect(gui.g, v) + + return nil +} + +type removeServiceOption struct { + description string + command string +} + +// GetDisplayStrings is a function. +func (r *removeServiceOption) GetDisplayStrings(isFocused bool) []string { + return []string{r.description, color.New(color.FgRed).Sprint(r.command)} +} + +func (gui *Gui) handleServiceRemoveMenu(g *gocui.Gui, v *gocui.View) error { + service, err := gui.getSelectedService(g) + if err != nil { + return nil + } + + composeCommand := gui.Config.GetUserConfig().GetString("commandTemplates.dockerCompose") + + options := []*removeServiceOption{ + { + description: gui.Tr.SLocalize("remove"), + command: fmt.Sprintf("%s rm --stop --force %s", composeCommand, service.Name), + }, + { + description: gui.Tr.SLocalize("removeWithVolumes"), + command: fmt.Sprintf("%s rm --stop --force -v %s", composeCommand, service.Name), + }, + { + description: gui.Tr.SLocalize("cancel"), + }, + } + + handleMenuPress := func(index int) error { + if options[index].command == "" { + return nil + } + if err := gui.OSCommand.RunCommand(options[index].command); err != nil { + return gui.createErrorPanel(gui.g, err.Error()) + } + + return gui.refreshContainersAndServices() + } + + return gui.createMenu("", options, len(options), handleMenuPress) +} + +func (gui *Gui) handleServiceStop(g *gocui.Gui, v *gocui.View) error { + service, err := gui.getSelectedService(g) + if err != nil { + return nil + } + + return gui.createConfirmationPanel(gui.g, v, gui.Tr.SLocalize("Confirm"), gui.Tr.SLocalize("StopService"), func(g *gocui.Gui, v *gocui.View) error { + return gui.WithWaitingStatus(gui.Tr.SLocalize("StoppingStatus"), func() error { + if err := service.Stop(); err != nil { + return gui.createErrorPanel(gui.g, err.Error()) + } + + return gui.refreshContainersAndServices() + }) + + }, nil) +} + +func (gui *Gui) handleServiceRestart(g *gocui.Gui, v *gocui.View) error { + service, err := gui.getSelectedService(g) + if err != nil { + return nil + } + + return gui.WithWaitingStatus(gui.Tr.SLocalize("RestartingStatus"), func() error { + if err := service.Restart(); err != nil { + return gui.createErrorPanel(gui.g, err.Error()) + } + + return gui.refreshContainersAndServices() + }) +} + +func (gui *Gui) handleServiceAttach(g *gocui.Gui, v *gocui.View) error { + service, err := gui.getSelectedService(g) + if err != nil { + return nil + } + + c := service.Attach() + + gui.SubProcess = c + return gui.Errors.ErrSubProcess +} diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 14df07a4..d37df856 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -13,7 +13,7 @@ import ( var cyclableViews = []string{"status", "containers", "images"} func (gui *Gui) refreshSidePanels(g *gocui.Gui) error { - if err := gui.refreshContainers(); err != nil { + if err := gui.refreshContainersAndServices(); err != nil { return err } if err := gui.refreshImages(); err != nil { @@ -223,6 +223,11 @@ func (gui *Gui) renderOptionsMap(optionsMap map[string]string) error { return gui.renderString(gui.g, "options", gui.optionsMapToString(optionsMap)) } +func (gui *Gui) getServicesView() *gocui.View { + v, _ := gui.g.View("services") + return v +} + func (gui *Gui) getContainersView() *gocui.View { v, _ := gui.g.View("containers") return v diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 2478abc3..0a06ad1e 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -445,7 +445,7 @@ func addEnglish(i18nObject *i18n.Bundle) error { ID: "ignoreContainer", Other: `add to .gitignore`, }, &i18n.Message{ - ID: "refreshContainers", + ID: "refreshContainersAndServices", Other: `refresh containers`, }, &i18n.Message{ ID: "mergeIntoCurrentBranch", @@ -785,20 +785,20 @@ func addEnglish(i18nObject *i18n.Bundle) error { Other: "stopping", }, &i18n.Message{ - ID: "removeContainer", - Other: "remove container", + ID: "remove", + Other: "remove", }, &i18n.Message{ - ID: "stopContainer", - Other: "stop container", + ID: "removeService", + Other: "remove containers", }, &i18n.Message{ - ID: "restartContainer", - Other: "restart container", + ID: "stop", + Other: "stop", }, &i18n.Message{ - ID: "restartService", - Other: "restart container's service", + ID: "restart", + Other: "restart", }, &i18n.Message{ ID: "previousContext", @@ -809,8 +809,8 @@ func addEnglish(i18nObject *i18n.Bundle) error { Other: "next context", }, &i18n.Message{ - ID: "attachContainer", - Other: "attach to container", + ID: "attach", + Other: "attach", }, &i18n.Message{ ID: "ContainersTitle", diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go new file mode 100644 index 00000000..666f9077 --- /dev/null +++ b/pkg/tasks/tasks.go @@ -0,0 +1,48 @@ +package tasks + +import "sync" + +type TaskManager struct { + waitingTasks []*Task + currentTask *Task + waitingMutex sync.Mutex +} + +type Task struct { + stop chan struct{} + notifyStopped chan struct{} +} + +func NewTaskManager() *TaskManager { + return &TaskManager{} +} + +func (t *TaskManager) NewTask(f func(stop chan struct{})) error { + t.waitingMutex.Lock() + defer t.waitingMutex.Unlock() + + if t.currentTask != nil { + t.currentTask.Stop() + } + + stop := make(chan struct{}, 1) // we don't want to block on this in case the task already returned + notifyStopped := make(chan struct{}) + + t.currentTask = &Task{ + stop: stop, + notifyStopped: notifyStopped, + } + + go func() { + f(stop) + notifyStopped <- struct{}{} + }() + + return nil +} + +func (t *Task) Stop() { + t.stop <- struct{}{} + <-t.notifyStopped + return +}