feat(ui): improve error handling on startup
This commit is contained in:
parent
2fbf2176cf
commit
30da888184
@ -55,8 +55,23 @@ func Run(ctx context.Context, params RunParams) error {
|
|||||||
}
|
}
|
||||||
defer ui.Close()
|
defer ui.Close()
|
||||||
|
|
||||||
|
// emptyUI is a dummy function that sets the UI state to an empty state, and
|
||||||
|
// re-renders the screen.
|
||||||
|
//
|
||||||
|
// This is a workaround for a weird interaction between tview and
|
||||||
|
// tcell.SimulationScreen which leads to newly-added pages not rendering if
|
||||||
|
// the UI is not re-rendered for a second time.
|
||||||
|
// It is only needed for integration tests when rendering modals before the
|
||||||
|
// main loop starts. It would be nice to remove this but the risk/impact on
|
||||||
|
// non-test code is pretty low.
|
||||||
|
emptyUI := func() { ui.SetState(domain.AppState{}) }
|
||||||
|
|
||||||
containerClient, err := container.NewClient(ctx, params.DockerClient, logger.With("component", "container_client"))
|
containerClient, err := container.NewClient(ctx, params.DockerClient, logger.With("component", "container_client"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = fmt.Errorf("create container client: %w", err)
|
||||||
|
ui.ShowFatalErrorModal(err)
|
||||||
|
emptyUI()
|
||||||
|
<-ui.C()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer containerClient.Close()
|
defer containerClient.Close()
|
||||||
@ -70,7 +85,11 @@ func Run(ctx context.Context, params RunParams) error {
|
|||||||
Logger: logger.With("component", "mediaserver"),
|
Logger: logger.With("component", "mediaserver"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create mediaserver: %w", err)
|
err = fmt.Errorf("create mediaserver: %w", err)
|
||||||
|
ui.ShowFatalErrorModal(err)
|
||||||
|
emptyUI()
|
||||||
|
<-ui.C()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
|
@ -44,6 +44,9 @@ func setupSimulationScreen(t *testing.T) (tcell.SimulationScreen, chan<- termina
|
|||||||
if y > len(lines)-1 {
|
if y > len(lines)-1 {
|
||||||
lines = append(lines, "")
|
lines = append(lines, "")
|
||||||
}
|
}
|
||||||
|
if len(screenCells[n].Runes) == 0 { // shouldn't really happen unless there is no output
|
||||||
|
continue
|
||||||
|
}
|
||||||
lines[y] += string(screenCells[n].Runes[0])
|
lines[y] += string(screenCells[n].Runes[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ package app_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@ -15,13 +16,16 @@ import (
|
|||||||
"git.netflux.io/rob/octoplex/internal/app"
|
"git.netflux.io/rob/octoplex/internal/app"
|
||||||
"git.netflux.io/rob/octoplex/internal/config"
|
"git.netflux.io/rob/octoplex/internal/config"
|
||||||
"git.netflux.io/rob/octoplex/internal/container"
|
"git.netflux.io/rob/octoplex/internal/container"
|
||||||
|
"git.netflux.io/rob/octoplex/internal/container/mocks"
|
||||||
"git.netflux.io/rob/octoplex/internal/domain"
|
"git.netflux.io/rob/octoplex/internal/domain"
|
||||||
"git.netflux.io/rob/octoplex/internal/terminal"
|
"git.netflux.io/rob/octoplex/internal/terminal"
|
||||||
"git.netflux.io/rob/octoplex/internal/testhelpers"
|
"git.netflux.io/rob/octoplex/internal/testhelpers"
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
"github.com/docker/docker/errdefs"
|
"github.com/docker/docker/errdefs"
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/testcontainers/testcontainers-go"
|
"github.com/testcontainers/testcontainers-go"
|
||||||
"github.com/testcontainers/testcontainers-go/wait"
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
@ -68,6 +72,10 @@ func TestIntegration(t *testing.T) {
|
|||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
err := app.Run(ctx, app.RunParams{
|
err := app.Run(ctx, app.RunParams{
|
||||||
ConfigService: configService,
|
ConfigService: configService,
|
||||||
DockerClient: dockerClient,
|
DockerClient: dockerClient,
|
||||||
@ -82,8 +90,6 @@ func TestIntegration(t *testing.T) {
|
|||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
require.EventuallyWithT(
|
require.EventuallyWithT(
|
||||||
@ -258,6 +264,10 @@ func TestIntegrationDestinationValidations(t *testing.T) {
|
|||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
err := app.Run(ctx, app.RunParams{
|
err := app.Run(ctx, app.RunParams{
|
||||||
ConfigService: configService,
|
ConfigService: configService,
|
||||||
DockerClient: dockerClient,
|
DockerClient: dockerClient,
|
||||||
@ -272,8 +282,6 @@ func TestIntegrationDestinationValidations(t *testing.T) {
|
|||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
require.EventuallyWithT(
|
require.EventuallyWithT(
|
||||||
@ -412,6 +420,10 @@ func TestIntegrationStartupCheck(t *testing.T) {
|
|||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
err := app.Run(ctx, app.RunParams{
|
err := app.Run(ctx, app.RunParams{
|
||||||
ConfigService: configService,
|
ConfigService: configService,
|
||||||
DockerClient: dockerClient,
|
DockerClient: dockerClient,
|
||||||
@ -426,8 +438,6 @@ func TestIntegrationStartupCheck(t *testing.T) {
|
|||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
require.EventuallyWithT(
|
require.EventuallyWithT(
|
||||||
@ -492,6 +502,10 @@ func TestIntegrationMediaServerError(t *testing.T) {
|
|||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
err := app.Run(ctx, app.RunParams{
|
err := app.Run(ctx, app.RunParams{
|
||||||
ConfigService: configService,
|
ConfigService: configService,
|
||||||
DockerClient: dockerClient,
|
DockerClient: dockerClient,
|
||||||
@ -506,8 +520,6 @@ func TestIntegrationMediaServerError(t *testing.T) {
|
|||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
require.EventuallyWithT(
|
require.EventuallyWithT(
|
||||||
@ -527,3 +539,55 @@ func TestIntegrationMediaServerError(t *testing.T) {
|
|||||||
|
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegrationDockerClientError(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := testhelpers.NewTestLogger(t).With("component", "integration")
|
||||||
|
|
||||||
|
var dockerClient mocks.DockerClient
|
||||||
|
dockerClient.EXPECT().NetworkCreate(mock.Anything, mock.Anything, mock.Anything).Return(network.CreateResponse{}, errors.New("boom"))
|
||||||
|
|
||||||
|
configService := setupConfigService(t, config.Config{Sources: config.Sources{RTMP: config.RTMPSource{Enabled: true}}})
|
||||||
|
screen, screenCaptureC, getContents := setupSimulationScreen(t)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := app.Run(ctx, app.RunParams{
|
||||||
|
ConfigService: configService,
|
||||||
|
DockerClient: &dockerClient,
|
||||||
|
Screen: &terminal.Screen{
|
||||||
|
Screen: screen,
|
||||||
|
Width: 200,
|
||||||
|
Height: 25,
|
||||||
|
CaptureC: screenCaptureC,
|
||||||
|
},
|
||||||
|
ClipboardAvailable: false,
|
||||||
|
BuildInfo: domain.BuildInfo{Version: "0.0.1", GoVersion: "go1.16.3"},
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
|
require.EqualError(t, err, "create container client: network create: boom")
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.EventuallyWithT(
|
||||||
|
t,
|
||||||
|
func(t *assert.CollectT) {
|
||||||
|
assert.True(t, contentsIncludes(getContents(), "An error occurred:"), "expected to see error message")
|
||||||
|
assert.True(t, contentsIncludes(getContents(), "create container client: network create: boom"), "expected to see message")
|
||||||
|
},
|
||||||
|
5*time.Second,
|
||||||
|
time.Second,
|
||||||
|
"expected to see fatal error modal",
|
||||||
|
)
|
||||||
|
printScreen(getContents, "Ater displaying the fatal error modal")
|
||||||
|
|
||||||
|
// Quit the app, this should cause the done channel to receive.
|
||||||
|
sendKey(screen, tcell.KeyEnter, ' ')
|
||||||
|
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
@ -40,7 +40,7 @@ const (
|
|||||||
|
|
||||||
// UI is responsible for managing the terminal user interface.
|
// UI is responsible for managing the terminal user interface.
|
||||||
type UI struct {
|
type UI struct {
|
||||||
commandCh chan Command
|
commandC chan Command
|
||||||
clipboardAvailable bool
|
clipboardAvailable bool
|
||||||
configFilePath string
|
configFilePath string
|
||||||
buildInfo domain.BuildInfo
|
buildInfo domain.BuildInfo
|
||||||
@ -216,7 +216,7 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
|
|||||||
app.EnableMouse(false)
|
app.EnableMouse(false)
|
||||||
|
|
||||||
ui := &UI{
|
ui := &UI{
|
||||||
commandCh: commandCh,
|
commandC: commandCh,
|
||||||
clipboardAvailable: params.ClipboardAvailable,
|
clipboardAvailable: params.ClipboardAvailable,
|
||||||
configFilePath: params.ConfigFilePath,
|
configFilePath: params.ConfigFilePath,
|
||||||
buildInfo: params.BuildInfo,
|
buildInfo: params.BuildInfo,
|
||||||
@ -254,11 +254,11 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
|
|||||||
|
|
||||||
// C returns a channel that receives commands from the user interface.
|
// C returns a channel that receives commands from the user interface.
|
||||||
func (ui *UI) C() <-chan Command {
|
func (ui *UI) C() <-chan Command {
|
||||||
return ui.commandCh
|
return ui.commandC
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) run(ctx context.Context) {
|
func (ui *UI) run(ctx context.Context) {
|
||||||
defer close(ui.commandCh)
|
defer close(ui.commandC)
|
||||||
|
|
||||||
uiDone := make(chan struct{})
|
uiDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@ -379,6 +379,24 @@ func (ui *UI) ShowDestinationErrorModal(name string, err error) {
|
|||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShowFatalErrorModal displays the provided error. It sends a CommandQuit to the
|
||||||
|
// command channel when the user selects the Quit button.
|
||||||
|
func (ui *UI) ShowFatalErrorModal(err error) {
|
||||||
|
ui.app.QueueUpdateDraw(func() {
|
||||||
|
ui.showModal(
|
||||||
|
pageNameModalFatalError,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"An error occurred:\n\n%s",
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
[]string{"Quit"},
|
||||||
|
func(int, string) {
|
||||||
|
ui.commandC <- CommandQuit{}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// captureScreen captures the screen and sends it to the screenCaptureC
|
// captureScreen captures the screen and sends it to the screenCaptureC
|
||||||
// channel, which must have been set in StartParams.
|
// channel, which must have been set in StartParams.
|
||||||
//
|
//
|
||||||
@ -387,7 +405,8 @@ func (ui *UI) ShowDestinationErrorModal(name string, err error) {
|
|||||||
func (ui *UI) captureScreen(screen tcell.Screen) {
|
func (ui *UI) captureScreen(screen tcell.Screen) {
|
||||||
simScreen, ok := screen.(tcell.SimulationScreen)
|
simScreen, ok := screen.(tcell.SimulationScreen)
|
||||||
if !ok {
|
if !ok {
|
||||||
ui.logger.Error("simulation screen not available")
|
ui.logger.Warn("captureScreen: simulation screen not available")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cells, w, h := simScreen.GetContents()
|
cells, w, h := simScreen.GetContents()
|
||||||
@ -491,6 +510,7 @@ const (
|
|||||||
pageNameModalAbout = "modal-about"
|
pageNameModalAbout = "modal-about"
|
||||||
pageNameModalClipboard = "modal-clipboard"
|
pageNameModalClipboard = "modal-clipboard"
|
||||||
pageNameModalDestinationError = "modal-destination-error"
|
pageNameModalDestinationError = "modal-destination-error"
|
||||||
|
pageNameModalFatalError = "modal-fatal-error"
|
||||||
pageNameModalPullProgress = "modal-pull-progress"
|
pageNameModalPullProgress = "modal-pull-progress"
|
||||||
pageNameModalQuit = "modal-quit"
|
pageNameModalQuit = "modal-quit"
|
||||||
pageNameModalRemoveDestination = "modal-remove-destination"
|
pageNameModalRemoveDestination = "modal-remove-destination"
|
||||||
@ -567,7 +587,7 @@ func (ui *UI) handleMediaServerClosed(exitReason string) {
|
|||||||
SetBackgroundColor(tcell.ColorBlack).
|
SetBackgroundColor(tcell.ColorBlack).
|
||||||
SetTextColor(tcell.ColorWhite).
|
SetTextColor(tcell.ColorWhite).
|
||||||
SetDoneFunc(func(int, string) {
|
SetDoneFunc(func(int, string) {
|
||||||
ui.commandCh <- CommandQuit{}
|
ui.commandC <- CommandQuit{}
|
||||||
})
|
})
|
||||||
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
|
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
|
||||||
|
|
||||||
@ -758,7 +778,7 @@ func (ui *UI) addDestination() {
|
|||||||
AddInputField(inputLabelName, "My stream", inputLen, nil, nil).
|
AddInputField(inputLabelName, "My stream", inputLen, nil, nil).
|
||||||
AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil).
|
AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil).
|
||||||
AddButton("Add", func() {
|
AddButton("Add", func() {
|
||||||
ui.commandCh <- CommandAddDestination{
|
ui.commandC <- CommandAddDestination{
|
||||||
DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(),
|
DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(),
|
||||||
URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(),
|
URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(),
|
||||||
}
|
}
|
||||||
@ -809,7 +829,7 @@ func (ui *UI) removeDestination() {
|
|||||||
[]string{"Remove", "Cancel"},
|
[]string{"Remove", "Cancel"},
|
||||||
func(buttonIndex int, _ string) {
|
func(buttonIndex int, _ string) {
|
||||||
if buttonIndex == 0 {
|
if buttonIndex == 0 {
|
||||||
ui.commandCh <- CommandRemoveDestination{URL: url}
|
ui.commandC <- CommandRemoveDestination{URL: url}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -867,12 +887,12 @@ func (ui *UI) toggleDestination() {
|
|||||||
switch ss {
|
switch ss {
|
||||||
case startStateNotStarted:
|
case startStateNotStarted:
|
||||||
ui.urlsToStartState[url] = startStateStarting
|
ui.urlsToStartState[url] = startStateStarting
|
||||||
ui.commandCh <- CommandStartDestination{URL: url}
|
ui.commandC <- CommandStartDestination{URL: url}
|
||||||
case startStateStarting:
|
case startStateStarting:
|
||||||
// do nothing
|
// do nothing
|
||||||
return
|
return
|
||||||
case startStateStarted:
|
case startStateStarted:
|
||||||
ui.commandCh <- CommandStopDestination{URL: url}
|
ui.commandC <- CommandStopDestination{URL: url}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -923,7 +943,7 @@ func (ui *UI) confirmQuit() {
|
|||||||
[]string{"Quit", "Cancel"},
|
[]string{"Quit", "Cancel"},
|
||||||
func(buttonIndex int, _ string) {
|
func(buttonIndex int, _ string) {
|
||||||
if buttonIndex == 0 {
|
if buttonIndex == 0 {
|
||||||
ui.commandCh <- CommandQuit{}
|
ui.commandC <- CommandQuit{}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user