diff --git a/internal/container/container.go b/internal/container/container.go index d263b6c..8fd4b06 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -167,6 +167,7 @@ func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (< defer close(errC) if err := a.pullImageIfNeeded(ctx, params.ContainerConfig.Image, containerStateC); err != nil { + a.logger.Error("Error pulling image", "err", err) sendError(fmt.Errorf("image pull: %w", err)) return } @@ -209,12 +210,30 @@ func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (< containerStateC <- domain.Container{ID: createResp.ID, Status: domain.ContainerStatusRunning} - a.runContainerLoop(ctx, createResp.ID, params.NetworkCountConfig, containerStateC, errC) + a.runContainerLoop( + ctx, + createResp.ID, + params.ContainerConfig.Image, + params.NetworkCountConfig, + containerStateC, + errC, + ) }() return containerStateC, errC } +type pullProgressDetail struct { + Curr int64 `json:"current"` + Total int64 `json:"total"` +} + +type pullProgress struct { + Status string `json:"status"` + Detail pullProgressDetail `json:"progressDetail"` + Progress string `json:"progress"` +} + // pullImageIfNeeded pulls the image if it has not already been pulled. func (a *Client) pullImageIfNeeded(ctx context.Context, imageName string, containerStateC chan<- domain.Container) error { a.mu.Lock() @@ -225,14 +244,9 @@ func (a *Client) pullImageIfNeeded(ctx context.Context, imageName string, contai return nil } - containerStateC <- domain.Container{Status: domain.ContainerStatusPulling} - - pullReader, err := a.apiClient.ImagePull(ctx, imageName, image.PullOptions{}) - if err != nil { + if err := handleImagePull(ctx, imageName, a.apiClient, containerStateC); err != nil { return err } - _, _ = io.Copy(io.Discard, pullReader) - _ = pullReader.Close() a.mu.Lock() a.pulledImages[imageName] = struct{}{} @@ -246,6 +260,7 @@ func (a *Client) pullImageIfNeeded(ctx context.Context, imageName string, contai func (a *Client) runContainerLoop( ctx context.Context, containerID string, + imageName string, networkCountConfig NetworkCountConfig, stateC chan<- domain.Container, errC chan<- error, @@ -297,7 +312,11 @@ func (a *Client) runContainerLoop( } }() - state := &domain.Container{ID: containerID, Status: domain.ContainerStatusRunning} + state := &domain.Container{ + ID: containerID, + Status: domain.ContainerStatusRunning, + ImageName: imageName, + } sendState := func() { stateC <- *state } sendState() diff --git a/internal/container/pull.go b/internal/container/pull.go new file mode 100644 index 0000000..f1a88f5 --- /dev/null +++ b/internal/container/pull.go @@ -0,0 +1,53 @@ +package container + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "git.netflux.io/rob/octoplex/internal/domain" + "github.com/docker/docker/api/types/image" +) + +func handleImagePull( + ctx context.Context, + imageName string, + dockerClient DockerClient, + containerStateC chan<- domain.Container, +) error { + containerStateC <- domain.Container{ + Status: domain.ContainerStatusPulling, + ImageName: imageName, + PullStatus: "Waiting", + } + + pullReader, err := dockerClient.ImagePull(ctx, imageName, image.PullOptions{}) + if err != nil { + return err + } + + pullDecoder := json.NewDecoder(pullReader) + var pp pullProgress + for { + if err := pullDecoder.Decode(&pp); err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("image pull: %w", err) + } + + if pp.Progress != "" { + containerStateC <- domain.Container{ + Status: domain.ContainerStatusPulling, + ImageName: imageName, + PullStatus: pp.Status, + PullProgress: pp.Progress, + PullPercent: int(pp.Detail.Curr * 100 / pp.Detail.Total), + } + } + } + _ = pullReader.Close() + + return nil +} diff --git a/internal/container/pull.json b/internal/container/pull.json new file mode 100644 index 0000000..e69de29 diff --git a/internal/container/pull_test.go b/internal/container/pull_test.go new file mode 100644 index 0000000..a59ae88 --- /dev/null +++ b/internal/container/pull_test.go @@ -0,0 +1,50 @@ +package container + +import ( + "bytes" + _ "embed" + "io" + "testing" + + "git.netflux.io/rob/octoplex/internal/container/mocks" + "git.netflux.io/rob/octoplex/internal/domain" + "github.com/docker/docker/api/types/image" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +//go:embed testdata/pull_progress.json +var pullProgressJSON []byte + +func TestHandleImagePull(t *testing.T) { + const imageName = "alpine" + containerStateC := make(chan domain.Container) + + var dockerClient mocks.DockerClient + defer dockerClient.AssertExpectations(t) + + dockerClient. + EXPECT(). + ImagePull(mock.Anything, imageName, image.PullOptions{}). + Return(io.NopCloser(bytes.NewReader(pullProgressJSON)), nil) + + var containerStates []domain.Container + + go func() { + require.NoError(t, handleImagePull(t.Context(), imageName, &dockerClient, containerStateC)) + }() + + const expectedContainerStates = 46 + for range expectedContainerStates { + containerStates = append(containerStates, <-containerStateC) + } + + assert.Len(t, containerStates, expectedContainerStates) + + for _, containerState := range containerStates { + assert.Equal(t, domain.ContainerStatusPulling, containerState.Status) + assert.Equal(t, imageName, containerState.ImageName) + assert.NotZero(t, containerState.PullStatus) + } +} diff --git a/internal/container/testdata/pull_progress.json b/internal/container/testdata/pull_progress.json new file mode 100644 index 0000000..33c3339 --- /dev/null +++ b/internal/container/testdata/pull_progress.json @@ -0,0 +1,51 @@ +{"status":"Pulling from netfluxio/mediamtx-alpine","id":"latest"} +{"status":"Pulling fs layer","progressDetail":{},"id":"1f3e46996e29"} +{"status":"Pulling fs layer","progressDetail":{},"id":"6e814715152b"} +{"status":"Pulling fs layer","progressDetail":{},"id":"f6273376c644"} +{"status":"Pulling fs layer","progressDetail":{},"id":"3c07878de45f"} +{"status":"Waiting","progressDetail":{},"id":"3c07878de45f"} +{"status":"Downloading","progressDetail":{"current":31487,"total":3069027},"progress":"[\u003e ] 31.49kB/3.069MB","id":"6e814715152b"} +{"status":"Downloading","progressDetail":{"current":37415,"total":3641715},"progress":"[\u003e ] 37.41kB/3.642MB","id":"1f3e46996e29"} +{"status":"Downloading","progressDetail":{"current":163098,"total":16182831},"progress":"[\u003e ] 163.1kB/16.18MB","id":"f6273376c644"} +{"status":"Downloading","progressDetail":{"current":927991,"total":3069027},"progress":"[===============\u003e ] 928kB/3.069MB","id":"6e814715152b"} +{"status":"Downloading","progressDetail":{"current":330958,"total":3641715},"progress":"[====\u003e ] 331kB/3.642MB","id":"1f3e46996e29"} +{"status":"Downloading","progressDetail":{"current":1652998,"total":16182831},"progress":"[=====\u003e ] 1.653MB/16.18MB","id":"f6273376c644"} +{"status":"Downloading","progressDetail":{"current":915681,"total":3641715},"progress":"[============\u003e ] 915.7kB/3.642MB","id":"1f3e46996e29"} +{"status":"Downloading","progressDetail":{"current":1956087,"total":3069027},"progress":"[===============================\u003e ] 1.956MB/3.069MB","id":"6e814715152b"} +{"status":"Downloading","progressDetail":{"current":2324742,"total":16182831},"progress":"[=======\u003e ] 2.325MB/16.18MB","id":"f6273376c644"} +{"status":"Verifying Checksum","progressDetail":{},"id":"6e814715152b"} +{"status":"Download complete","progressDetail":{},"id":"6e814715152b"} +{"status":"Downloading","progressDetail":{"current":1603809,"total":3641715},"progress":"[======================\u003e ] 1.604MB/3.642MB","id":"1f3e46996e29"} +{"status":"Downloading","progressDetail":{"current":5654790,"total":16182831},"progress":"[=================\u003e ] 5.655MB/16.18MB","id":"f6273376c644"} +{"status":"Downloading","progressDetail":{"current":2291937,"total":3641715},"progress":"[===============================\u003e ] 2.292MB/3.642MB","id":"1f3e46996e29"} +{"status":"Downloading","progressDetail":{"current":8653062,"total":16182831},"progress":"[==========================\u003e ] 8.653MB/16.18MB","id":"f6273376c644"} +{"status":"Downloading","progressDetail":{"current":3025121,"total":3641715},"progress":"[=========================================\u003e ] 3.025MB/3.642MB","id":"1f3e46996e29"} +{"status":"Downloading","progressDetail":{"current":11663622,"total":16182831},"progress":"[====================================\u003e ] 11.66MB/16.18MB","id":"f6273376c644"} +{"status":"Verifying Checksum","progressDetail":{},"id":"1f3e46996e29"} +{"status":"Download complete","progressDetail":{},"id":"1f3e46996e29"} +{"status":"Extracting","progressDetail":{"current":65536,"total":3641715},"progress":"[\u003e ] 65.54kB/3.642MB","id":"1f3e46996e29"} +{"status":"Downloading","progressDetail":{"current":14141702,"total":16182831},"progress":"[===========================================\u003e ] 14.14MB/16.18MB","id":"f6273376c644"} +{"status":"Verifying Checksum","progressDetail":{},"id":"f6273376c644"} +{"status":"Download complete","progressDetail":{},"id":"f6273376c644"} +{"status":"Extracting","progressDetail":{"current":2424832,"total":3641715},"progress":"[=================================\u003e ] 2.425MB/3.642MB","id":"1f3e46996e29"} +{"status":"Extracting","progressDetail":{"current":3641715,"total":3641715},"progress":"[==================================================\u003e] 3.642MB/3.642MB","id":"1f3e46996e29"} +{"status":"Extracting","progressDetail":{"current":3641715,"total":3641715},"progress":"[==================================================\u003e] 3.642MB/3.642MB","id":"1f3e46996e29"} +{"status":"Pull complete","progressDetail":{},"id":"1f3e46996e29"} +{"status":"Extracting","progressDetail":{"current":32768,"total":3069027},"progress":"[\u003e ] 32.77kB/3.069MB","id":"6e814715152b"} +{"status":"Downloading","progressDetail":{"current":1369,"total":7593},"progress":"[=========\u003e ] 1.369kB/7.593kB","id":"3c07878de45f"} +{"status":"Downloading","progressDetail":{"current":7593,"total":7593},"progress":"[==================================================\u003e] 7.593kB/7.593kB","id":"3c07878de45f"} +{"status":"Download complete","progressDetail":{},"id":"3c07878de45f"} +{"status":"Extracting","progressDetail":{"current":2031616,"total":3069027},"progress":"[=================================\u003e ] 2.032MB/3.069MB","id":"6e814715152b"} +{"status":"Extracting","progressDetail":{"current":3069027,"total":3069027},"progress":"[==================================================\u003e] 3.069MB/3.069MB","id":"6e814715152b"} +{"status":"Pull complete","progressDetail":{},"id":"6e814715152b"} +{"status":"Extracting","progressDetail":{"current":163840,"total":16182831},"progress":"[\u003e ] 163.8kB/16.18MB","id":"f6273376c644"} +{"status":"Extracting","progressDetail":{"current":3112960,"total":16182831},"progress":"[=========\u003e ] 3.113MB/16.18MB","id":"f6273376c644"} +{"status":"Extracting","progressDetail":{"current":5734400,"total":16182831},"progress":"[=================\u003e ] 5.734MB/16.18MB","id":"f6273376c644"} +{"status":"Extracting","progressDetail":{"current":11632640,"total":16182831},"progress":"[===================================\u003e ] 11.63MB/16.18MB","id":"f6273376c644"} +{"status":"Extracting","progressDetail":{"current":16182831,"total":16182831},"progress":"[==================================================\u003e] 16.18MB/16.18MB","id":"f6273376c644"} +{"status":"Pull complete","progressDetail":{},"id":"f6273376c644"} +{"status":"Extracting","progressDetail":{"current":7593,"total":7593},"progress":"[==================================================\u003e] 7.593kB/7.593kB","id":"3c07878de45f"} +{"status":"Extracting","progressDetail":{"current":7593,"total":7593},"progress":"[==================================================\u003e] 7.593kB/7.593kB","id":"3c07878de45f"} +{"status":"Pull complete","progressDetail":{},"id":"3c07878de45f"} +{"status":"Digest: sha256:6edf95a345caa2c173bf24125164500283d10814cc441f2d8861054b4f755ac4"} +{"status":"Status: Downloaded newer image for netfluxio/mediamtx-alpine:latest"} diff --git a/internal/domain/types.go b/internal/domain/types.go index 9c74d7a..a5be8ae 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -80,6 +80,10 @@ type Container struct { RxRate int TxRate int RxSince time.Time + ImageName string + PullStatus string // PullStatus is the status of the image pull. + PullProgress string // PullProgress is the "progress string" of the image pull. + PullPercent int // PullPercent is the percentage of the image that has been pulled. RestartCount int ExitCode *int Err error // Err is set if any error was received from the container client. diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index f2dfd7e..7ff0589 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "log/slog" + "maps" "slices" "strconv" "strings" @@ -45,12 +46,13 @@ type UI struct { // tview state - app *tview.Application - screen tcell.Screen - screenCaptureC chan<- ScreenCapture - pages *tview.Pages - sourceViews sourceViews - destView *tview.Table + app *tview.Application + screen tcell.Screen + screenCaptureC chan<- ScreenCapture + pages *tview.Pages + sourceViews sourceViews + destView *tview.Table + pullProgressModal *tview.Modal // other mutable state @@ -175,6 +177,12 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) { destView.SetWrapSelection(true, false) destView.SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorDarkSlateGrey)) + pullProgressModal := tview.NewModal() + pullProgressModal. + SetBackgroundColor(tcell.ColorBlack). + SetTextColor(tcell.ColorWhite). + SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) + flex := tview.NewFlex(). SetDirection(tview.FlexColumn). AddItem(sidebar, 40, 0, false). @@ -204,8 +212,9 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) { mem: memTextView, rx: rxTextView, }, - destView: destView, - urlsToStartState: make(map[string]startState), + destView: destView, + pullProgressModal: pullProgressModal, + urlsToStartState: make(map[string]startState), } app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { @@ -354,6 +363,8 @@ func (ui *UI) SetState(state domain.AppState) { ui.handleMediaServerClosed(state.Source.ExitReason) } + ui.updatePullProgress(state) + ui.mu.Lock() for _, dest := range state.Destinations { ui.urlsToStartState[dest.URL] = containerStateToStartState(dest.Container.Status) @@ -366,6 +377,63 @@ func (ui *UI) SetState(state domain.AppState) { ui.app.QueueUpdateDraw(func() { ui.redrawFromState(stateClone) }) } +func (ui *UI) updatePullProgress(state domain.AppState) { + pullingContainers := make(map[string]domain.Container) + + isPulling := func(containerState string, pullPercent int) bool { + return containerState == domain.ContainerStatusPulling && pullPercent > 0 + } + + if isPulling(state.Source.Container.Status, state.Source.Container.PullPercent) { + pullingContainers[state.Source.Container.ImageName] = state.Source.Container + } + + for _, dest := range state.Destinations { + if isPulling(dest.Container.Status, dest.Container.PullPercent) { + pullingContainers[dest.Container.ImageName] = dest.Container + } + } + + if len(pullingContainers) == 0 { + ui.hideModal(modalGroupPullProgress) + return + } + + // We don't really expect two images to be pulling simultaneously, but it's + // easy enough to handle. + imageNames := slices.Collect(maps.Keys(pullingContainers)) + slices.Sort(imageNames) + container := pullingContainers[imageNames[0]] + + ui.updateProgressModal(container) +} + +func (ui *UI) updateProgressModal(container domain.Container) { + ui.app.QueueUpdateDraw(func() { + modalName := "modal-" + string(modalGroupPullProgress) + + var status string + // Avoid showing the long Docker pull status in the modal content. + if len(container.PullStatus) < 30 { + status = container.PullStatus + } + + modalContent := fmt.Sprintf( + "Pulling %s:\n%s (%d%%)\n\n%s", + container.ImageName, + status, + container.PullPercent, + container.PullProgress, + ) + + if ui.pages.HasPage(modalName) { + ui.pullProgressModal.SetText(modalContent) + } else { + ui.pages.AddPage(modalName, ui.pullProgressModal, true, true) + } + }) +} + // modalGroup represents a specific modal of which only one may be shown // simultaneously. type modalGroup string @@ -375,6 +443,7 @@ const ( modalGroupQuit modalGroup = "quit" modalGroupStartupCheck modalGroup = "startup-check" modalGroupClipboard modalGroup = "clipboard" + modalGroupPullProgress modalGroup = "pull-progress" ) func (ui *UI) showModal(group modalGroup, text string, buttons []string, doneFunc func(int, string)) { @@ -404,6 +473,16 @@ func (ui *UI) showModal(group modalGroup, text string, buttons []string, doneFun ui.pages.AddPage(modalName, modal, true, true) } +func (ui *UI) hideModal(group modalGroup) { + modalName := "modal-" + string(group) + if !ui.pages.HasPage(modalName) { + return + } + + ui.pages.RemovePage(modalName) + ui.app.SetFocus(ui.destView) +} + func (ui *UI) handleMediaServerClosed(exitReason string) { done := make(chan struct{}) @@ -414,7 +493,6 @@ func (ui *UI) handleMediaServerClosed(exitReason string) { SetBackgroundColor(tcell.ColorBlack). SetTextColor(tcell.ColorWhite). SetDoneFunc(func(int, string) { - ui.logger.Info("closing app") // TODO: improve app cleanup done <- struct{}{}