package ui import ( "context" "fmt" "strings" "time" "github.com/cloudflare/cloudflared/ingress" "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 Reconnecting SetUrl RegisteringTunnel ) type TunnelEvent struct { Index uint8 EventType status Location string Url string } type uiModel struct { version string edgeURL string metricsURL string localServices []string connections []connState } type palette struct { url string connected string defaultText string disconnected string reconnecting string } func NewUIModel(version, hostname, metricsURL string, ing *ingress.Ingress, haConnections int) *uiModel { localServices := make([]string, len(ing.Rules)) for i, rule := range ing.Rules { localServices[i] = rule.Service.String() } return &uiModel{ version: version, edgeURL: hostname, metricsURL: metricsURL, localServices: localServices, connections: make([]connState, haConnections), } } func (data *uiModel) LaunchUI( ctx context.Context, generalLogger, transportLogger logger.Service, logLevels []logger.Level, tunnelEventChan <-chan TunnelEvent, ) { // Configure the logger to stream logs into the textview // Add TextView as a group to write output to logTextView := NewDynamicColorTextView() generalLogger.Add(logTextView, logger.NewUIFormatter(time.RFC3339), logLevels...) transportLogger.Add(logTextView, logger.NewUIFormatter(time.RFC3339), logLevels...) // Construct the UI palette := palette{ url: "lightblue", connected: "lime", defaultText: "white", disconnected: "red", reconnecting: "orange", } 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, len(data.connections), 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) tunnelHostText := tview.NewTextView().SetText(data.edgeURL) grid.AddItem(tunnelHostText, 0, 1, 1, 1, 0, 0, false) status := fmt.Sprintf("[%s]\u2022[%s] Proxying to [%s::b]%s", palette.connected, palette.defaultText, palette.url, strings.Join(data.localServices, ", ")) grid.AddItem(NewDynamicColorTextView().SetText(status), 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]http://%s/metrics", palette.url, data.metricsURL)), 3, 1, 1, 1, 0, 0, false) // Add TextView to stream logs // Logs are displayed in a new grid so a border can be set around them logGrid := tview.NewGrid().SetBorders(true).AddItem(logTextView.SetChangedFunc(handleNewText(app, logTextView)), 0, 0, 5, 2, 0, 0, false) // LogFrame holds the Logs header as well as the grid with the textView for streamed logs logFrame := tview.NewFrame(logGrid).AddText("[::b]Logs:[::-]", true, tview.AlignLeft, tcell.ColorWhite).SetBorders(0, 0, 0, 0, 0, 0) // Footer for log frame logFrame.AddText("[::d]Use Ctrl+C to exit[::-]", false, tview.AlignRight, tcell.ColorWhite) grid.AddItem(logFrame, 4, 0, 5, 2, 0, 0, false) go func() { for { select { case <-ctx.Done(): app.Stop() return case event := <-tunnelEventChan: switch event.EventType { case Connected: data.setConnTableCell(event, connTable, palette) case Disconnected, Reconnecting: data.changeConnStatus(event, connTable, generalLogger, palette) case SetUrl: tunnelHostText.SetText(event.Url) data.edgeURL = event.Url case RegisteringTunnel: if data.edgeURL == "" { tunnelHostText.SetText("Registering tunnel...") } } } app.Draw() } }() go func() { if err := app.SetRoot(frame, true).Run(); err != nil { generalLogger.Errorf("Error launching UI: %s", err) } }() } 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 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 } locationState := event.Location if event.EventType == Disconnected { connState.state = Disconnected } else if event.EventType == Reconnecting { connState.state = Reconnecting locationState = "Reconnecting..." } connectionNum := index + 1 // Get table cell cell := table.GetCell(index, 0) // Change dot color in front of text as well as location state text := newCellText(palette, connectionNum, locationState, event.EventType) 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(event TunnelEvent, table *tview.Table, palette palette) { index := int(event.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 = event.Location // Update text in table cell to show disconnected state text := newCellText(palette, connectionNum, event.Location, event.EventType) cell := tview.NewTableCell(text) table.SetCell(index, 0, cell) } func newCellText(palette palette, connectionNum int, location string, connectedStatus status) string { // HA connection indicator formatted as: "• #: ", // where the left middle dot's color depends on the status of the connection const connFmtString = "[%s]\u2022[%s] #%d: %s" var dotColor string switch connectedStatus { case Connected: dotColor = palette.connected case Disconnected: dotColor = palette.disconnected case Reconnecting: dotColor = palette.reconnecting } return fmt.Sprintf(connFmtString, dotColor, palette.defaultText, connectionNum, location) }