Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): show progress bar when uploading app sources with deploy #63

Merged
merged 1 commit into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading