488 lines
14 KiB
Go
488 lines
14 KiB
Go
package terminal
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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
|
|
}
|
|
|
|
// UI is responsible for managing the terminal user interface.
|
|
type UI struct {
|
|
app *tview.Application
|
|
pages *tview.Pages
|
|
commandCh chan Command
|
|
buildInfo domain.BuildInfo
|
|
logger *slog.Logger
|
|
sourceViews sourceViews
|
|
destView *tview.Table
|
|
}
|
|
|
|
// 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("[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))
|
|
destView.SetDoneFunc(func(key tcell.Key) {
|
|
const urlCol = 1
|
|
row, _ := destView.GetSelection()
|
|
url, ok := destView.GetCell(row, urlCol).GetReference().(string)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
commandCh <- CommandToggleDestination{URL: url}
|
|
})
|
|
|
|
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,
|
|
}
|
|
|
|
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
switch event.Key() {
|
|
case tcell.KeyRune:
|
|
switch event.Rune() {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
|
|
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 {
|
|
ui.sourceViews.status.SetText("[black:green]receiving")
|
|
} else if state.Source.Container.State == "running" && 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.State == "running" {
|
|
cpuPercent = fmt.Sprintf("%.1f", state.Source.Container.CPUPercent)
|
|
}
|
|
ui.sourceViews.cpu.SetText("[white]" + cpuPercent)
|
|
|
|
memUsage := dash
|
|
if state.Source.Container.State == "running" {
|
|
memUsage = fmt.Sprintf("%.1f", float64(state.Source.Container.MemoryUsageBytes)/1024/1024)
|
|
}
|
|
ui.sourceViews.mem.SetText("[white]" + memUsage)
|
|
|
|
rxRate := dash
|
|
if state.Source.Container.State == "running" {
|
|
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.State, 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.State == "running" {
|
|
cpuPercent = fmt.Sprintf("%.1f", dest.Container.CPUPercent)
|
|
}
|
|
ui.destView.SetCell(i+1, 5, tview.NewTableCell("[white]"+rightPad(cpuPercent, 4)))
|
|
|
|
memoryUsage := dash
|
|
if dest.Container.State == "running" {
|
|
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.State == "running" {
|
|
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) 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)
|
|
}
|
|
|
|
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))
|
|
}
|