diff --git a/README.md b/README.md index 83a6f95e..63bd1d25 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,16 @@ Nelm has powerful resource tracking built from the ground up, much more advanced ![tracking](resources/images/nelm-release-install.gif) +The width of the tracking table adapts to the terminal width automatically. To override it, set `NELM_LOG_TERMINAL_WIDTH` or use `--progress-table-width`: + +```shell +# Set a fixed width globally via env var (affects all log output) +NELM_LOG_TERMINAL_WIDTH=200 nelm release install -n myproject -r myproject + +# Set a fixed width just for the progress/log/event tables +nelm release install -n myproject -r myproject --progress-table-width 120 +``` + ### Printing logs and events during deploy During the deployment, Nelm finds Pods of deploying resources and periodically prints their container logs. With annotation `werf.io/show-service-messages: "true"`, resource events are also printed. Can be configured with CLI flags and annotations. diff --git a/cmd/nelm/common_flags.go b/cmd/nelm/common_flags.go index a8dbee95..5082f378 100644 --- a/cmd/nelm/common_flags.go +++ b/cmd/nelm/common_flags.go @@ -439,6 +439,13 @@ func AddTrackingFlags(cmd *cobra.Command, cfg *common.TrackingOptions) error { return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.ProgressTableWidth, "progress-table-width", 0, "Maximum width in characters for progress, log and event tables. Use 0 for auto-detect from terminal width", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: progressFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + return nil } diff --git a/cmd/nelm/main.go b/cmd/nelm/main.go index e59024ea..97220d6c 100644 --- a/cmd/nelm/main.go +++ b/cmd/nelm/main.go @@ -70,7 +70,8 @@ func main() { return fg.EnvVarName() }) - if unsupportedEnvVars := lo.Without(cli.FindUndefinedFlagEnvVarsInEnviron(), featGatesEnvVars...); len(unsupportedEnvVars) > 0 { + knownEnvVars := append(featGatesEnvVars, log.LogTerminalWidthEnvVarName) + if unsupportedEnvVars := lo.Without(cli.FindUndefinedFlagEnvVarsInEnviron(), knownEnvVars...); len(unsupportedEnvVars) > 0 { log.Default.Warn(ctx, "Unsupported environment variable(s): %s", strings.Join(unsupportedEnvVars, ",")) } diff --git a/nelm b/nelm new file mode 100755 index 00000000..4ccfebb6 Binary files /dev/null and b/nelm differ diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 65d5ab4a..eb9e6508 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -578,6 +578,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re if !opts.NoProgressTablePrint { progressPrinter = track.NewProgressTablesPrinter(taskStore, logStore, track.ProgressTablesPrinterOptions{ DefaultNamespace: releaseNamespace, + MaxTableWidth: opts.ProgressTableWidth, }) progressPrinter.Start(ctx, opts.ProgressTablePrintInterval) } diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index ec1b4fe0..3666a389 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -405,6 +405,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r if !opts.NoProgressTablePrint { progressPrinter = track.NewProgressTablesPrinter(taskStore, logStore, track.ProgressTablesPrinterOptions{ DefaultNamespace: releaseNamespace, + MaxTableWidth: opts.ProgressTableWidth, }) progressPrinter.Start(ctx, opts.ProgressTablePrintInterval) } diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index 85931ecd..aac34072 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -290,6 +290,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, if !opts.NoProgressTablePrint { progressPrinter = track.NewProgressTablesPrinter(taskStore, logStore, track.ProgressTablesPrinterOptions{ DefaultNamespace: releaseNamespace, + MaxTableWidth: opts.ProgressTableWidth, }) progressPrinter.Start(ctx, opts.ProgressTablePrintInterval) } diff --git a/pkg/common/options.go b/pkg/common/options.go index afe3931c..20b3da1f 100644 --- a/pkg/common/options.go +++ b/pkg/common/options.go @@ -195,6 +195,9 @@ type TrackingOptions struct { // ProgressTablePrintInterval is the interval for updating the progress table display. // Defaults to DefaultProgressPrintInterval (5 seconds) if not set or <= 0. ProgressTablePrintInterval time.Duration + // ProgressTableWidth sets a fixed maximum width in characters for progress, log and event tables. + // When 0, the width is auto-detected from the terminal. Defaults to 140 if auto-detection fails. + ProgressTableWidth int // TrackCreationTimeout is the timeout duration for tracking resource creation. // If resource creation doesn't complete within this time, the operation fails. // If 0, no timeout is applied and resources are tracked indefinitely. diff --git a/pkg/log/init.go b/pkg/log/init.go index 71a3bef3..3f8f0a6b 100644 --- a/pkg/log/init.go +++ b/pkg/log/init.go @@ -7,6 +7,7 @@ import ( "io" stdlog "log" "os" + "strconv" cdlog "github.com/containerd/log" "github.com/davecgh/go-spew/spew" @@ -23,6 +24,8 @@ import ( "github.com/werf/nelm/pkg/helm/pkg/engine" ) +const LogTerminalWidthEnvVarName = "NELM_LOG_TERMINAL_WIDTH" + var Default Logger = NewLogboekLogger() type SetupLoggingOptions struct { @@ -116,6 +119,12 @@ func SetupLogging(ctx context.Context, logLevel Level, opts SetupLoggingOptions) panic(fmt.Sprintf("unknown log level %q", logLevel)) } + if widthStr := os.Getenv(LogTerminalWidthEnvVarName); widthStr != "" { + if width, err := strconv.Atoi(widthStr); err == nil && width > 0 { + logboek.Context(ctx).Streams().SetWidth(width) + } + } + colorLevel := getColorLevel(opts.ColorMode, opts.LogIsParseable) color.Enable = colorLevel != terminfo.ColorLevelNone diff --git a/pkg/log/logger.go b/pkg/log/logger.go index 01db8169..98548226 100644 --- a/pkg/log/logger.go +++ b/pkg/log/logger.go @@ -44,6 +44,7 @@ type Logger interface { InfoBlock(ctx context.Context, opts BlockOptions, fn func()) InfoBlockErr(ctx context.Context, opts BlockOptions, fn func() error) error BlockContentWidth(ctx context.Context) int + TerminalWidth(ctx context.Context) int SetLevel(ctx context.Context, lvl Level) Level(ctx context.Context) Level AcceptLevel(ctx context.Context, lvl Level) bool diff --git a/pkg/log/logger_logboek.go b/pkg/log/logger_logboek.go index 0694a434..f38dd3d5 100644 --- a/pkg/log/logger_logboek.go +++ b/pkg/log/logger_logboek.go @@ -52,6 +52,10 @@ func (l *LogboekLogger) BlockContentWidth(ctx context.Context) int { return logboek.Context(ctx).Streams().ContentWidth() } +func (l *LogboekLogger) TerminalWidth(ctx context.Context) int { + return logboek.Context(ctx).Streams().Width() +} + func (l *LogboekLogger) Debug(ctx context.Context, format string, a ...interface{}) { if !l.AcceptLevel(ctx, DebugLevel) { return diff --git a/pkg/track/progress_tables.go b/pkg/track/progress_tables.go index 07a76a65..a20e1212 100644 --- a/pkg/track/progress_tables.go +++ b/pkg/track/progress_tables.go @@ -70,33 +70,38 @@ func (p *ProgressTablesPrinter) Wait() { type ProgressTablesPrinterOptions struct { DefaultNamespace string + // MaxTableWidth sets a fixed maximum width in characters for all tables. + // When 0, the width is auto-detected from the terminal. + MaxTableWidth int } type tablesBuilder struct { - defaultNamespace string - hideAbsenceTasks map[string]bool - hidePresenceTasks map[string]bool - hideReadinessTasks map[string]bool - logStore *kdutil.Concurrent[*logstore.LogStore] - maxLogEventTableWidth int - maxProgressTableWidth int - nextEventPointers map[string]int - nextLogPointers map[string]int - taskStore *kdutil.Concurrent[*statestore.TaskStore] + configuredMaxTableWidth int + defaultNamespace string + hideAbsenceTasks map[string]bool + hidePresenceTasks map[string]bool + hideReadinessTasks map[string]bool + logStore *kdutil.Concurrent[*logstore.LogStore] + maxLogEventTableWidth int + maxProgressTableWidth int + nextEventPointers map[string]int + nextLogPointers map[string]int + taskStore *kdutil.Concurrent[*statestore.TaskStore] } func newTablesBuilder(taskStore *kdutil.Concurrent[*statestore.TaskStore], logStore *kdutil.Concurrent[*logstore.LogStore], opts tablesBuilderOptions) *tablesBuilder { defaultNamespace := lo.Compact([]string{opts.DefaultNamespace, v1.NamespaceDefault})[0] builder := &tablesBuilder{ - defaultNamespace: defaultNamespace, - hideAbsenceTasks: make(map[string]bool), - hidePresenceTasks: make(map[string]bool), - hideReadinessTasks: make(map[string]bool), - logStore: logStore, - nextEventPointers: make(map[string]int), - nextLogPointers: make(map[string]int), - taskStore: taskStore, + configuredMaxTableWidth: opts.MaxTableWidth, + defaultNamespace: defaultNamespace, + hideAbsenceTasks: make(map[string]bool), + hidePresenceTasks: make(map[string]bool), + hideReadinessTasks: make(map[string]bool), + logStore: logStore, + nextEventPointers: make(map[string]int), + nextLogPointers: make(map[string]int), + taskStore: taskStore, } return builder @@ -231,23 +236,13 @@ func (b *tablesBuilder) BuildProgressTable() (table prtable.Writer, notEmpty boo } func (b *tablesBuilder) SetMaxTableWidth(maxTableWidth int) { - var maxProgressTableWidth int if maxTableWidth > 0 { - maxProgressTableWidth = maxTableWidth + b.maxProgressTableWidth = maxTableWidth + b.maxLogEventTableWidth = maxTableWidth } else { - maxProgressTableWidth = 140 + b.maxProgressTableWidth = 140 + b.maxLogEventTableWidth = 140 } - - b.maxProgressTableWidth = lo.Min([]int{maxProgressTableWidth, 200}) - - var maxLogEventTableWidth int - if maxTableWidth > 0 { - maxLogEventTableWidth = maxTableWidth - } else { - maxLogEventTableWidth = 140 - } - - b.maxLogEventTableWidth = lo.Min([]int{maxLogEventTableWidth, 250}) } func (b *tablesBuilder) buildAbsenceProgressRows() (rows []prtable.Row) { @@ -422,6 +417,7 @@ func (b *tablesBuilder) buildReadinessProgressRows() (rows []prtable.Row) { type tablesBuilderOptions struct { DefaultNamespace string + MaxTableWidth int } func buildChildResourceCell(resourceState *statestore.ResourceState) string { @@ -745,9 +741,16 @@ func compareKindNameNamespace(iName, iNamespace, iKind, jName, jNamespace, jKind return false } +func resolveMaxTableWidth(configuredWidth, autoWidth int) int { + if configuredWidth > 0 { + return min(configuredWidth, autoWidth) + } + return autoWidth +} + func printTables(ctx context.Context, tablesBuilder *tablesBuilder) { - maxTableWidth := log.Default.BlockContentWidth(ctx) - 2 - tablesBuilder.SetMaxTableWidth(maxTableWidth) + autoWidth := log.Default.BlockContentWidth(ctx) - 2 + tablesBuilder.SetMaxTableWidth(resolveMaxTableWidth(tablesBuilder.configuredMaxTableWidth, autoWidth)) if tables, nonEmpty := tablesBuilder.BuildEventTables(); nonEmpty { headers := lo.Keys(tables) @@ -861,8 +864,8 @@ func setProgressTableStyle(table prtable.Writer, tableWidth int) { columnsWidth := tableWidth - paddingsWidth columnConfigs[1].WidthMax = 7 - columnConfigs[0].WidthMax = int(float64(columnsWidth-columnConfigs[1].WidthMax) * 0.6) - columnConfigs[2].WidthMax = int(float64(columnsWidth-columnConfigs[1].WidthMax) * 0.4) + columnConfigs[0].WidthMax = int(float64(columnsWidth-columnConfigs[1].WidthMax) * 0.25) + columnConfigs[2].WidthMax = columnsWidth - columnConfigs[1].WidthMax - columnConfigs[0].WidthMax table.SetColumnConfigs(columnConfigs) table.SetStyle(prtable.Style{ diff --git a/pkg/track/progress_tables_ai_test.go b/pkg/track/progress_tables_ai_test.go new file mode 100644 index 00000000..db1d3ef1 --- /dev/null +++ b/pkg/track/progress_tables_ai_test.go @@ -0,0 +1,165 @@ +//go:build ai_tests + +package track + +import ( + "strings" + "testing" + + prtable "github.com/jedib0t/go-pretty/v6/table" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// SetMaxTableWidth + +func TestAI_SetMaxTableWidth_DefaultsTo140WhenZero(t *testing.T) { + b := &tablesBuilder{} + b.SetMaxTableWidth(0) + assert.Equal(t, 140, b.maxProgressTableWidth) + assert.Equal(t, 140, b.maxLogEventTableWidth) +} + +func TestAI_SetMaxTableWidth_DefaultsTo140WhenNegative(t *testing.T) { + b := &tablesBuilder{} + b.SetMaxTableWidth(-5) + assert.Equal(t, 140, b.maxProgressTableWidth) + assert.Equal(t, 140, b.maxLogEventTableWidth) +} + +func TestAI_SetMaxTableWidth_UsesProvidedWidthWhenPositive(t *testing.T) { + b := &tablesBuilder{} + b.SetMaxTableWidth(120) + assert.Equal(t, 120, b.maxProgressTableWidth) + assert.Equal(t, 120, b.maxLogEventTableWidth) +} + +func TestAI_SetMaxTableWidth_NoCap_BothGetSameWidth(t *testing.T) { + b := &tablesBuilder{} + b.SetMaxTableWidth(300) + assert.Equal(t, 300, b.maxProgressTableWidth) + assert.Equal(t, 300, b.maxLogEventTableWidth) +} + +func TestAI_SetMaxTableWidth_LargeWidth_NoCap(t *testing.T) { + b := &tablesBuilder{} + b.SetMaxTableWidth(500) + assert.Equal(t, 500, b.maxProgressTableWidth) + assert.Equal(t, 500, b.maxLogEventTableWidth) +} + +func TestAI_SetMaxTableWidth_Exactly200_NoCap(t *testing.T) { + b := &tablesBuilder{} + b.SetMaxTableWidth(200) + assert.Equal(t, 200, b.maxProgressTableWidth) + assert.Equal(t, 200, b.maxLogEventTableWidth) +} + +// configuredMaxTableWidth + +func TestAI_TablesBuilder_ConfiguredMaxTableWidthStored(t *testing.T) { + b := &tablesBuilder{configuredMaxTableWidth: 180} + assert.Equal(t, 180, b.configuredMaxTableWidth) +} + +func TestAI_TablesBuilder_ZeroConfiguredMaxTableWidthMeansAuto(t *testing.T) { + b := &tablesBuilder{configuredMaxTableWidth: 0} + assert.Equal(t, 0, b.configuredMaxTableWidth) +} + +// setProgressTableStyle column allocation + +func TestAI_SetProgressTableStyle_InfoWiderThanResource(t *testing.T) { + table := prtable.NewWriter() + setProgressTableStyle(table, 192) + + // Render with content that exceeds any column width to force wrapping at WidthMax. + longText := strings.Repeat("x", 300) + table.AppendRow(prtable.Row{longText, "WAITING", longText}) + rendered := table.Render() + + // Measure actual column widths from the first data line. + lines := strings.Split(rendered, "\n") + require.NotEmpty(t, lines) + + resourceWidth, infoWidth := measureProgressColumnWidths(lines) + assert.Greater(t, infoWidth, resourceWidth, "INFO column should be wider than RESOURCE column") +} + +func TestAI_SetProgressTableStyle_InfoGetsMoreThanHalfWidth(t *testing.T) { + tableWidth := 200 + table := prtable.NewWriter() + setProgressTableStyle(table, tableWidth) + + longText := strings.Repeat("x", 300) + table.AppendRow(prtable.Row{longText, "WAITING", longText}) + rendered := table.Render() + + lines := strings.Split(rendered, "\n") + require.NotEmpty(t, lines) + + resourceWidth, infoWidth := measureProgressColumnWidths(lines) + assert.Greater(t, infoWidth, tableWidth/2, "INFO should get more than half the total width") + _ = resourceWidth +} + +func TestAI_SetProgressTableStyle_NarrowTerminal_InfoStillPositive(t *testing.T) { + table := prtable.NewWriter() + setProgressTableStyle(table, 80) + + longText := strings.Repeat("x", 300) + table.AppendRow(prtable.Row{longText, "WAITING", longText}) + rendered := table.Render() + + lines := strings.Split(rendered, "\n") + require.NotEmpty(t, lines) + + _, infoWidth := measureProgressColumnWidths(lines) + assert.Greater(t, infoWidth, 0, "INFO column must have positive width even on narrow terminal") +} + +// resolveMaxTableWidth + +func TestAI_ResolveMaxTableWidth_ZeroConfiguredUsesAuto(t *testing.T) { + assert.Equal(t, 198, resolveMaxTableWidth(0, 198)) +} + +func TestAI_ResolveMaxTableWidth_ConfiguredBelowAutoUsesConfigured(t *testing.T) { + assert.Equal(t, 120, resolveMaxTableWidth(120, 198)) +} + +func TestAI_ResolveMaxTableWidth_ConfiguredAboveAutoUsesAuto(t *testing.T) { + assert.Equal(t, 198, resolveMaxTableWidth(300, 198)) +} + +func TestAI_ResolveMaxTableWidth_ConfiguredEqualsAutoUsesConfigured(t *testing.T) { + assert.Equal(t, 198, resolveMaxTableWidth(198, 198)) +} + +func TestAI_ResolveMaxTableWidth_NegativeConfiguredUsesAuto(t *testing.T) { + assert.Equal(t, 198, resolveMaxTableWidth(-1, 198)) +} + +// measureProgressColumnWidths measures RESOURCE and INFO column widths from +// the rendered table by finding the first non-header content line. +func measureProgressColumnWidths(lines []string) (resourceWidth, infoWidth int) { + for _, line := range lines { + // Skip header line (all-caps words like RESOURCE STATE INFO). + if strings.Contains(line, "RESOURCE") { + continue + } + if len(line) == 0 { + continue + } + // Trim trailing spaces; columns are separated by two spaces (PaddingRight=" "). + parts := strings.SplitN(line, " ", 3) + if len(parts) < 3 { + continue + } + resourceWidth = len(parts[0]) + // parts[1] is STATE, parts[2] is INFO (may itself contain " " but we care about total). + infoWidth = len(strings.TrimRight(parts[2], " ")) + return + } + return +}