octoplex/terminal/terminal.go
2025-03-09 06:39:41 +01:00

559 lines
16 KiB
Go

package terminal
import (
"cmp"
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
"git.netflux.io/rob/octoplex/domain"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"golang.design/x/clipboard"
)
type sourceViews struct {
url *tview.TextView
status *tview.TextView
tracks *tview.TextView
health *tview.TextView
cpu *tview.TextView
mem *tview.TextView
rx *tview.TextView
}
// startState represents the state of a destination from the point of view of
// the user interface: either started, starting or not started.
type startState int
const (
startStateNotStarted startState = iota
startStateStarting
startStateStarted
)
// UI is responsible for managing the terminal user interface.
type UI struct {
commandCh chan Command
buildInfo domain.BuildInfo
logger *slog.Logger
// tview state
app *tview.Application
pages *tview.Pages
sourceViews sourceViews
destView *tview.Table
// other mutable state
mu sync.Mutex
urlsToStartState map[string]startState
}
// StartParams contains the parameters for starting a new terminal user
// interface.
type StartParams struct {
ChanSize int
Logger *slog.Logger
ClipboardAvailable bool
BuildInfo domain.BuildInfo
Screen tcell.Screen
}
const defaultChanSize = 64
// StartUI starts the terminal user interface.
func StartUI(ctx context.Context, params StartParams) (*UI, error) {
chanSize := cmp.Or(params.ChanSize, defaultChanSize)
commandCh := make(chan Command, chanSize)
app := tview.NewApplication()
// Allow the tcell screen to be overridden for integration tests. If
// params.Screen is nil, the real terminal is used.
app.SetScreen(params.Screen)
sidebar := tview.NewFlex()
sidebar.SetDirection(tview.FlexRow)
sourceView := tview.NewFlex()
sourceView.SetDirection(tview.FlexColumn)
sourceView.SetBorder(true)
sourceView.SetTitle("Ingress RTMP server")
sidebar.AddItem(sourceView, 9, 0, false)
leftCol := tview.NewFlex()
leftCol.SetDirection(tview.FlexRow)
rightCol := tview.NewFlex()
rightCol.SetDirection(tview.FlexRow)
sourceView.AddItem(leftCol, 9, 0, false)
sourceView.AddItem(rightCol, 0, 1, false)
urlHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerURL)
leftCol.AddItem(urlHeaderTextView, 1, 0, false)
urlTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
rightCol.AddItem(urlTextView, 1, 0, false)
statusHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerStatus)
leftCol.AddItem(statusHeaderTextView, 1, 0, false)
statusTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
rightCol.AddItem(statusTextView, 1, 0, false)
tracksHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerTracks)
leftCol.AddItem(tracksHeaderTextView, 1, 0, false)
tracksTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
rightCol.AddItem(tracksTextView, 1, 0, false)
healthHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerHealth)
leftCol.AddItem(healthHeaderTextView, 1, 0, false)
healthTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
rightCol.AddItem(healthTextView, 1, 0, false)
cpuHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerCPU)
leftCol.AddItem(cpuHeaderTextView, 1, 0, false)
cpuTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
rightCol.AddItem(cpuTextView, 1, 0, false)
memHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerMem)
leftCol.AddItem(memHeaderTextView, 1, 0, false)
memTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
rightCol.AddItem(memTextView, 1, 0, false)
rxHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerRx)
leftCol.AddItem(rxHeaderTextView, 1, 0, false)
rxTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
rightCol.AddItem(rxTextView, 1, 0, false)
aboutView := tview.NewFlex()
aboutView.SetDirection(tview.FlexRow)
aboutView.SetBorder(true)
aboutView.SetTitle("Actions")
aboutView.AddItem(tview.NewTextView().SetText("[Space] Toggle destination"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[C] Copy ingress RTMP URL"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[?] About"), 1, 0, false)
sidebar.AddItem(aboutView, 0, 1, false)
destView := tview.NewTable()
destView.SetTitle("Egress streams")
destView.SetBorder(true)
destView.SetSelectable(true, false)
destView.SetWrapSelection(true, false)
destView.SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorDarkSlateGrey))
flex := tview.NewFlex().
SetDirection(tview.FlexColumn).
AddItem(sidebar, 40, 0, false).
AddItem(destView, 0, 6, false)
pages := tview.NewPages()
pages.AddPage("main", flex, true, true)
app.SetRoot(pages, true)
app.SetFocus(destView)
app.EnableMouse(false)
ui := &UI{
commandCh: commandCh,
buildInfo: params.BuildInfo,
logger: params.Logger,
app: app,
pages: pages,
sourceViews: sourceViews{
url: urlTextView,
status: statusTextView,
tracks: tracksTextView,
health: healthTextView,
cpu: cpuTextView,
mem: memTextView,
rx: rxTextView,
},
destView: destView,
urlsToStartState: make(map[string]startState),
}
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyRune:
switch event.Rune() {
case ' ':
ui.toggleDestination()
case 'c', 'C':
ui.copySourceURLToClipboard(params.ClipboardAvailable)
case '?':
ui.showAbout()
}
case tcell.KeyCtrlC:
ui.confirmQuit()
return nil
}
return event
})
go ui.run(ctx)
return ui, nil
}
// C returns a channel that receives commands from the user interface.
func (ui *UI) C() <-chan Command {
return ui.commandCh
}
func (ui *UI) run(ctx context.Context) {
defer close(ui.commandCh)
uiDone := make(chan struct{})
go func() {
defer func() {
uiDone <- struct{}{}
}()
if err := ui.app.Run(); err != nil {
ui.logger.Error("tui application error", "err", err)
}
}()
for {
select {
case <-ctx.Done():
return
case <-uiDone:
return
}
}
}
// ShowStartupCheckModal shows a modal dialog to the user, asking if they want
// to kill a running instance of Octoplex.
//
// The method will block until the user has made a choice, after which the
// channel will receive true if the user wants to quit the other instance, or
// false to quit this instance.
func (ui *UI) ShowStartupCheckModal() bool {
done := make(chan bool)
ui.app.QueueUpdateDraw(func() {
modal := tview.NewModal()
modal.SetText("Another instance of Octoplex may already be running. Pressing continue will close that instance. Continue?").
AddButtons([]string{"Continue", "Exit"}).
SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(buttonIndex int, _ string) {
if buttonIndex == 0 {
ui.pages.RemovePage("modal")
ui.app.SetFocus(ui.destView)
done <- true
} else {
done <- false
}
})
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.pages.AddPage("modal", modal, true, true)
})
return <-done
}
// SetState sets the state of the terminal user interface.
func (ui *UI) SetState(state domain.AppState) {
if state.Source.ExitReason != "" {
ui.handleMediaServerClosed(state.Source.ExitReason)
}
ui.mu.Lock()
for _, dest := range state.Destinations {
ui.urlsToStartState[dest.URL] = containerStateToStartState(dest.Container.Status)
}
ui.mu.Unlock()
// The state is mutable so can't be passed into QueueUpdateDraw, which
// passes it to another goroutine, without cloning it first.
stateClone := state.Clone()
ui.app.QueueUpdateDraw(func() { ui.redrawFromState(stateClone) })
}
func (ui *UI) handleMediaServerClosed(exitReason string) {
done := make(chan struct{})
ui.app.QueueUpdateDraw(func() {
modal := tview.NewModal()
modal.SetText("Mediaserver error: " + exitReason).
AddButtons([]string{"Quit"}).
SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(int, string) {
ui.logger.Info("closing app")
// TODO: improve app cleanup
done <- struct{}{}
ui.app.Stop()
})
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.pages.AddPage("modal", modal, true, true)
})
<-done
}
const dash = "—"
const (
headerName = "Name"
headerURL = "URL"
headerStatus = "Status"
headerContainer = "Container"
headerHealth = "Health"
headerCPU = "CPU %"
headerMem = "Mem M"
headerRx = "Rx Kbps"
headerTx = "Tx Kbps"
headerTracks = "Tracks"
)
func (ui *UI) redrawFromState(state domain.AppState) {
headerCell := func(content string, expansion int) *tview.TableCell {
return tview.
NewTableCell(content).
SetExpansion(expansion).
SetAlign(tview.AlignLeft).
SetSelectable(false)
}
ui.sourceViews.url.SetText(state.Source.RTMPURL)
tracks := dash
if state.Source.Live && len(state.Source.Tracks) > 0 {
tracks = strings.Join(state.Source.Tracks, ", ")
}
ui.sourceViews.tracks.SetText(tracks)
if state.Source.Live {
var durStr string
if !state.Source.LiveChangedAt.IsZero() {
durStr = fmt.Sprintf(" (%s)", time.Since(state.Source.LiveChangedAt).Round(time.Second))
}
ui.sourceViews.status.SetText("[black:green]receiving" + durStr)
} else if state.Source.Container.Status == domain.ContainerStatusRunning && state.Source.Container.HealthState == "healthy" {
ui.sourceViews.status.SetText("[black:yellow]ready")
} else {
ui.sourceViews.status.SetText("[white:red]not ready")
}
ui.sourceViews.health.SetText("[white]" + cmp.Or(rightPad(state.Source.Container.HealthState, 9), dash))
cpuPercent := dash
if state.Source.Container.Status == domain.ContainerStatusRunning {
cpuPercent = fmt.Sprintf("%.1f", state.Source.Container.CPUPercent)
}
ui.sourceViews.cpu.SetText("[white]" + cpuPercent)
memUsage := dash
if state.Source.Container.Status == domain.ContainerStatusRunning {
memUsage = fmt.Sprintf("%.1f", float64(state.Source.Container.MemoryUsageBytes)/1024/1024)
}
ui.sourceViews.mem.SetText("[white]" + memUsage)
rxRate := dash
if state.Source.Container.Status == domain.ContainerStatusRunning {
rxRate = fmt.Sprintf("%d", state.Source.Container.RxRate)
}
ui.sourceViews.rx.SetText("[white]" + rxRate)
ui.destView.Clear()
ui.destView.SetCell(0, 0, headerCell("[grey]"+headerName, 3))
ui.destView.SetCell(0, 1, headerCell("[grey]"+headerURL, 3))
ui.destView.SetCell(0, 2, headerCell("[grey]"+headerStatus, 2))
ui.destView.SetCell(0, 3, headerCell("[grey]"+headerContainer, 2))
ui.destView.SetCell(0, 4, headerCell("[grey]"+headerHealth, 2))
ui.destView.SetCell(0, 5, headerCell("[grey]"+headerCPU, 1))
ui.destView.SetCell(0, 6, headerCell("[grey]"+headerMem, 1))
ui.destView.SetCell(0, 7, headerCell("[grey]"+headerTx, 1))
for i, dest := range state.Destinations {
ui.destView.SetCell(i+1, 0, tview.NewTableCell(dest.Name))
ui.destView.SetCell(i+1, 1, tview.NewTableCell(dest.URL).SetReference(dest.URL).SetMaxWidth(20))
const statusLen = 10
switch dest.Status {
case domain.DestinationStatusLive:
ui.destView.SetCell(
i+1,
2,
tview.NewTableCell(rightPad("sending", statusLen)).
SetTextColor(tcell.ColorBlack).
SetBackgroundColor(tcell.ColorGreen).
SetSelectedStyle(
tcell.
StyleDefault.
Foreground(tcell.ColorBlack).
Background(tcell.ColorGreen),
),
)
default:
ui.destView.SetCell(i+1, 2, tview.NewTableCell("[white]"+rightPad("off-air", statusLen)))
}
ui.destView.SetCell(i+1, 3, tview.NewTableCell("[white]"+rightPad(cmp.Or(dest.Container.Status, dash), 10)))
healthState := dash
if dest.Status == domain.DestinationStatusLive {
healthState = "healthy"
}
ui.destView.SetCell(i+1, 4, tview.NewTableCell("[white]"+rightPad(healthState, 7)))
cpuPercent := dash
if dest.Container.Status == domain.ContainerStatusRunning {
cpuPercent = fmt.Sprintf("%.1f", dest.Container.CPUPercent)
}
ui.destView.SetCell(i+1, 5, tview.NewTableCell("[white]"+rightPad(cpuPercent, 4)))
memoryUsage := dash
if dest.Container.Status == domain.ContainerStatusRunning {
memoryUsage = fmt.Sprintf("%.1f", float64(dest.Container.MemoryUsageBytes)/1000/1000)
}
ui.destView.SetCell(i+1, 6, tview.NewTableCell("[white]"+rightPad(memoryUsage, 4)))
txRate := dash
if dest.Container.Status == domain.ContainerStatusRunning {
txRate = "[white]" + rightPad(strconv.Itoa(dest.Container.TxRate), 4)
}
ui.destView.SetCell(i+1, 7, tview.NewTableCell(txRate))
}
}
// Close closes the terminal user interface.
func (ui *UI) Close() {
ui.app.Stop()
}
func (ui *UI) toggleDestination() {
const urlCol = 1
row, _ := ui.destView.GetSelection()
url, ok := ui.destView.GetCell(row, urlCol).GetReference().(string)
if !ok {
return
}
// Communicating with the multiplexer/container client is asynchronous. To
// ensure we can limit each destination to a single container we need some
// kind of local mutable state which synchronously tracks the "start state"
// of each destination.
//
// Something about this approach feels a tiny bit hacky. Either of these
// approaches would be nicer, if one could be made to work:
//
// 1. Store the state in the *tview.Table, which would mean not recreating
// the cells on each redraw.
// 2. Piggy-back on the tview goroutine to handle synchronization, but that
// seems to introduce deadlocks and/or UI bugs.
ui.mu.Lock()
defer ui.mu.Unlock()
ss := ui.urlsToStartState[url]
switch ss {
case startStateNotStarted:
ui.urlsToStartState[url] = startStateStarting
ui.commandCh <- CommandStartDestination{URL: url}
case startStateStarting:
// do nothing
return
case startStateStarted:
ui.commandCh <- CommandStopDestination{URL: url}
}
}
func (ui *UI) copySourceURLToClipboard(clipboardAvailable bool) {
var text string
if clipboardAvailable {
clipboard.Write(clipboard.FmtText, []byte(ui.sourceViews.url.GetText(true)))
text = "Ingress URL copied to clipboard"
} else {
text = "Copy to clipboard not available"
}
modal := tview.NewModal()
modal.SetText(text).
AddButtons([]string{"Ok"}).
SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite).
SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
modal.SetDoneFunc(func(buttonIndex int, _ string) {
ui.pages.RemovePage("modal")
ui.app.SetFocus(ui.destView)
})
ui.pages.AddPage("modal", modal, true, true)
}
func (ui *UI) confirmQuit() {
modal := tview.NewModal()
modal.SetText("Are you sure you want to quit?").
AddButtons([]string{"Quit", "Cancel"}).
SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(buttonIndex int, _ string) {
if buttonIndex == 1 || buttonIndex == -1 {
ui.pages.RemovePage("modal")
ui.app.SetFocus(ui.destView)
return
}
ui.commandCh <- CommandQuit{}
})
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.pages.AddPage("modal", modal, true, true)
}
func (ui *UI) showAbout() {
modal := tview.NewModal()
modal.SetText(fmt.Sprintf(
"%s: live stream multiplexer\n\nv0.0.0 %s (%s)",
domain.AppName,
ui.buildInfo.Version,
ui.buildInfo.GoVersion,
)).
AddButtons([]string{"Ok"}).
SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(buttonIndex int, _ string) {
ui.pages.RemovePage("modal")
ui.app.SetFocus(ui.destView)
})
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.pages.AddPage("modal", modal, true, true)
}
// comtainerStateToStartState converts a container state to a start state.
func containerStateToStartState(containerState string) startState {
switch containerState {
case domain.ContainerStatusPulling, domain.ContainerStatusCreated:
return startStateStarting
case domain.ContainerStatusRunning, domain.ContainerStatusRestarting, domain.ContainerStatusPaused, domain.ContainerStatusRemoving:
return startStateStarted
default:
return startStateNotStarted
}
}
func rightPad(s string, n int) string {
if s == "" || len(s) == n {
return s
}
if len(s) > n {
return s[:n]
}
return s + strings.Repeat(" ", n-len(s))
}