diff --git a/cmd/cloudflared/tunnel/cmd.go b/cmd/cloudflared/tunnel/cmd.go index a6a35172..7c7f79b8 100644 --- a/cmd/cloudflared/tunnel/cmd.go +++ b/cmd/cloudflared/tunnel/cmd.go @@ -20,6 +20,7 @@ import ( "github.com/cloudflare/cloudflared/cmd/cloudflared/buildinfo" "github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil" "github.com/cloudflare/cloudflared/cmd/cloudflared/config" + "github.com/cloudflare/cloudflared/cmd/cloudflared/ui" "github.com/cloudflare/cloudflared/cmd/cloudflared/updater" "github.com/cloudflare/cloudflared/dbconnect" "github.com/cloudflare/cloudflared/h2mux" @@ -532,8 +533,11 @@ func StartServer(c *cli.Context, version string, shutdownC, graceShutdownC chan }() if c.IsSet("launch-ui") { - tunnelInfo := newUIModel(version, hostname, metricsListener.Addr().String(), tunnelConfig.OriginUrl) - tunnelInfo.launchUI(ctx, logger) + connEventChan := make(chan ui.ConnEvent) + tunnelConfig.ConnEventChan = connEventChan + + tunnelInfo := ui.NewUIModel(version, hostname, metricsListener.Addr().String(), tunnelConfig.OriginUrl, tunnelConfig.HAConnections) + tunnelInfo.LaunchUI(ctx, logger, connEventChan) } return waitToShutdown(&wg, errC, shutdownC, graceShutdownC, c.Duration("grace-period"), logger) diff --git a/cmd/cloudflared/tunnel/launch_ui.go b/cmd/cloudflared/tunnel/launch_ui.go deleted file mode 100644 index 4f5fd2fe..00000000 --- a/cmd/cloudflared/tunnel/launch_ui.go +++ /dev/null @@ -1,75 +0,0 @@ -package tunnel - -import ( - "context" - "fmt" - "github.com/cloudflare/cloudflared/logger" - "github.com/gdamore/tcell" - "github.com/rivo/tview" -) - -type uiModel struct { - version string - hostname string - metricsURL string - proxyURL string -} - -func newUIModel(version, hostname, metricsURL, proxyURL string) *uiModel { - return &uiModel{ - version: version, - hostname: hostname, - metricsURL: metricsURL, - proxyURL: proxyURL, - } -} - -func (data *uiModel) launchUI(ctx context.Context, logger logger.Service) { - const steelBlue = "#4682B4" - const limeGreen = "#00FF00" - - app := tview.NewApplication() - - grid := tview.NewGrid() - frame := tview.NewFrame(grid) - header := fmt.Sprintf("cloudflared [::b]%s", data.version) - - frame.AddText(header, true, tview.AlignLeft, tcell.ColorWhite) - - // SetColumns takes a value for each column, representing the size of the column - // Numbers <= 0 represent proportional widths and positive numbers represent absolute widths - grid.SetColumns(20, 0) - - // SetRows takes a value for each row, representing the size of the row - grid.SetRows(2, 2, 1, 1, 1, 2, 1, 0) - - // AddItem takes a primitive tview type, row, column, rowSpan, columnSpan, minGridHeight, minGridWidth, and focus - grid.AddItem(tview.NewTextView().SetText("Tunnel:"), 0, 0, 1, 1, 0, 0, false) - grid.AddItem(tview.NewTextView().SetText("Status:"), 1, 0, 1, 1, 0, 0, false) - grid.AddItem(tview.NewTextView().SetText("Connections:"), 2, 0, 4, 1, 0, 0, false) - grid.AddItem(tview.NewTextView().SetText("Metrics:"), 6, 0, 1, 1, 0, 0, false) - - grid.AddItem(tview.NewTextView().SetText(data.hostname), 0, 1, 1, 1, 0, 0, false) - grid.AddItem(newDynamicColorTextView().SetText(fmt.Sprintf("[%s]\u2022[white] Proxying to [%s::b]%s", limeGreen, steelBlue, data.proxyURL)), 1, 1, 1, 1, 0, 0, false) - grid.AddItem(newDynamicColorTextView().SetText("\u2022 #1 "), 2, 1, 1, 1, 0, 0, false) - grid.AddItem(newDynamicColorTextView().SetText("\u2022 #2 "), 3, 1, 1, 1, 0, 0, false) - grid.AddItem(newDynamicColorTextView().SetText("\u2022 #3 "), 4, 1, 1, 1, 0, 0, false) - grid.AddItem(newDynamicColorTextView().SetText("\u2022 #4 "), 5, 1, 1, 1, 0, 0, false) - grid.AddItem(newDynamicColorTextView().SetText(fmt.Sprintf("Metrics at [%s::b]%s/metrics", steelBlue, data.metricsURL)), 6, 1, 1, 1, 0, 0, false) - grid.AddItem(tview.NewBox(), 7, 0, 1, 2, 0, 0, false) - - go func() { - <-ctx.Done() - app.Stop() - }() - - go func() { - if err := app.SetRoot(frame, true).Run(); err != nil { - logger.Errorf("Error launching UI: %s", err) - } - }() -} - -func newDynamicColorTextView() *tview.TextView { - return tview.NewTextView().SetDynamicColors(true) -} diff --git a/cmd/cloudflared/ui/launch_ui.go b/cmd/cloudflared/ui/launch_ui.go new file mode 100644 index 00000000..3b395b36 --- /dev/null +++ b/cmd/cloudflared/ui/launch_ui.go @@ -0,0 +1,169 @@ +package ui + +import ( + "context" + "fmt" + "github.com/cloudflare/cloudflared/logger" + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +type connState struct { + location string + state status +} + +type status int + +const ( + Disconnected status = iota + Connected +) + +type ConnEvent struct { + Index uint8 + EventType status + Location string +} + +type uiModel struct { + version string + hostname string + metricsURL string + proxyURL string + connections []connState +} + +type palette struct { + url string + connected string + defaultText string + disconnected string +} + +func NewUIModel(version, hostname, metricsURL, proxyURL string, haConnections int) *uiModel { + return &uiModel{ + version: version, + hostname: hostname, + metricsURL: metricsURL, + proxyURL: proxyURL, + connections: make([]connState, haConnections), + } +} + +func (data *uiModel) LaunchUI(ctx context.Context, logger logger.Service, connEventChan <-chan ConnEvent) { + palette := palette{url: "#4682B4", connected: "#00FF00", defaultText: "white", disconnected: "red"} + + app := tview.NewApplication() + + grid := tview.NewGrid().SetGap(1, 0) + frame := tview.NewFrame(grid) + header := fmt.Sprintf("cloudflared [::b]%s", data.version) + + frame.AddText(header, true, tview.AlignLeft, tcell.ColorWhite) + + // Create table to store connection info and status + connTable := tview.NewTable() + // SetColumns takes a value for each column, representing the size of the column + // Numbers <= 0 represent proportional widths and positive numbers represent absolute widths + grid.SetColumns(20, 0) + + // SetRows takes a value for each row, representing the size of the row + grid.SetRows(1, 1, 11, 1, 0) + + // AddItem takes a primitive tview type, row, column, rowSpan, columnSpan, minGridHeight, minGridWidth, and focus + grid.AddItem(tview.NewTextView().SetText("Tunnel:"), 0, 0, 1, 1, 0, 0, false) + grid.AddItem(tview.NewTextView().SetText("Status:"), 1, 0, 1, 1, 0, 0, false) + grid.AddItem(tview.NewTextView().SetText("Connections:"), 2, 0, 1, 1, 0, 0, false) + + grid.AddItem(tview.NewTextView().SetText("Metrics:"), 3, 0, 1, 1, 0, 0, false) + + grid.AddItem(tview.NewTextView().SetText(data.hostname), 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(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) + + go func() { + for { + select { + case <-ctx.Done(): + app.Stop() + return + case conn := <-connEventChan: + switch conn.EventType { + case Connected: + data.setConnTableCell(conn, connTable, palette) + case Disconnected: + data.changeConnStatus(conn, connTable, logger, palette) + } + } + app.Draw() + } + }() + + go func() { + if err := app.SetRoot(frame, true).Run(); err != nil { + logger.Errorf("Error launching UI: %s", err) + } + }() +} + +func newDynamicColorTextView() *tview.TextView { + return tview.NewTextView().SetDynamicColors(true) +} + +func (data *uiModel) changeConnStatus(conn ConnEvent, table *tview.Table, logger logger.Service, palette palette) { + index := int(conn.Index) + // Get table row and connection location + connState := data.getConnState(index) + // Check if connection is already displayed in UI + if connState == nil { + logger.Info("Connection is not in the UI table") + return + } + + connState.state = Disconnected + connectionNum := index + 1 + // Get table cell + cell := table.GetCell(index, 0) + // Change text in cell to have red dot in front + text := newCellText(palette, connectionNum, connState.location, false) + cell.SetText(text) +} + +// Return connection location and row in UI table +func (data *uiModel) getConnState(connID int) *connState { + if connID < len(data.connections) { + return &data.connections[connID] + } + + return nil +} + +func (data *uiModel) setConnTableCell(conn ConnEvent, table *tview.Table, palette palette) { + index := int(conn.Index) + connectionNum := index + 1 + + // Update slice to keep track of connection location and state in UI table + data.connections[index].state = Connected + data.connections[index].location = conn.Location + + // Update text in table cell to show disconnected state + text := newCellText(palette, connectionNum, conn.Location, true) + cell := tview.NewTableCell(text) + table.SetCell(index, 0, cell) +} + +func newCellText(palette palette, connectionNum int, location string, connected bool) string { + const connFmtString = "[%s]\u2022[%s] #%d: %s" + + dotColor := palette.disconnected + if connected { + dotColor = palette.connected + } + + return fmt.Sprintf(connFmtString, dotColor, palette.defaultText, connectionNum, location) +} diff --git a/origin/tunnel.go b/origin/tunnel.go index 07985069..81ee6f21 100644 --- a/origin/tunnel.go +++ b/origin/tunnel.go @@ -21,6 +21,7 @@ import ( "github.com/cloudflare/cloudflared/buffer" "github.com/cloudflare/cloudflared/cmd/cloudflared/buildinfo" + "github.com/cloudflare/cloudflared/cmd/cloudflared/ui" "github.com/cloudflare/cloudflared/connection" "github.com/cloudflare/cloudflared/h2mux" "github.com/cloudflare/cloudflared/logger" @@ -88,6 +89,7 @@ type TunnelConfig struct { NamedTunnel *NamedTunnelConfig ReplaceExisting bool + ConnEventChan chan<- ui.ConnEvent } type dupConnRegisterTunnelError struct{} @@ -268,6 +270,13 @@ func ServeTunnel( } }() + // If launch-ui flag is set, send disconnect msg + if config.ConnEventChan != nil { + defer func() { + config.ConnEventChan <- ui.ConnEvent{Index: connectionIndex, EventType: ui.Disconnected} + }() + } + connectionTag := uint8ToString(connectionIndex) // Returns error from parsing the origin URL or handshake errors @@ -426,6 +435,11 @@ func RegisterConnection( config.Metrics.regSuccess.WithLabelValues(registerConnection).Inc() config.Logger.Infof("Connection %d registered with %s using ID %s", connectionIndex, conn.Location, conn.UUID) + // If launch-ui flag is set, send connect msg + if config.ConnEventChan != nil { + config.ConnEventChan <- ui.ConnEvent{Index: connectionIndex, EventType: ui.Connected, Location: conn.Location} + } + return nil }