feat: pull progress
This commit is contained in:
parent
b231e8736c
commit
797ef57417
@ -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()
|
||||
|
||||
|
53
internal/container/pull.go
Normal file
53
internal/container/pull.go
Normal 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
|
||||
}
|
0
internal/container/pull.json
Normal file
0
internal/container/pull.json
Normal file
50
internal/container/pull_test.go
Normal file
50
internal/container/pull_test.go
Normal 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)
|
||||
}
|
||||
}
|
51
internal/container/testdata/pull_progress.json
vendored
Normal file
51
internal/container/testdata/pull_progress.json
vendored
Normal 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"}
|
@ -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.
|
||||
|
@ -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{}{}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user