github.com/mutagen-io/mutagen@v0.18.0-rc1/cmd/mutagen/sync/monitor.go (about)

     1  package sync
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"path"
     9  	"time"
    10  
    11  	"github.com/spf13/cobra"
    12  
    13  	"github.com/fatih/color"
    14  
    15  	"github.com/dustin/go-humanize"
    16  
    17  	"github.com/mutagen-io/mutagen/cmd"
    18  	"github.com/mutagen-io/mutagen/cmd/mutagen/common"
    19  	"github.com/mutagen-io/mutagen/cmd/mutagen/common/templating"
    20  	"github.com/mutagen-io/mutagen/cmd/mutagen/daemon"
    21  
    22  	synchronizationmodels "github.com/mutagen-io/mutagen/pkg/api/models/synchronization"
    23  	"github.com/mutagen-io/mutagen/pkg/grpcutil"
    24  	"github.com/mutagen-io/mutagen/pkg/platform/terminal"
    25  	selectionpkg "github.com/mutagen-io/mutagen/pkg/selection"
    26  	synchronizationsvc "github.com/mutagen-io/mutagen/pkg/service/synchronization"
    27  	"github.com/mutagen-io/mutagen/pkg/synchronization"
    28  	"github.com/mutagen-io/mutagen/pkg/synchronization/rsync"
    29  )
    30  
    31  // computeMonitorStatusLine constructs a monitoring status line for a
    32  // synchronization session.
    33  func computeMonitorStatusLine(state *synchronization.State) string {
    34  	// Build the status line.
    35  	var status string
    36  	if state.Session.Paused {
    37  		status += color.YellowString("[Paused]")
    38  	} else {
    39  		// Add a conflict flag if there are conflicts.
    40  		if len(state.Conflicts) > 0 {
    41  			status += color.YellowString("[C] ")
    42  		}
    43  
    44  		// Add a problems flag if there are problems.
    45  		haveProblems := len(state.AlphaState.ScanProblems) > 0 ||
    46  			len(state.BetaState.ScanProblems) > 0 ||
    47  			len(state.AlphaState.TransitionProblems) > 0 ||
    48  			len(state.BetaState.TransitionProblems) > 0
    49  		if haveProblems {
    50  			status += color.YellowString("[!] ")
    51  		}
    52  
    53  		// Add an error flag if there is one present.
    54  		if state.LastError != "" {
    55  			status += color.RedString("[X] ")
    56  		}
    57  
    58  		// Handle the formatting based on status. If we're in a staging mode,
    59  		// then extract the relevant progress information. Despite not having a
    60  		// built-in mechanism for knowing the total expected size of a staging
    61  		// operation, we do know the number of files that the staging operation
    62  		// is performing, so if that's equal to the number of files on the
    63  		// source endpoint, then we know that we can use the total file size on
    64  		// the source endpoint as an estimate for the total staging size.
    65  		var stagingProgress *rsync.ReceiverState
    66  		var totalExpectedSize uint64
    67  		if state.Status == synchronization.Status_StagingAlpha {
    68  			status += "[←] "
    69  			stagingProgress = state.AlphaState.StagingProgress
    70  			if stagingProgress == nil {
    71  				status += "Preparing to stage files on alpha"
    72  			} else if stagingProgress.ExpectedFiles == state.BetaState.Files {
    73  				totalExpectedSize = state.BetaState.TotalFileSize
    74  			}
    75  		} else if state.Status == synchronization.Status_StagingBeta {
    76  			status += "[→] "
    77  			stagingProgress = state.BetaState.StagingProgress
    78  			if stagingProgress == nil {
    79  				status += "Preparing to stage files on beta"
    80  			} else if stagingProgress.ExpectedFiles == state.AlphaState.Files {
    81  				totalExpectedSize = state.AlphaState.TotalFileSize
    82  			}
    83  		} else {
    84  			status += state.Status.Description()
    85  		}
    86  
    87  		// Print staging progress, if available.
    88  		if stagingProgress != nil {
    89  			var fractionComplete float32
    90  			var totalSizeDenominator string
    91  			if totalExpectedSize != 0 {
    92  				fractionComplete = float32(stagingProgress.TotalReceivedSize) / float32(totalExpectedSize)
    93  				totalSizeDenominator = "/" + humanize.Bytes(totalExpectedSize)
    94  			} else {
    95  				fractionComplete = float32(stagingProgress.ReceivedFiles) / float32(stagingProgress.ExpectedFiles)
    96  			}
    97  			status += fmt.Sprintf("[%d/%d - %s%s - %.0f%%] %s (%s/%s)",
    98  				stagingProgress.ReceivedFiles, stagingProgress.ExpectedFiles,
    99  				humanize.Bytes(stagingProgress.TotalReceivedSize), totalSizeDenominator,
   100  				100.0*fractionComplete,
   101  				terminal.NeutralizeControlCharacters(path.Base(stagingProgress.Path)),
   102  				humanize.Bytes(stagingProgress.ReceivedSize), humanize.Bytes(stagingProgress.ExpectedSize),
   103  			)
   104  		}
   105  	}
   106  
   107  	// Done.
   108  	return status
   109  }
   110  
   111  // monitorMain is the entry point for the monitor command.
   112  func monitorMain(_ *cobra.Command, arguments []string) error {
   113  	// Create the session selection specification that will select our initial
   114  	// batch of sessions.
   115  	selection := &selectionpkg.Selection{
   116  		All:            len(arguments) == 0 && monitorConfiguration.labelSelector == "",
   117  		Specifications: arguments,
   118  		LabelSelector:  monitorConfiguration.labelSelector,
   119  	}
   120  	if err := selection.EnsureValid(); err != nil {
   121  		return fmt.Errorf("invalid session selection specification: %w", err)
   122  	}
   123  
   124  	// Load the formatting template (if any has been specified).
   125  	template, err := monitorConfiguration.TemplateFlags.LoadTemplate()
   126  	if err != nil {
   127  		return fmt.Errorf("unable to load formatting template: %w", err)
   128  	}
   129  
   130  	// Determine the listing mode.
   131  	mode := common.SessionDisplayModeMonitor
   132  	if monitorConfiguration.long {
   133  		mode = common.SessionDisplayModeMonitorLong
   134  	}
   135  
   136  	// Connect to the daemon and defer closure of the connection.
   137  	daemonConnection, err := daemon.Connect(true, true)
   138  	if err != nil {
   139  		return fmt.Errorf("unable to connect to daemon: %w", err)
   140  	}
   141  	defer daemonConnection.Close()
   142  
   143  	// Create a session service client.
   144  	synchronizationService := synchronizationsvc.NewSynchronizationClient(daemonConnection)
   145  
   146  	// Create the list request that we'll use.
   147  	request := &synchronizationsvc.ListRequest{
   148  		Selection: selection,
   149  	}
   150  
   151  	// If no template has been specified, then create a status line printer with
   152  	// bold text and defer a line break operation.
   153  	var statusLinePrinter *cmd.StatusLinePrinter
   154  	if template == nil {
   155  		statusLinePrinter = &cmd.StatusLinePrinter{
   156  			Color: color.New(color.Bold),
   157  		}
   158  		defer statusLinePrinter.BreakIfPopulated()
   159  	}
   160  
   161  	// Track the last update time.
   162  	var lastUpdateTime time.Time
   163  
   164  	// Track whether or not we've identified an individual session in the
   165  	// non-templated case.
   166  	var identifiedSingleTargetSession bool
   167  
   168  	// Loop and print monitoring information indefinitely.
   169  	for {
   170  		// Regulate the update frequency (and tame CPU usage in both the monitor
   171  		// command and the daemon) by enforcing a minimum update cycle interval.
   172  		now := time.Now()
   173  		timeSinceLastUpdate := now.Sub(lastUpdateTime)
   174  		if timeSinceLastUpdate < common.MinimumMonitorUpdateInterval {
   175  			time.Sleep(common.MinimumMonitorUpdateInterval - timeSinceLastUpdate)
   176  		}
   177  		lastUpdateTime = now
   178  
   179  		// Perform a list operation.
   180  		response, err := synchronizationService.List(context.Background(), request)
   181  		if err != nil {
   182  			return fmt.Errorf("list failed: %w", grpcutil.PeelAwayRPCErrorLayer(err))
   183  		} else if err = response.EnsureValid(); err != nil {
   184  			return fmt.Errorf("invalid list response received: %w", err)
   185  		}
   186  
   187  		// Update the state tracking index.
   188  		request.PreviousStateIndex = response.StateIndex
   189  
   190  		// If a template has been specified, then use that to format output with
   191  		// public model types. No validation is necessary here since we don't
   192  		// require any specific number of sessions.
   193  		if template != nil {
   194  			sessions := synchronizationmodels.ExportSessions(response.SessionStates)
   195  			if err := template.Execute(os.Stdout, sessions); err != nil {
   196  				return fmt.Errorf("unable to execute formatting template: %w", err)
   197  			}
   198  			continue
   199  		}
   200  
   201  		// No template has been specified, but our command line monitoring
   202  		// interface only supports dynamic status displays for a single session
   203  		// at a time, so we choose the newest session identified by the initial
   204  		// criteria and update our selection to target it specifically.
   205  		var state *synchronization.State
   206  		if !identifiedSingleTargetSession {
   207  			if len(response.SessionStates) == 0 {
   208  				err = errors.New("no matching sessions exist")
   209  			} else {
   210  				// Select the most recently created session matching the
   211  				// selection criteria (which are ordered by creation date).
   212  				state = response.SessionStates[len(response.SessionStates)-1]
   213  
   214  				// Update the selection criteria to target only that session.
   215  				request.Selection = &selectionpkg.Selection{
   216  					Specifications: []string{state.Session.Identifier},
   217  				}
   218  
   219  				// Print session information.
   220  				printSession(state, mode)
   221  
   222  				// Record that we've identified our target session.
   223  				identifiedSingleTargetSession = true
   224  			}
   225  		} else if len(response.SessionStates) != 1 {
   226  			err = errors.New("invalid list response")
   227  		} else {
   228  			state = response.SessionStates[0]
   229  		}
   230  		if err != nil {
   231  			return err
   232  		}
   233  
   234  		// Compute the status line.
   235  		statusLine := computeMonitorStatusLine(state)
   236  
   237  		// Print the status line.
   238  		statusLinePrinter.Print(statusLine)
   239  	}
   240  }
   241  
   242  // monitorCommand is the monitor command.
   243  var monitorCommand = &cobra.Command{
   244  	Use:          "monitor [<session>...]",
   245  	Short:        "Display streaming session status information",
   246  	RunE:         monitorMain,
   247  	SilenceUsage: true,
   248  }
   249  
   250  // monitorConfiguration stores configuration for the monitor command.
   251  var monitorConfiguration struct {
   252  	// help indicates whether or not to show help information and exit.
   253  	help bool
   254  	// long indicates whether or not to use long-format monitoring.
   255  	long bool
   256  	// labelSelector encodes a label selector to be used in identifying which
   257  	// sessions should be paused.
   258  	labelSelector string
   259  	// TemplateFlags store custom templating behavior.
   260  	templating.TemplateFlags
   261  }
   262  
   263  func init() {
   264  	// Grab a handle for the command line flags.
   265  	flags := monitorCommand.Flags()
   266  
   267  	// Disable alphabetical sorting of flags in help output.
   268  	flags.SortFlags = false
   269  
   270  	// Manually add a help flag to override the default message. Cobra will
   271  	// still implement its logic automatically.
   272  	flags.BoolVarP(&monitorConfiguration.help, "help", "h", false, "Show help information")
   273  
   274  	// Wire up monitor flags.
   275  	flags.BoolVarP(&monitorConfiguration.long, "long", "l", false, "Show detailed session information")
   276  	flags.StringVar(&monitorConfiguration.labelSelector, "label-selector", "", "Monitor the most recently created session matching the specified label selector")
   277  
   278  	// Wire up templating flags.
   279  	monitorConfiguration.TemplateFlags.Register(flags)
   280  }