From 6f0ecdec9b942ed1c52ec179bbe809cdec875735 Mon Sep 17 00:00:00 2001 From: Artem Yadelskyi Date: Mon, 17 Nov 2025 19:53:18 +0200 Subject: [PATCH] feat: logs column lock (#3669) * feat: logs column lock * feat: logs column lock indicator * feat: fix getting values from config * feat: removed unnecessary new line --- README.md | 3 ++ internal/config/json/schemas/k9s.json | 1 + internal/config/json/testdata/k9s/cool.yaml | 1 + internal/config/json/testdata/k9s/toast.yaml | 1 + internal/config/logger.go | 1 + internal/config/testdata/configs/default.yaml | 1 + .../config/testdata/configs/expected.yaml | 1 + internal/config/testdata/configs/k9s.yaml | 1 + .../config/testdata/configs/k9s_toast.yaml | 1 + internal/view/log.go | 35 ++++++++++++++----- internal/view/log_indicator.go | 19 ++++++++++ internal/view/log_indicator_test.go | 4 +-- internal/view/log_int_test.go | 28 +++++++++++++-- 13 files changed, 85 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index defac15b..3260b1b1 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,8 @@ You can now override the context portForward default address configuration by se textWrap: false # Autoscroll in logs will be disabled. Default is false. disableAutoscroll: false + # Enable column locking when autoscroll is enabled. Default is false. + columnLock: false # Toggles log line timestamp info. Default false showTime: false # Provide shell pod customization when nodeShell feature gate is enabled! @@ -1115,6 +1117,7 @@ k9s: sinceSeconds: -1 textWrap: false disableAutoscroll: false + columnLock: false showTime: false thresholds: cpu: diff --git a/internal/config/json/schemas/k9s.json b/internal/config/json/schemas/k9s.json index 8c5242b6..d057e045 100644 --- a/internal/config/json/schemas/k9s.json +++ b/internal/config/json/schemas/k9s.json @@ -120,6 +120,7 @@ "sinceSeconds": {"type": "integer"}, "textWrap": {"type": "boolean"}, "disableAutoscroll": {"type": "boolean"}, + "columnLock": {"type": "boolean"}, "showTime": {"type": "boolean"} } }, diff --git a/internal/config/json/testdata/k9s/cool.yaml b/internal/config/json/testdata/k9s/cool.yaml index eadf5f10..bae5894c 100644 --- a/internal/config/json/testdata/k9s/cool.yaml +++ b/internal/config/json/testdata/k9s/cool.yaml @@ -31,6 +31,7 @@ k9s: sinceSeconds: -1 textWrap: false disableAutoscroll: false + columnLock: false showTime: false thresholds: cpu: diff --git a/internal/config/json/testdata/k9s/toast.yaml b/internal/config/json/testdata/k9s/toast.yaml index 77dffc0d..691bba69 100644 --- a/internal/config/json/testdata/k9s/toast.yaml +++ b/internal/config/json/testdata/k9s/toast.yaml @@ -24,6 +24,7 @@ k9s: sinceSeconds: -1 textWrap: false disableAutoscroll: false + columnLock: false showTime: false thresholds: cpu: diff --git a/internal/config/logger.go b/internal/config/logger.go index 5087f82f..85b6da7d 100644 --- a/internal/config/logger.go +++ b/internal/config/logger.go @@ -21,6 +21,7 @@ type Logger struct { SinceSeconds int64 `json:"sinceSeconds" yaml:"sinceSeconds"` TextWrap bool `json:"textWrap" yaml:"textWrap"` DisableAutoscroll bool `json:"disableAutoscroll" yaml:"disableAutoscroll"` + ColumnLock bool `json:"columnLock" yaml:"columnLock"` ShowTime bool `json:"showTime" yaml:"showTime"` } diff --git a/internal/config/testdata/configs/default.yaml b/internal/config/testdata/configs/default.yaml index fd10a337..cca791c8 100644 --- a/internal/config/testdata/configs/default.yaml +++ b/internal/config/testdata/configs/default.yaml @@ -37,6 +37,7 @@ k9s: sinceSeconds: -1 textWrap: false disableAutoscroll: false + columnLock: false showTime: false thresholds: cpu: diff --git a/internal/config/testdata/configs/expected.yaml b/internal/config/testdata/configs/expected.yaml index 70980893..7eda913a 100644 --- a/internal/config/testdata/configs/expected.yaml +++ b/internal/config/testdata/configs/expected.yaml @@ -38,6 +38,7 @@ k9s: sinceSeconds: -1 textWrap: false disableAutoscroll: false + columnLock: false showTime: false thresholds: cpu: diff --git a/internal/config/testdata/configs/k9s.yaml b/internal/config/testdata/configs/k9s.yaml index 47b00bc0..cb44df44 100644 --- a/internal/config/testdata/configs/k9s.yaml +++ b/internal/config/testdata/configs/k9s.yaml @@ -37,6 +37,7 @@ k9s: sinceSeconds: -1 textWrap: false disableAutoscroll: false + columnLock: false showTime: false thresholds: cpu: diff --git a/internal/config/testdata/configs/k9s_toast.yaml b/internal/config/testdata/configs/k9s_toast.yaml index 49b14458..d6591098 100644 --- a/internal/config/testdata/configs/k9s_toast.yaml +++ b/internal/config/testdata/configs/k9s_toast.yaml @@ -31,6 +31,7 @@ k9s: sinceSeconds: -1 textWrap: false disableAutoscroll: false + columnLock: false showTime: false thresholds: cpu: diff --git a/internal/view/log.go b/internal/view/log.go index fc0c64bb..62e256c8 100644 --- a/internal/view/log.go +++ b/internal/view/log.go @@ -49,6 +49,7 @@ type Log struct { cancelUpdates bool mx sync.Mutex follow bool + columnLock bool requestOneRefresh bool } @@ -56,13 +57,10 @@ var _ model.Component = (*Log)(nil) // NewLog returns a new viewer. func NewLog(gvr *client.GVR, opts *dao.LogOptions) *Log { - l := Log{ - Flex: tview.NewFlex(), - model: model.NewLog(gvr, opts, defaultFlushTimeout), - follow: true, + return &Log{ + Flex: tview.NewFlex(), + model: model.NewLog(gvr, opts, defaultFlushTimeout), } - - return &l } func (*Log) SetCommand(*cmd.Interpreter) {} @@ -105,6 +103,9 @@ func (l *Log) Init(ctx context.Context) (err error) { l.model.Init(l.app.factory) l.updateTitle() + l.follow = !l.app.Config.K9s.Logger.DisableAutoscroll + l.columnLock = l.app.Config.K9s.Logger.ColumnLock + l.model.ToggleShowTimestamp(l.app.Config.K9s.Logger.ShowTime) return nil @@ -258,6 +259,7 @@ func (l *Log) bindKeys() { ui.KeyShiftC: ui.NewKeyAction("Clear", l.clearCmd, true), ui.KeyM: ui.NewKeyAction("Mark", l.markCmd, true), ui.KeyS: ui.NewKeyAction("Toggle AutoScroll", l.toggleAutoScrollCmd, true), + ui.KeyShiftL: ui.NewKeyAction("Toggle ColumnLock", l.toggleColumnLockCmd, true), ui.KeyF: ui.NewKeyAction("Toggle FullScreen", l.toggleFullScreenCmd, true), ui.KeyT: ui.NewKeyAction("Toggle Timestamp", l.toggleTimestampCmd, true), ui.KeyW: ui.NewKeyAction("Toggle Wrap", l.toggleTextWrapCmd, true), @@ -363,7 +365,12 @@ func (l *Log) Flush(lines [][]byte) { _, _ = l.ansiWriter.Write(lines[i]) } if l.follow { - l.logs.ScrollToEnd() + if l.columnLock { + // Enables end tracking without resetting column + l.logs.SetScrollable(false).SetScrollable(true) + } else { + l.logs.ScrollToEnd() + } } } @@ -464,7 +471,7 @@ func (l *Log) clearCmd(*tcell.EventKey) *tcell.EventKey { func (l *Log) markCmd(*tcell.EventKey) *tcell.EventKey { _, _, w, _ := l.GetRect() - _, _ = fmt.Fprintf(l.ansiWriter, "\n[%s:-:b]%s[-:-:-]", l.app.Styles.Views().Log.FgColor.String(), strings.Repeat("-", w-4)) + _, _ = fmt.Fprintf(l.ansiWriter, "[%s:-:b]%s[-:-:-]\n", l.app.Styles.Views().Log.FgColor.String(), strings.Repeat("-", w-4)) l.follow = true return nil @@ -507,6 +514,18 @@ func (l *Log) toggleAutoScrollCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } +func (l *Log) toggleColumnLockCmd(evt *tcell.EventKey) *tcell.EventKey { + if l.app.InCmdMode() { + return evt + } + + l.indicator.ToggleColumnLock() + l.columnLock = l.indicator.ColumnLock() + l.indicator.Refresh() + + return nil +} + func (l *Log) toggleFullScreenCmd(evt *tcell.EventKey) *tcell.EventKey { if l.app.InCmdMode() { return evt diff --git a/internal/view/log_indicator.go b/internal/view/log_indicator.go index 1b29a0b5..0d5e8908 100644 --- a/internal/view/log_indicator.go +++ b/internal/view/log_indicator.go @@ -25,6 +25,7 @@ type LogIndicator struct { showTime bool allContainers bool shouldDisplayAllContainers bool + columnLock bool } // NewLogIndicator returns a new indicator. @@ -38,11 +39,13 @@ func NewLogIndicator(cfg *config.Config, styles *config.Styles, allContainers bo textWrap: cfg.K9s.Logger.TextWrap, showTime: cfg.K9s.Logger.ShowTime, shouldDisplayAllContainers: allContainers, + columnLock: cfg.K9s.Logger.ColumnLock, } if cfg.K9s.Logger.DisableAutoscroll { l.scrollStatus = 0 } + l.StylesChanged(styles) styles.AddListener(&l) l.SetTextAlign(tview.AlignCenter) @@ -63,6 +66,11 @@ func (l *LogIndicator) AutoScroll() bool { return atomic.LoadInt32(&l.scrollStatus) == 1 } +// ColumnLock reports the current column lock mode. +func (l *LogIndicator) ColumnLock() bool { + return l.columnLock +} + // Timestamp reports the current timestamp mode. func (l *LogIndicator) Timestamp() bool { return l.showTime @@ -78,6 +86,11 @@ func (l *LogIndicator) FullScreen() bool { return l.fullScreen } +// ToggleColumnLock toggles the current column lock mode. +func (l *LogIndicator) ToggleColumnLock() { + l.columnLock = !l.columnLock +} + // ToggleTimestamp toggles the current timestamp mode. func (l *LogIndicator) ToggleTimestamp() { l.showTime = !l.showTime @@ -140,6 +153,12 @@ func (l *LogIndicator) Refresh() { l.indicator = append(l.indicator, fmt.Sprintf(toggleOffFmt, "Autoscroll", spacer)...) } + if l.ColumnLock() { + l.indicator = append(l.indicator, fmt.Sprintf(toggleOnFmt, "ColumnLock", spacer)...) + } else { + l.indicator = append(l.indicator, fmt.Sprintf(toggleOffFmt, "ColumnLock", spacer)...) + } + if l.FullScreen() { l.indicator = append(l.indicator, fmt.Sprintf(toggleOnFmt, "FullScreen", spacer)...) } else { diff --git a/internal/view/log_indicator_test.go b/internal/view/log_indicator_test.go index a49d658a..8b8c8b9c 100644 --- a/internal/view/log_indicator_test.go +++ b/internal/view/log_indicator_test.go @@ -18,10 +18,10 @@ func TestLogIndicatorRefresh(t *testing.T) { e string }{ "all-containers": { - view.NewLogIndicator(config.NewConfig(nil), defaults, true), "[::b]AllContainers:[gray::d]Off[-::] [::b]Autoscroll:[limegreen::b]On[-::] [::b]FullScreen:[gray::d]Off[-::] [::b]Timestamps:[gray::d]Off[-::] [::b]Wrap:[gray::d]Off[-::]\n", + view.NewLogIndicator(config.NewConfig(nil), defaults, true), "[::b]AllContainers:[gray::d]Off[-::] [::b]Autoscroll:[limegreen::b]On[-::] [::b]ColumnLock:[gray::d]Off[-::] [::b]FullScreen:[gray::d]Off[-::] [::b]Timestamps:[gray::d]Off[-::] [::b]Wrap:[gray::d]Off[-::]\n", }, "plain": { - view.NewLogIndicator(config.NewConfig(nil), defaults, false), "[::b]Autoscroll:[limegreen::b]On[-::] [::b]FullScreen:[gray::d]Off[-::] [::b]Timestamps:[gray::d]Off[-::] [::b]Wrap:[gray::d]Off[-::]\n", + view.NewLogIndicator(config.NewConfig(nil), defaults, false), "[::b]Autoscroll:[limegreen::b]On[-::] [::b]ColumnLock:[gray::d]Off[-::] [::b]FullScreen:[gray::d]Off[-::] [::b]Timestamps:[gray::d]Off[-::] [::b]Wrap:[gray::d]Off[-::]\n", }, } diff --git a/internal/view/log_int_test.go b/internal/view/log_int_test.go index 9630416c..ff3d0392 100644 --- a/internal/view/log_int_test.go +++ b/internal/view/log_int_test.go @@ -27,10 +27,34 @@ func TestLogAutoScroll(t *testing.T) { v.GetModel().Set(ii) v.GetModel().Notify() - assert.Len(t, v.Hints(), 16) + assert.Len(t, v.Hints(), 17) v.toggleAutoScrollCmd(nil) - assert.Equal(t, "Autoscroll:Off FullScreen:Off Timestamps:Off Wrap:Off", v.Indicator().GetText(true)) + assert.Equal(t, "Autoscroll:Off ColumnLock:Off FullScreen:Off Timestamps:Off Wrap:Off", v.Indicator().GetText(true)) +} + +func TestLogColumnLock(t *testing.T) { + opts := dao.LogOptions{ + Path: "fred/p1", + Container: "blee", + } + v := NewLog(client.PodGVR, &opts) + require.NoError(t, v.Init(makeContext(t))) + + buff := dao.NewLogItems() + for i := range 100 { + buff.Add(dao.NewLogItemFromString(fmt.Sprintf("line-%d\n", i))) + } + v.GetModel().Set(buff) + + v.toggleColumnLockCmd(nil) + const column = 2 + v.Logs().ScrollTo(-1, column) + v.toggleAutoScrollCmd(nil) + + r, c := v.Logs().GetScrollOffset() + assert.Equal(t, -1, r) + assert.Equal(t, column, c) } func TestLogViewNav(t *testing.T) {