diff --git a/cmd/cloudflared/tunnel/cmd.go b/cmd/cloudflared/tunnel/cmd.go index c3fc3a8f..2637956b 100644 --- a/cmd/cloudflared/tunnel/cmd.go +++ b/cmd/cloudflared/tunnel/cmd.go @@ -36,6 +36,7 @@ import ( "github.com/cloudflare/cloudflared/tunneldns" "github.com/cloudflare/cloudflared/tunnelstore" "github.com/cloudflare/cloudflared/websocket" + "github.com/rivo/tview" "github.com/coreos/go-systemd/daemon" "github.com/facebookgo/grace/gracenet" @@ -215,11 +216,23 @@ func TunnelCommand(c *cli.Context) error { if name := c.String("name"); name != "" { return adhocNamedTunnel(c, name) } + + if c.IsSet("launch-ui") { + // Create textView to stream logs to + logTextView := ui.NewDynamicColorTextView() + logger, err := createLoggerConfigured(c, false, logTextView) + if err != nil { + return errors.Wrap(err, "error setting up logger") + } + return StartServer(c, version, shutdownC, graceShutdownC, nil, logger, logTextView) + } + logger, err := createLogger(c, false) if err != nil { return errors.Wrap(err, "error setting up logger") } - return StartServer(c, version, shutdownC, graceShutdownC, nil, logger) + + return StartServer(c, version, shutdownC, graceShutdownC, nil, logger, nil) } func Init(v string, s, g chan struct{}) { @@ -268,7 +281,7 @@ func routeFromFlag(c *cli.Context, tunnelID uuid.UUID) (tunnelstore.Route, bool) return nil, false } -func createLogger(c *cli.Context, isTransport bool) (logger.Service, error) { +func createLogger(c *cli.Context, isTransport bool) (*logger.OutputWriter, error) { loggerOpts := []logger.Option{} logPath := c.String("logfile") @@ -297,7 +310,26 @@ func createLogger(c *cli.Context, isTransport bool) (logger.Service, error) { return logger.New(loggerOpts...) } -func StartServer(c *cli.Context, version string, shutdownC, graceShutdownC chan struct{}, namedTunnel *origin.NamedTunnelConfig, logger logger.Service) error { +// Create logger configured for use in UI +func createLoggerConfigured(c *cli.Context, isTransport bool, logTextView *tview.TextView) (logger.Service, error) { + l, err := createLogger(c, isTransport) + if err != nil { + return nil, errors.Wrap(err, "Error creating logger") + } + + logLevel := c.String("loglevel") + supportedLevels, err := logger.GetSupportedLevels(logLevel) + if err != nil { + return nil, errors.Wrap(err, "Error parsing supported levels") + } + + // Add TextView as a group to write output to + l.Add(logTextView, logger.NewUIFormatter(time.RFC3339), supportedLevels...) + + return l, nil +} + +func StartServer(c *cli.Context, version string, shutdownC, graceShutdownC chan struct{}, namedTunnel *origin.NamedTunnelConfig, logger logger.Service, logTextView *tview.TextView) error { _ = raven.SetDSN(sentryDSN) var wg sync.WaitGroup listeners := gracenet.Net{} @@ -537,7 +569,7 @@ func StartServer(c *cli.Context, version string, shutdownC, graceShutdownC chan tunnelConfig.TunnelEventChan = tunnelEventChan tunnelInfo := ui.NewUIModel(version, hostname, metricsListener.Addr().String(), tunnelConfig.OriginUrl, tunnelConfig.HAConnections) - tunnelInfo.LaunchUI(ctx, logger, tunnelEventChan) + tunnelInfo.LaunchUI(ctx, logger, tunnelEventChan, logTextView) } return waitToShutdown(&wg, errC, shutdownC, graceShutdownC, c.Duration("grace-period"), logger) @@ -1138,7 +1170,7 @@ func tunnelFlags(shouldHide bool) []cli.Flag { }), altsrc.NewBoolFlag(&cli.BoolFlag{ Name: "launch-ui", - Usage: "Launch tunnel UI and disable logs", + Usage: "Launch tunnel UI. Tunnel logs are scrollable via 'j', 'k', or arrow keys.", Value: false, Hidden: shouldHide, }), diff --git a/cmd/cloudflared/tunnel/subcommand_context.go b/cmd/cloudflared/tunnel/subcommand_context.go index bd2c2d77..a4ae2d4a 100644 --- a/cmd/cloudflared/tunnel/subcommand_context.go +++ b/cmd/cloudflared/tunnel/subcommand_context.go @@ -10,20 +10,23 @@ import ( "github.com/cloudflare/cloudflared/certutil" "github.com/cloudflare/cloudflared/cmd/cloudflared/config" + "github.com/cloudflare/cloudflared/cmd/cloudflared/ui" "github.com/cloudflare/cloudflared/logger" "github.com/cloudflare/cloudflared/origin" "github.com/cloudflare/cloudflared/tunnelrpc/pogs" "github.com/cloudflare/cloudflared/tunnelstore" "github.com/google/uuid" "github.com/pkg/errors" + "github.com/rivo/tview" "github.com/urfave/cli/v2" ) // subcommandContext carries structs shared between subcommands, to reduce number of arguments needed to pass between subcommands, // and make sure they are only initialized once type subcommandContext struct { - c *cli.Context - logger logger.Service + c *cli.Context + logger logger.Service + uiTextView *tview.TextView // These fields should be accessed using their respective Getter tunnelstoreClient tunnelstore.Client @@ -31,6 +34,20 @@ type subcommandContext struct { } func newSubcommandContext(c *cli.Context) (*subcommandContext, error) { + if c.IsSet("launch-ui") { + // Create textView to stream logs to + logTextView := ui.NewDynamicColorTextView() + logger, err := createLoggerConfigured(c, false, logTextView) + if err != nil { + return nil, errors.Wrap(err, "error setting up logger") + } + return &subcommandContext{ + c: c, + logger: logger, + uiTextView: logTextView, + }, nil + } + logger, err := createLogger(c, false) if err != nil { return nil, errors.Wrap(err, "error setting up logger") @@ -237,7 +254,8 @@ func (sc *subcommandContext) run(tunnelID uuid.UUID) error { if err != nil { return err } - return StartServer(sc.c, version, shutdownC, graceShutdownC, &origin.NamedTunnelConfig{Auth: *credentials, ID: tunnelID}, sc.logger) + + return StartServer(sc.c, version, shutdownC, graceShutdownC, &origin.NamedTunnelConfig{Auth: *credentials, ID: tunnelID}, sc.logger, sc.uiTextView) } func (sc *subcommandContext) cleanupConnections(tunnelIDs []uuid.UUID) error { diff --git a/cmd/cloudflared/ui/launch_ui.go b/cmd/cloudflared/ui/launch_ui.go index e724def5..dd9bd85c 100644 --- a/cmd/cloudflared/ui/launch_ui.go +++ b/cmd/cloudflared/ui/launch_ui.go @@ -56,7 +56,7 @@ func NewUIModel(version, hostname, metricsURL, proxyURL string, haConnections in } } -func (data *uiModel) LaunchUI(ctx context.Context, logger logger.Service, tunnelEventChan <-chan TunnelEvent) { +func (data *uiModel) LaunchUI(ctx context.Context, logger logger.Service, tunnelEventChan <-chan TunnelEvent, logTextView *tview.TextView) { palette := palette{url: "#4682B4", connected: "#00FF00", defaultText: "white", disconnected: "red", reconnecting: "orange"} app := tview.NewApplication() @@ -86,12 +86,14 @@ func (data *uiModel) LaunchUI(ctx context.Context, logger logger.Service, tunnel tunnelHostText := tview.NewTextView().SetText(data.edgeURL) grid.AddItem(tunnelHostText, 0, 1, 1, 1, 0, 0, false) - grid.AddItem(newDynamicColorTextView().SetText(fmt.Sprintf("[%s]\u2022[%s] Proxying to [%s::b]%s", palette.connected, palette.defaultText, palette.url, data.proxyURL)), 1, 1, 1, 1, 0, 0, false) + grid.AddItem(NewDynamicColorTextView().SetText(fmt.Sprintf("[%s]\u2022[%s] Proxying to [%s::b]%s", palette.connected, palette.defaultText, palette.url, data.proxyURL)), 1, 1, 1, 1, 0, 0, false) grid.AddItem(connTable, 2, 1, 1, 1, 0, 0, false) - grid.AddItem(newDynamicColorTextView().SetText(fmt.Sprintf("Metrics at [%s::b]%s/metrics", palette.url, data.metricsURL)), 3, 1, 1, 1, 0, 0, false) - grid.AddItem(tview.NewBox(), 4, 0, 1, 2, 0, 0, false) + grid.AddItem(NewDynamicColorTextView().SetText(fmt.Sprintf("Metrics at [%s::b]%s/metrics", palette.url, data.metricsURL)), 3, 1, 1, 1, 0, 0, false) + // Add TextView to stream logs + // LOGS header is displayed in bold + grid.AddItem(logTextView.SetText("[::b]LOGS:[::-]\n\n").SetChangedFunc(handleNewText(app, logTextView)), 4, 0, 5, 2, 0, 0, false) go func() { for { @@ -125,10 +127,19 @@ func (data *uiModel) LaunchUI(ctx context.Context, logger logger.Service, tunnel }() } -func newDynamicColorTextView() *tview.TextView { +func NewDynamicColorTextView() *tview.TextView { return tview.NewTextView().SetDynamicColors(true) } +// Re-draws application when new logs are streamed to UI +func handleNewText(app *tview.Application, logTextView *tview.TextView) func() { + return func() { + app.Draw() + // SetFocus to enable scrolling in textview + app.SetFocus(logTextView) + } +} + func (data *uiModel) changeConnStatus(event TunnelEvent, table *tview.Table, logger logger.Service, palette palette) { index := int(event.Index) // Get connection location and state diff --git a/logger/create.go b/logger/create.go index 040a4ec8..687bbf50 100644 --- a/logger/create.go +++ b/logger/create.go @@ -85,6 +85,14 @@ func LogLevelString(level string) Option { } } +func GetSupportedLevels(level string) ([]Level, error) { + supported, err := ParseLevelString(level) + if err != nil { + return nil, err + } + return supported, nil +} + // Parse builds the Options struct so the caller knows what actions should be run func Parse(opts ...Option) (*Options, error) { options := &Options{} @@ -98,7 +106,7 @@ func Parse(opts ...Option) (*Options, error) { // New setups a new logger based on the options. // The default behavior is to write to standard out -func New(opts ...Option) (Service, error) { +func New(opts ...Option) (*OutputWriter, error) { config, err := Parse(opts...) if err != nil { @@ -123,6 +131,7 @@ func New(opts ...Option) (Service, error) { l.Add(os.Stderr, terminalFormatter, config.supportedTerminalLevels...) } } + return l, nil } diff --git a/logger/formatter.go b/logger/formatter.go index 1119f2ed..0ed0c3fa 100644 --- a/logger/formatter.go +++ b/logger/formatter.go @@ -63,6 +63,12 @@ type TerminalFormatter struct { supportsColor bool } +// UIFormatter is used for streaming logs to UI +type UIFormatter struct { + format string + supportsColor bool +} + // NewTerminalFormatter creates a Terminal formatter for colored output // format is the time format to use for timestamp formatting func NewTerminalFormatter(format string) Formatter { @@ -73,10 +79,39 @@ func NewTerminalFormatter(format string) Formatter { } } +func NewUIFormatter(format string) Formatter { + supportsColor := (runtime.GOOS != "windows") + return &UIFormatter{ + format: format, + supportsColor: supportsColor, + } +} + +// Timestamp uses formatting that is tview-specific for UI +func (f *UIFormatter) Timestamp(l Level, d time.Time) string { + t := "" + dateStr := "[" + d.Format(f.format) + "] " + switch l { + case InfoLevel: + t = "[#00ffff]INFO[white]" + case ErrorLevel: + t = "[red]ERROR[white]" + case DebugLevel: + t = "[yellow]DEBUG[white]" + case FatalLevel: + t = "[red]FATAL[white]" + } + return t + dateStr +} + +func (f *UIFormatter) Content(l Level, c string) string { + return c +} + // Timestamp returns the log level with a matching color to the log type func (f *TerminalFormatter) Timestamp(l Level, d time.Time) string { t := "" - dateStr := "[" + d.Format(f.format) + "] " + dateStr := "[" + d.Format(f.format) + "] " switch l { case InfoLevel: t = f.output("INFO", skittles.Cyan)