Skip to content

Commit

Permalink
feat(cli): show progress bar when uploading app sources with deploy
Browse files Browse the repository at this point in the history
  • Loading branch information
jfeo committed Dec 11, 2024
1 parent a99fb02 commit ff971bd
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 21 deletions.
41 changes: 38 additions & 3 deletions cmd/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type appService interface {
Create(ctx context.Context, input app.CreateAppInput) (app.CreateAppOutput, error)
CreateVersion(ctx context.Context, input app.CreateAppVersionInput) (app.CreateAppVersionOutput, error)
AppVersionUploadURL(ctx context.Context, input app.AppVersionUploadURLInput) (app.AppVersionUploadURLOutput, error)
UploadAppSource(uploadURL string, archive io.Reader) error
UploadAppSource(uploadURL string, archive app.UploadArchive) error
DeployApp(ctx context.Context, input app.DeployAppInput) (app.DeployAppOutput, error)
DeployEvents(ctx context.Context, input app.DeployEventsInput) error
AppDeployLogs(appident.AppIdentifier) (chan app.AppDeployLogEntry, error)
Expand Down Expand Up @@ -269,6 +269,35 @@ func readOrCreateApp(ctx context.Context, apps appService, ai appident.AppIdenti
return appCreateOutput.AppID, nil
}

type progressReader struct {
r io.Reader
bytesSent int
task *output.Task
totalSize int64
}

func (pr *progressReader) Read(p []byte) (int, error) {
n, err := pr.r.Read(p)
pr.bytesSent += n
pr.report(errors.Is(err, io.EOF))

return n, err
}

func (pr *progressReader) report(eof bool) {
if eof {
pr.task.Progress(100.0) // nolint:mnd
return
}

percent := float32(0.0)
if pr.bytesSent >= 0 {
percent = 100.0 * float32(pr.bytesSent) / float32(pr.totalSize) // nolint:mnd
}

pr.task.Progress(percent)
}

func uploadAppArchive(ctx context.Context, apps appService, archive *os.File, appVersionID string) error {
task := output.StartTask("Uploading app archive")
uploadURLInput := app.AppVersionUploadURLInput(app.AppVersionUploadURLInput{AppVersionID: appVersionID})
Expand All @@ -280,7 +309,8 @@ func uploadAppArchive(ctx context.Context, apps appService, archive *os.File, ap
return err
}

if stat, err := archive.Stat(); err != nil {
stat, err := archive.Stat()
if err != nil {
task.Error()
output.PrintErrorDetails("Error checking archive size", err)

Expand All @@ -292,7 +322,12 @@ func uploadAppArchive(ctx context.Context, apps appService, archive *os.File, ap
return errArchiveTooLarge
}

err = apps.UploadAppSource(uploadURLOutput.UploadURL, archive)
uploadArchive := app.UploadArchive{
Reader: &progressReader{r: archive, totalSize: stat.Size(), task: task},
Size: stat.Size(),
}

err = apps.UploadAppSource(uploadURLOutput.UploadURL, uploadArchive)
var appSourceUploadErr *app.AppSourceUploadError
if errors.As(err, &appSourceUploadErr) {
task.Error()
Expand Down
3 changes: 1 addition & 2 deletions cmd/deploy/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package deploy

import (
"context"
"io"

"numerous.com/cli/internal/app"
"numerous.com/cli/internal/appident"
Expand Down Expand Up @@ -53,7 +52,7 @@ func (m *mockAppService) CreateVersion(ctx context.Context, input app.CreateAppV
}

// UploadAppSource implements AppService.
func (m *mockAppService) UploadAppSource(uploadURL string, archive io.Reader) error {
func (m *mockAppService) UploadAppSource(uploadURL string, archive app.UploadArchive) error {
args := m.Called(uploadURL, archive)
return args.Error(0)
}
Expand Down
26 changes: 23 additions & 3 deletions cmd/output/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
const (
fallbackTaskLineWidth = 60
maxTaskLineWidth = 120
minDots = 3
defaultMinDots = 3
)

type lineWidthFunc func() int
Expand All @@ -18,20 +18,21 @@ type Task struct {
msg string
lineAdded bool
lineUpdating bool
progress bool
lineWidth lineWidthFunc
w io.Writer
}

func (t *Task) line(icon string) string {
dotCount, msg := t.trimMessage()
dotCount, msg := t.trimMessage(defaultMinDots)

line := icon + " " + msg + AnsiFaint + "..."
line += strings.Repeat(".", dotCount)

return line + AnsiReset
}

func (t *Task) trimMessage() (int, string) {
func (t *Task) trimMessage(minDots int) (int, string) {
w := t.lineWidth()

errLineLen := 2 + len(t.msg) + minDots + len("Error") // +2 for icon and space
Expand Down Expand Up @@ -82,6 +83,25 @@ func (t *Task) EndUpdateLine() {
}
}

func (t *Task) Progress(percent float32) {
if t.lineAdded || t.lineUpdating {
return
}

t.progress = true
progressWidth, msg := t.trimMessage(0)
line := "\r" + hourglassIcon + " " + msg + AnsiFaint

if progressWidth > 0 {
completedWidth := int((float32)(progressWidth) / 100.0 * percent)
remainingWidth := progressWidth - completedWidth
line += strings.Repeat("#", completedWidth) + strings.Repeat(".", remainingWidth)
}
line += AnsiReset

fmt.Fprint(t.w, line)
}

func (t *Task) Done() {
t.terminate(checkmarkIcon, AnsiGreen+"OK"+AnsiReset)
}
Expand Down
22 changes: 22 additions & 0 deletions cmd/output/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,28 @@ func TestTask(t *testing.T) {
})
})

t.Run("Progress", func(t *testing.T) {
buf := bytes.NewBuffer(nil)
term := &stubTerminal{buf: buf, width: 19} // width: task message + 10 spaces for progress + max suffix length

task := StartTaskWithTerminal("message", term)
for i := float32(0.0); i <= 100.0; i += 20.0 {
task.Progress(i)
}
actual := buf.String()

expected := strings.Join([]string{
hourglassIcon + " message" + AnsiFaint + "....." + AnsiReset,
"\r" + hourglassIcon + " message" + AnsiFaint + "....." + AnsiReset,
"\r" + hourglassIcon + " message" + AnsiFaint + "#...." + AnsiReset,
"\r" + hourglassIcon + " message" + AnsiFaint + "##..." + AnsiReset,
"\r" + hourglassIcon + " message" + AnsiFaint + "###.." + AnsiReset,
"\r" + hourglassIcon + " message" + AnsiFaint + "####." + AnsiReset,
"\r" + hourglassIcon + " message" + AnsiFaint + "#####" + AnsiReset,
}, "")
assert.Equal(t, expected, actual)
})

t.Run("StartTask", func(t *testing.T) {
t.Run("writes expected output to stdout", func(t *testing.T) {
stdoutR := test.RunWithPatchedStdout(t, func() {
Expand Down
15 changes: 8 additions & 7 deletions internal/app/upload_app_source.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package app

import (
"bytes"
"fmt"
"io"
"net/http"
Expand All @@ -18,17 +17,19 @@ func (e *AppSourceUploadError) Error() string {
return fmt.Sprintf("http %d: %q uploading app source file to %q ", e.HTTPStatusCode, e.HTTPStatus, e.UploadURL)
}

func (s *Service) UploadAppSource(uploadURL string, archive io.Reader) error {
var buf bytes.Buffer
if _, err := io.Copy(&buf, archive); err != nil {
return err
}
type UploadArchive struct {
Reader io.Reader
Size int64
}

req, err := http.NewRequest(http.MethodPut, uploadURL, &buf)
func (s *Service) UploadAppSource(uploadURL string, archive UploadArchive) error {
req, err := http.NewRequest(http.MethodPut, uploadURL, archive.Reader)
if err != nil {
return err
}

req.ContentLength = archive.Size

resp, err := s.uploadDoer.Do(req)
if err != nil {
return err
Expand Down
15 changes: 9 additions & 6 deletions internal/app/upload_app_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import (
"github.com/stretchr/testify/mock"
)

var dummyData = []byte("some data")

func dummyReader() io.Reader {
return bytes.NewBuffer([]byte("some data"))
return bytes.NewBuffer(dummyData)
}

func TestUploadAppSource(t *testing.T) {
Expand All @@ -26,7 +28,7 @@ func TestUploadAppSource(t *testing.T) {
doer.On("Do", mock.Anything).Return(nilResp, testError)
s := Service{uploadDoer: &doer}

err := s.UploadAppSource("http://some-upload-url", dummyReader())
err := s.UploadAppSource("http://some-upload-url", UploadArchive{Reader: dummyReader(), Size: int64(len(dummyData))})

assert.ErrorIs(t, err, testError)
})
Expand All @@ -38,7 +40,7 @@ func TestUploadAppSource(t *testing.T) {
doer.On("Do", mock.Anything).Return(&resp, nil)
s := Service{uploadDoer: &doer}

err := s.UploadAppSource("http://some-upload-url", dummyReader())
err := s.UploadAppSource("http://some-upload-url", UploadArchive{Reader: dummyReader(), Size: int64(len(dummyData))})

expected := AppSourceUploadError{
HTTPStatusCode: http.StatusBadRequest,
Expand All @@ -55,7 +57,7 @@ func TestUploadAppSource(t *testing.T) {
t.Run("given invalid upload URL then it returns error", func(t *testing.T) {
s := Service{uploadDoer: &test.MockDoer{}}

err := s.UploadAppSource("://invalid-url", dummyReader())
err := s.UploadAppSource("://invalid-url", UploadArchive{Reader: dummyReader(), Size: int64(len(dummyData))})

assert.Error(t, err)
})
Expand All @@ -66,7 +68,7 @@ func TestUploadAppSource(t *testing.T) {
doer.On("Do", mock.Anything).Return(&resp, nil)
s := Service{uploadDoer: &doer}

err := s.UploadAppSource("http://some-upload-url", bytes.NewReader([]byte("")))
err := s.UploadAppSource("http://some-upload-url", UploadArchive{Reader: bytes.NewReader([]byte("")), Size: 0})

assert.NoError(t, err)
})
Expand All @@ -76,7 +78,8 @@ func TestUploadAppSource(t *testing.T) {
resp := http.Response{Status: "OK", StatusCode: http.StatusOK}
doer.On("Do", mock.Anything).Return(&resp, nil)
s := Service{uploadDoer: &doer}
err := s.UploadAppSource("http://some-upload-url", bytes.NewReader([]byte("some data")))
data := []byte("some data")
err := s.UploadAppSource("http://some-upload-url", UploadArchive{Reader: bytes.NewReader(data), Size: int64(len(data))})

assert.NoError(t, err)
doer.AssertCalled(t, "Do", mock.MatchedBy(func(r *http.Request) bool {
Expand Down

0 comments on commit ff971bd

Please sign in to comment.