From 1c3186a3427a0c51f9942f8358deab34c4d22c19 Mon Sep 17 00:00:00 2001 From: Joakim Date: Mon, 5 Jan 2026 23:20:27 +0100 Subject: [PATCH] feat: Add visual progress indicators to sync operations - Implement ProgressReporter interface with InteractiveProgress and NoOpProgress - Add real-time progress bars using go-pretty/progress library - Track 6 sync phases: connection test, queue push, pull, parse, apply, and server push - Add --quiet flag to suppress progress output - Auto-detect TTY to disable progress when piped/redirected - Show task-level progress during apply phase with descriptions - Display percentage complete and elapsed time for each phase --- opal-task/cmd/sync.go | 39 ++++++- opal-task/go.mod | 1 + opal-task/go.sum | 2 + opal-task/internal/sync/client.go | 68 ++++++++++-- opal-task/internal/sync/progress.go | 164 ++++++++++++++++++++++++++++ 5 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 opal-task/internal/sync/progress.go diff --git a/opal-task/cmd/sync.go b/opal-task/cmd/sync.go index 21c1189..9279164 100644 --- a/opal-task/cmd/sync.go +++ b/opal-task/cmd/sync.go @@ -12,6 +12,10 @@ import ( "github.com/spf13/cobra" ) +var ( + quietFlag bool +) + var syncCmd = &cobra.Command{ Use: "sync", Short: "Sync tasks with remote server", @@ -91,7 +95,16 @@ Examples: if response == "y" || response == "yes" { strategy := sync.ParseStrategy(cfg.SyncStrategy) - result, err := client.Sync(strategy) + + // Create progress reporter + var reporter sync.ProgressReporter + if sync.ShouldShowProgress(quietFlag) { + reporter = sync.NewInteractiveProgress(os.Stdout) + } else { + reporter = &sync.NoOpProgress{} + } + + result, err := client.Sync(strategy, reporter) if err != nil { fmt.Fprintf(os.Stderr, "Error syncing: %v\n", err) os.Exit(1) @@ -172,7 +185,15 @@ var syncNowCmd = &cobra.Command{ client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID) strategy := sync.ParseStrategy(cfg.SyncStrategy) - result, err := client.Sync(strategy) + // Create progress reporter + var reporter sync.ProgressReporter + if sync.ShouldShowProgress(quietFlag) { + reporter = sync.NewInteractiveProgress(os.Stdout) + } else { + reporter = &sync.NoOpProgress{} + } + + result, err := client.Sync(strategy, reporter) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -351,7 +372,16 @@ Examples: // Parse server tasks // For initial merge, we'll do a full sync - result, err := client.Sync(strategy) + + // Create progress reporter + var reporter sync.ProgressReporter + if sync.ShouldShowProgress(quietFlag) { + reporter = sync.NewInteractiveProgress(os.Stdout) + } else { + reporter = &sync.NoOpProgress{} + } + + result, err := client.Sync(strategy, reporter) if err != nil { fmt.Fprintf(os.Stderr, "Error during merge: %v\n", err) os.Exit(1) @@ -379,6 +409,9 @@ func init() { syncMergeCmd.Flags().Bool("prefer-local", false, "Prefer local database") syncMergeCmd.Flags().Bool("prefer-server", false, "Prefer server database") + + // Global sync flags + syncCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress progress output") } // Helper functions diff --git a/opal-task/go.mod b/opal-task/go.mod index 88abae9..92a89f5 100644 --- a/opal-task/go.mod +++ b/opal-task/go.mod @@ -30,5 +30,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect ) diff --git a/opal-task/go.sum b/opal-task/go.sum index 24e9e7f..bc7e885 100644 --- a/opal-task/go.sum +++ b/opal-task/go.sum @@ -69,6 +69,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/opal-task/internal/sync/client.go b/opal-task/internal/sync/client.go index aabd9e2..e03b9f0 100644 --- a/opal-task/internal/sync/client.go +++ b/opal-task/internal/sync/client.go @@ -152,17 +152,24 @@ type ChangeLogEntry struct { } // Sync performs a full bidirectional sync -func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) { +func (c *Client) Sync(strategy ConflictResolution, reporter ProgressReporter) (*SyncResult, error) { result := &SyncResult{} + // Use NoOp progress if none provided + if reporter == nil { + reporter = &NoOpProgress{} + } + // Check if we have a queue with pending changes queue, err := NewQueue() if err != nil { return nil, fmt.Errorf("failed to load queue: %w", err) } - // Test connection + // Phase 1: Test connection + reporter.StartPhase("Testing connection", 1) if !c.testConnection() { + reporter.CompletePhase() // Server offline - queue changes if any cfg, _ := engine.GetConfig() if cfg != nil && cfg.SyncQueueOffline { @@ -177,28 +184,46 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) { } return nil, fmt.Errorf("server unreachable") } + reporter.UpdatePhase(1, "Connected") + reporter.CompletePhase() - // Server is online - process queue first if it has items + // Phase 2: Process queued changes if any if queue.Size() > 0 { + reporter.StartPhase("Pushing queued changes", int64(queue.Size())) if err := c.pushQueuedChanges(queue.GetPending()); err != nil { + reporter.CompletePhase() return nil, fmt.Errorf("failed to push queued changes: %w", err) } result.Pushed = queue.Size() + reporter.UpdatePhase(int64(queue.Size()), fmt.Sprintf("%d changes pushed", queue.Size())) + reporter.CompletePhase() queue.Clear() } // Get last sync time lastSync := c.getLastSyncTime() - // Pull changes from server + // Phase 3: Pull changes from server + reporter.StartPhase("Pulling from server", 1) changes, err := c.PullChanges(lastSync) if err != nil { + reporter.CompletePhase() return nil, fmt.Errorf("failed to pull changes: %w", err) } + reporter.UpdatePhase(1, fmt.Sprintf("Received %d changes", len(changes))) + reporter.CompletePhase() + + // Phase 4: Parse and sort changes + if len(changes) > 0 { + reporter.StartPhase("Processing changes", int64(len(changes))) + } // Convert changes to tasks remoteTasks, err := c.parseChanges(changes) if err != nil { + if len(changes) > 0 { + reporter.CompletePhase() + } return nil, fmt.Errorf("failed to parse changes: %w", err) } @@ -217,6 +242,11 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) { return false // maintain original order }) + if len(changes) > 0 { + reporter.UpdatePhase(int64(len(changes)), fmt.Sprintf("Parsed %d tasks", len(remoteTasks))) + reporter.CompletePhase() + } + result.Pulled = len(remoteTasks) // Get local changes since last sync @@ -233,8 +263,21 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) { result.ConflictsResolved = conflicts - // Apply merged tasks locally - for _, task := range merged { + // Phase 5: Apply merged tasks locally + if len(merged) > 0 { + reporter.StartPhase("Applying changes locally", int64(len(merged))) + } + + for i, task := range merged { + // Update progress every task or every 10% for large syncs + if len(merged) < 20 || i%(len(merged)/10+1) == 0 { + desc := task.Description + if len(desc) > 40 { + desc = desc[:37] + "..." + } + reporter.UpdatePhase(int64(i+1), desc) + } + if err := task.Save(); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("failed to save task %s: %v", task.UUID, err)) continue @@ -276,18 +319,29 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) { } } - // Push local changes to server + if len(merged) > 0 { + reporter.CompletePhase() + } + + // Phase 6: Push local changes to server if len(localChanges) > 0 { + reporter.StartPhase("Pushing to server", int64(len(localChanges))) if err := c.PushChanges(localChanges); err != nil { + reporter.CompletePhase() result.Errors = append(result.Errors, fmt.Sprintf("failed to push changes: %v", err)) } else { result.Pushed += len(localChanges) + reporter.UpdatePhase(int64(len(localChanges)), fmt.Sprintf("%d changes pushed", len(localChanges))) + reporter.CompletePhase() } } // Update last sync time c.updateLastSyncTime(time.Now().Unix()) + // Complete progress + reporter.Done() + return result, nil } diff --git a/opal-task/internal/sync/progress.go b/opal-task/internal/sync/progress.go new file mode 100644 index 0000000..7c49101 --- /dev/null +++ b/opal-task/internal/sync/progress.go @@ -0,0 +1,164 @@ +package sync + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/jedib0t/go-pretty/v6/progress" + "github.com/jedib0t/go-pretty/v6/text" +) + +// ProgressReporter interface for sync progress reporting +type ProgressReporter interface { + // Phase tracking + StartPhase(name string, total int64) + UpdatePhase(current int64, message string) + CompletePhase() + + // Overall status + SetStatus(message string) + Done() +} + +// NoOpProgress is a no-op progress reporter for non-interactive scenarios +type NoOpProgress struct{} + +func (p *NoOpProgress) StartPhase(name string, total int64) {} +func (p *NoOpProgress) UpdatePhase(current int64, message string) {} +func (p *NoOpProgress) CompletePhase() {} +func (p *NoOpProgress) SetStatus(message string) {} +func (p *NoOpProgress) Done() {} + +// InteractiveProgress provides visual progress indication using go-pretty +type InteractiveProgress struct { + writer progress.Writer + currentTracker *progress.Tracker + baseMessage string // Store base message for current phase + startTime time.Time + output io.Writer +} + +// NewInteractiveProgress creates a new interactive progress reporter +func NewInteractiveProgress(output io.Writer) *InteractiveProgress { + pw := progress.NewWriter() + pw.SetOutputWriter(output) + + // Configure style + pw.SetStyle(progress.StyleDefault) + pw.Style().Colors = progress.StyleColors{ + Message: text.Colors{text.FgHiWhite}, + Percent: text.Colors{text.FgHiCyan}, + Stats: text.Colors{text.FgHiBlack}, + Time: text.Colors{text.FgHiBlack}, + Tracker: text.Colors{text.FgHiWhite}, + Value: text.Colors{text.FgCyan}, + } + pw.Style().Visibility.ETA = false + pw.Style().Visibility.ETAOverall = false + pw.Style().Visibility.Speed = false + pw.Style().Visibility.SpeedOverall = false + pw.Style().Visibility.Value = true + pw.Style().Visibility.Percentage = true + pw.Style().Options.PercentFormat = "%4.1f%%" + + // Start rendering + go pw.Render() + + return &InteractiveProgress{ + writer: pw, + startTime: time.Now(), + output: output, + } +} + +// StartPhase begins a new progress phase +func (p *InteractiveProgress) StartPhase(name string, total int64) { + // Complete previous phase if exists + if p.currentTracker != nil { + p.currentTracker.MarkAsDone() + } + + // Create new tracker + tracker := &progress.Tracker{ + Message: name, + Total: total, + Units: progress.Units{}, + } + + p.writer.AppendTracker(tracker) + p.currentTracker = tracker + p.baseMessage = name // Store base message +} + +// UpdatePhase updates the current phase progress +func (p *InteractiveProgress) UpdatePhase(current int64, message string) { + if p.currentTracker != nil { + p.currentTracker.SetValue(current) + if message != "" { + // Use base message + status to avoid appending repeatedly + p.currentTracker.UpdateMessage(fmt.Sprintf("%s - %s", p.baseMessage, message)) + } + } +} + +// CompletePhase marks the current phase as complete +func (p *InteractiveProgress) CompletePhase() { + if p.currentTracker != nil { + p.currentTracker.MarkAsDone() + p.currentTracker = nil + } +} + +// SetStatus updates the overall status message +func (p *InteractiveProgress) SetStatus(message string) { + // For status updates without a phase, we can use UpdateMessage on current tracker + if p.currentTracker != nil { + p.currentTracker.UpdateMessage(message) + } +} + +// Done completes all progress and cleans up +func (p *InteractiveProgress) Done() { + // Mark any remaining tracker as done + if p.currentTracker != nil { + p.currentTracker.MarkAsDone() + p.currentTracker = nil + } + + // Stop the writer (this waits for all trackers to finish) + p.writer.Stop() + + // Add a newline for clean separation from subsequent output + fmt.Fprintln(p.output) +} + +// formatDuration formats a duration in a human-readable way +func formatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % 60 + return fmt.Sprintf("%dm%ds", minutes, seconds) +} + +// ShouldShowProgress determines if progress should be shown based on environment +func ShouldShowProgress(quiet bool) bool { + if quiet { + return false + } + + // Check if output is a terminal + fileInfo, err := os.Stdout.Stat() + if err != nil { + return false + } + + // Check if it's a character device (terminal) + return (fileInfo.Mode() & os.ModeCharDevice) != 0 +}