feat: pull progress

This commit is contained in:
Rob Watson 2025-03-19 18:39:32 +01:00
parent b231e8736c
commit 797ef57417
7 changed files with 272 additions and 17 deletions

View File

@ -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()

View File

@ -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
}

View File

View File

@ -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)
}
}

View File

@ -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"}

View File

@ -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.

View File

@ -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{}{}