lazydocker/pkg/gui/container_logs.go
DevLeonardoK d5f6eee69f Support cycling the log tail count from the services panel too
The services panel's Logs tab renders the underlying container's logs
through the same code path, so bind 't' there as well and include the
tail value in the services context cache key so the tab re-renders
when the value changes. Also switch the tail-limiting writer to
SetContent so the clear+rewrite is atomic and the view never renders
empty in between.
2026-07-14 21:45:37 -03:00

233 lines
6.1 KiB
Go

package gui
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
"github.com/fatih/color"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazydocker/pkg/commands"
"github.com/jesseduffield/lazydocker/pkg/tasks"
"github.com/jesseduffield/lazydocker/pkg/utils"
)
// containerLogsTailPresets are the values we cycle through when the user asks to
// change how many lines of logs are displayed. An empty string means 'show all logs'.
var containerLogsTailPresets = []string{"", "50", "100", "200", "500"}
func containerLogsTailLabel(tail string) string {
if tail == "" {
return "all"
}
return tail
}
// cycleContainerLogsTail moves to the next tail preset (wrapping back to the start)
// and forces the logs tab to re-render with the new value.
func (gui *Gui) cycleContainerLogsTail(g *gocui.Gui, v *gocui.View) error {
currentIdx := 0
for i, preset := range containerLogsTailPresets {
if preset == gui.State.ContainerLogsTail {
currentIdx = i
break
}
}
gui.State.ContainerLogsTail = containerLogsTailPresets[(currentIdx+1)%len(containerLogsTailPresets)]
if v != nil && v.Name() == "services" {
return gui.Panels.Services.HandleSelect()
}
return gui.Panels.Containers.HandleSelect()
}
// tailLimitingWriter wraps a writer and re-renders it on every line so that it only
// ever displays the last maxLines lines, keeping a true sliding window rather than
// just applying `--tail` to the initial snapshot and then growing forever as new
// lines are followed in.
type tailLimitingWriter struct {
view *gocui.View
maxLines int
lines []string
partial string
}
func newTailLimitingWriter(view *gocui.View, maxLines int) *tailLimitingWriter {
return &tailLimitingWriter{view: view, maxLines: maxLines}
}
func (w *tailLimitingWriter) Write(p []byte) (int, error) {
w.partial += string(p)
split := strings.Split(w.partial, "\n")
w.partial = split[len(split)-1]
newLines := split[:len(split)-1]
if len(newLines) == 0 {
return len(p), nil
}
w.lines = append(w.lines, newLines...)
if excess := len(w.lines) - w.maxLines; excess > 0 {
w.lines = w.lines[excess:]
}
// SetContent clears and rewrites atomically, so the view never renders empty
// in between
w.view.SetContent(strings.Join(w.lines, "\n") + "\n")
return len(p), nil
}
func (gui *Gui) renderContainerLogsToMain(container *commands.Container) tasks.TaskFunc {
return gui.NewTickerTask(TickerTaskOpts{
Func: func(ctx context.Context, notifyStopped chan struct{}) {
gui.renderContainerLogsToMainAux(container, ctx, notifyStopped)
},
Duration: time.Millisecond * 200,
// TODO: see why this isn't working (when switching from Top tab to Logs tab in the services panel, the tops tab's content isn't removed)
Before: func(ctx context.Context) { gui.clearMainView() },
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
Autoscroll: true,
})
}
func (gui *Gui) renderContainerLogsToMainAux(container *commands.Container, ctx context.Context, notifyStopped chan struct{}) {
gui.clearMainView()
defer func() {
notifyStopped <- struct{}{}
}()
mainView := gui.Views.Main
tail := gui.State.ContainerLogsTail
mainView.Subtitle = fmt.Sprintf("tail: %s (%s to cycle)", containerLogsTailLabel(tail), "t")
var writer io.Writer = mainView
if maxLines, err := strconv.Atoi(tail); err == nil && maxLines > 0 {
// keep re-truncating to the chosen number of lines as new ones stream in,
// instead of just limiting the initial snapshot and growing unbounded after that
writer = newTailLimitingWriter(mainView, maxLines)
}
if err := gui.writeContainerLogs(container, ctx, writer); err != nil {
gui.Log.Error(err)
}
// if we are here because the task has been stopped, we should return
// if we are here then the container must have exited, meaning we should wait until it's back again before
ticker := time.NewTicker(time.Millisecond * 100)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
result, err := container.Inspect()
if err != nil {
// if we get an error, then the container has probably been removed so we'll get out of here
gui.Log.Error(err)
return
}
if result.State.Running {
return
}
}
}
}
func (gui *Gui) renderLogsToStdout(container *commands.Container) {
stop := make(chan os.Signal, 1)
defer signal.Stop(stop)
ctx, cancel := context.WithCancel(context.Background())
go func() {
signal.Notify(stop, os.Interrupt)
<-stop
cancel()
}()
if err := gui.g.Suspend(); err != nil {
gui.Log.Error(err)
return
}
defer func() {
if err := gui.g.Resume(); err != nil {
gui.Log.Error(err)
}
}()
if err := gui.writeContainerLogs(container, ctx, os.Stdout); err != nil {
gui.Log.Error(err)
return
}
gui.promptToReturn()
}
func (gui *Gui) promptToReturn() {
if !gui.Config.UserConfig.Gui.ReturnImmediately {
fmt.Fprintf(os.Stdout, "\n\n%s", utils.ColoredString(gui.Tr.PressEnterToReturn, color.FgGreen))
// wait for enter press
if _, err := fmt.Scanln(); err != nil {
gui.Log.Error(err)
}
}
}
func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context, writer io.Writer) error {
readCloser, err := gui.DockerCommand.Client.ContainerLogs(ctx, ctr.ID, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Timestamps: gui.Config.UserConfig.Logs.Timestamps,
Since: gui.Config.UserConfig.Logs.Since,
Tail: gui.State.ContainerLogsTail,
Follow: true,
})
if err != nil {
gui.Log.Error(err)
return err
}
defer readCloser.Close()
if !ctr.DetailsLoaded() {
// loop until the details load or context is cancelled, using timer
ticker := time.NewTicker(time.Millisecond * 100)
defer ticker.Stop()
outer:
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if ctr.DetailsLoaded() {
break outer
}
}
}
}
if ctr.Details.Config.Tty {
_, err = io.Copy(writer, readCloser)
if err != nil {
return err
}
} else {
_, err = stdcopy.StdCopy(writer, writer, readCloser)
if err != nil {
return err
}
}
return nil
}