diff --git a/README.md b/README.md index 187cb889..cc4c558b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ server. - An example Onebox session: ob plan prints a sealed diff of two image changes, a new workload and a migration whose data effect is unknown, then ob deploy rolls the workloads, verifies, and finishes with release r-0042 serving. + An example Onebox session: ob plan prints a sealed diff of two image changes, a new workload and a migration whose data effect is unknown, then ob deploy reports completed steps with durations and finishes with release r-0042 deployed. A rendering of an example session, not a recording of one. @@ -201,6 +201,14 @@ contract. ## Development +Preview the CLI's spinner, replica progress, and nested health/drain waits with +`go run ./scripts/ui-demo`; add `-fail` for a failed-healthcheck example. The +demo simulates a deployment locally and never connects to a server. + +Interactive terminals show a live progress line. Pipes, CI, and `TERM=dumb` +receive static progress updates. Set `ONEBOX_NO_ANIMATION=1` to use static +output in a terminal; `NO_COLOR` disables colors independently. + ```sh just check # local pre-commit gate just e2e # opt-in Docker end-to-end suite diff --git a/docs/media/deploy-dark.svg b/docs/media/deploy-dark.svg index f068ab3e..08bdafcf 100644 --- a/docs/media/deploy-dark.svg +++ b/docs/media/deploy-dark.svg @@ -1,4 +1,4 @@ - + - + @@ -34,7 +34,12 @@ plan sha256:9f21ab4c… · mode 0600 · expires 15m risk migration · strong confirmation required $ ob deploy --plan ob-plan.json --approval ob-approval.json - verified checks/url 200 · X-App-Ready: yes - serving r-0042 · r-0041 superseded + ✓ transfer 0.7s + ✓ job migrate 1.1s + ✓ web rolling ×2 4.1s + ✓ worker rolling 0.9s + ✓ verify 0.6s + ✓ activate 0.2s + ✓ deployed r-0042 in 8.4s (prev r-0041) diff --git a/docs/media/deploy-light.svg b/docs/media/deploy-light.svg index 7a567cff..87af561c 100644 --- a/docs/media/deploy-light.svg +++ b/docs/media/deploy-light.svg @@ -1,4 +1,4 @@ - + - + @@ -37,7 +37,12 @@ plan sha256:9f21ab4c… · mode 0600 · expires 15m risk migration · strong confirmation required $ ob deploy --plan ob-plan.json --approval ob-approval.json - verified checks/url 200 · X-App-Ready: yes - serving r-0042 · r-0041 superseded + ✓ transfer 0.7s + ✓ job migrate 1.1s + ✓ web rolling ×2 4.1s + ✓ worker rolling 0.9s + ✓ verify 0.6s + ✓ activate 0.2s + ✓ deployed r-0042 in 8.4s (prev r-0041) diff --git a/go.mod b/go.mod index 01c11204..5e4a790a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.27.0 require ( github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.8 github.com/compose-spec/compose-go/v2 v2.14.0 github.com/distribution/reference v0.6.0 github.com/opencontainers/go-digest v1.0.0 @@ -20,7 +21,6 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect diff --git a/internal/engine/deploy.go b/internal/engine/deploy.go index 32ec52b3..0ad5f201 100644 --- a/internal/engine/deploy.go +++ b/internal/engine/deploy.go @@ -274,7 +274,7 @@ func (e *Engine) runPhases(ctx context.Context, jw *journal.Writer, releaseID, l if done["transfer"] { e.logf("transfer: already complete (resume)") } else { - tr := e.ui.Step("transfer", false) + tr := e.ui.Step("transfer", true) if localStagingDir == "" { res, err := e.T.Run(ctx, "test -d "+q(remoteDir)) if err != nil || res.ExitCode != 0 { @@ -414,7 +414,7 @@ func (e *Engine) runPhases(ctx context.Context, jw *journal.Writer, releaseID, l } e.progress("verification", "started", "") - vf := e.ui.Step("verify", false) + vf := e.ui.Step("verify", true) if err := e.Verify(ctx); err != nil { vf(err) e.progress("verification", "failed", "verification failed; inspect journal evidence") @@ -430,7 +430,7 @@ func (e *Engine) runPhases(ctx context.Context, jw *journal.Writer, releaseID, l e.progress("verification", "succeeded", "") e.progress("activation", "started", "") - fin := e.ui.Step("activate", false) + fin := e.ui.Step("activate", true) if err := jw.Append(ctx, journal.Record{ Phase: "activation", Event: "intent", Detail: "release=" + releaseID, }); err != nil { @@ -719,12 +719,14 @@ func (e *Engine) releaseRoles(ctx context.Context, remoteCompose string) error { for _, roleName := range e.Spec.ReleaseOrder() { role := e.Spec.Workloads[roleName] e.logf("release %s (%s)", roleName, role.Mode()) + step := e.ui.Step(roleName+" "+role.Mode(), true) var err error if role.Mode() == "rolling" { err = e.RollRole(ctx, roleName, remoteCompose) } else { err = e.RecreateRole(ctx, roleName, remoteCompose) } + step(err) if err != nil { return fmt.Errorf("%s: %w", roleName, err) } diff --git a/internal/engine/recovery.go b/internal/engine/recovery.go index 3b01686c..5b919c02 100644 --- a/internal/engine/recovery.go +++ b/internal/engine/recovery.go @@ -250,12 +250,14 @@ func (e *Engine) restoreReleaseRoles(ctx context.Context, previous *Engine, prev continue } } + step := previous.ui.Step(roleName+" "+role.Mode(), true) var err error if role.Mode() == "rolling" { err = previous.RollRole(ctx, roleName, composePath) } else { err = previous.RecreateRole(ctx, roleName, composePath) } + step(err) if err != nil { return fmt.Errorf("restore %s: %w", roleName, err) } diff --git a/internal/engine/recovery_test.go b/internal/engine/recovery_test.go index 4c92628e..ff2d62d6 100644 --- a/internal/engine/recovery_test.go +++ b/internal/engine/recovery_test.go @@ -81,8 +81,9 @@ func TestRecoveryRetryKeepsCheckpointUntilHealthyAndSweepsStaleRoles(t *testing. } return base(command) } + var output bytes.Buffer engine := New(testConfig(), testProject(t), target, Options{ - Out: &bytes.Buffer{}, Sleep: noSleep, + Out: &output, Sleep: noSleep, Now: func() time.Time { return time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) }, }) seedInterruptedRecoveryState(t, engine) @@ -99,6 +100,9 @@ func TestRecoveryRetryKeepsCheckpointUntilHealthyAndSweepsStaleRoles(t *testing. if !errors.As(err, &incomplete) || incomplete.Code() != "recovery_incomplete" || incomplete.Phase != "verify" { t.Fatalf("first recovery error = %#v", err) } + if strings.Count(output.String(), "✓ web rolling") != 1 { + t.Fatalf("restored workload must leave one completed step before verification: %s", output.String()) + } if _, err := release.ReadActivationCheckpoint(context.Background(), target, engine.Names()); err != nil { t.Fatalf("failed recovery cleared checkpoint: %v", err) } diff --git a/internal/engine/roll.go b/internal/engine/roll.go index 2a27ce26..3b14351a 100644 --- a/internal/engine/roll.go +++ b/internal/engine/roll.go @@ -135,6 +135,8 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose cc := e.composeCmdForProject(remoteComposePath, remoteProjectDir) desired := role.Count() within, pollEvery := role.ReadyTiming() + update, stop := e.ui.Progress(roleName+" rolling", desired) + defer stop() pulled := false // Each pass converges by one step: add a missing new replica, or retire a @@ -149,6 +151,11 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose return err } olds := subtract(cur, news) + // A running newcomer alone is not a completed replacement: its old + // replica must have drained and retired too. Reserve the final segment + // until slot assignment has succeeded, including when resuming a roll. + completed := min(len(news), max(0, desired-len(olds)), max(0, desired-1)) + update(completed, "inspecting replicas") if guard > 4*(desired+len(olds))+8 { return fmt.Errorf("role %s: roll did not converge (news=%d olds=%d)", roleName, len(news), len(olds)) } @@ -158,6 +165,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose break } // surplus new replicas (count reduced) — drain one down to desired + update(completed, "retiring surplus replica") if err := e.retireContainer(ctx, role, news[desired], pollEvery); err != nil { return err } @@ -167,6 +175,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose // surge one new replica if we still need more of the new release if len(news) < desired { if !pulled { + update(completed, "checking image") if err := e.pullBeforeRelease(ctx, svc, cc); err != nil { return err } @@ -193,6 +202,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose } known := idSet(news) scale := len(cur) + 1 + update(completed, fmt.Sprintf("starting replica %d", len(news)+1)) if res, err := e.mutate(ctx, fmt.Sprintf("%s up -d --no-deps --no-recreate --scale %s=%d %s", cc, svc, scale, svc)); err != nil { return err } else if res.ExitCode != 0 { @@ -218,6 +228,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose return err } // join: the newcomer becomes a routable endpoint via its healthcheck + update(completed, fmt.Sprintf("replica %d healthcheck", len(news)+1)) if err := e.waitHealth(ctx, newID, "healthy", within, pollEvery); err != nil { e.logf("join failed for %s — removing new container, existing keep serving", roleName) cleanupErr := e.mutateChecked(ctx, "remove unhealthy newcomer "+newID, "docker rm -f "+newID) @@ -227,12 +238,14 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose // retire one old, freeing its slot for the newcomer just added if len(olds) > 0 { + update(completed, "retiring old replica") if err := e.retireContainer(ctx, role, olds[0], pollEvery); err != nil { return err } } // hand clean slot names to any new container that doesn't have one yet + update(completed, "assigning replica slots") if err := e.reslot(ctx, svc, releaseID, generation, desired); err != nil { return err } @@ -240,6 +253,7 @@ func (e *Engine) rollRoleForRelease(ctx context.Context, roleName, remoteCompose if err := e.reslot(ctx, svc, releaseID, generation, desired); err != nil { return err } + update(desired, "replicas converged") return nil } @@ -477,7 +491,8 @@ func subtract(all, remove []string) []string { func (e *Engine) waitHealth(ctx context.Context, id, want string, budget, interval time.Duration) error { deadline := e.Opts.Now().Add(budget) - _, stop := e.ui.Busy(fmt.Sprintf("waiting %.12s → %s", id, want)) + label := fmt.Sprintf("waiting %.12s → %s", id, want) + update, stop := e.ui.Busy(label) defer stop() for { h, err := e.healthOf(ctx, id) @@ -487,6 +502,7 @@ func (e *Engine) waitHealth(ctx context.Context, id, want string, budget, interv if h == want { return nil } + update(label + " · " + h) // A container that has exited will not report a different health // status later, so waiting out the budget only delays the same answer // with a worse message. diff --git a/internal/engine/roll_test.go b/internal/engine/roll_test.go index 56c6ee72..28879af4 100644 --- a/internal/engine/roll_test.go +++ b/internal/engine/roll_test.go @@ -269,6 +269,48 @@ func TestRollRoleTwoReplicasCleanSlots(t *testing.T) { } } +func TestRollProgressCompletesOnlyAfterRetirementAndSlots(t *testing.T) { + f := rollFake() + var out bytes.Buffer + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + if strings.HasPrefix(cmd, "docker stop ") || strings.HasPrefix(cmd, "docker rename ") { + if strings.Contains(out.String(), "web rolling · 1/1") { + t.Fatalf("progress completed before retirement/slot assignment: %s", out.String()) + } + } + return base(cmd) + } + e := New(testConfig(), testProject(t), f, Options{Out: &out, Sleep: noSleep}) + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "web rolling · 1/1 · replicas converged") { + t.Fatalf("successful roll must finish measured progress: %s", out.String()) + } +} + +func TestRollProgressDoesNotCompleteOnFailedHealthcheck(t *testing.T) { + f := rollFake() + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + if strings.Contains(cmd, "State.Health") && strings.Contains(cmd, "NEW1") { + return transport.Result{Stdout: "starting\n"}, true + } + return base(cmd) + } + cfg := testConfig() + cfg.Workloads["web"] = withinMillis(cfg.Workloads["web"], 1) + var out bytes.Buffer + e := New(cfg, testProject(t), f, Options{Out: &out, Sleep: noSleep}) + if err := e.RollRole(context.Background(), "web", "F"); err == nil { + t.Fatal("expected failed healthcheck") + } + if strings.Contains(out.String(), "web rolling · 1/1") || strings.Contains(out.String(), "replicas converged") { + t.Fatalf("failed roll must not show full progress: %s", out.String()) + } +} + // drain.grace sets the docker stop -t timeout when retiring a drained container; // absent it stays at the conservative 30s (asserted by the sequence tests). func TestRollRoleDrainGraceConfigurable(t *testing.T) { diff --git a/internal/engine/rollback_test.go b/internal/engine/rollback_test.go index c2a2922c..a8d48d8c 100644 --- a/internal/engine/rollback_test.go +++ b/internal/engine/rollback_test.go @@ -82,6 +82,9 @@ func TestRollbackReplaysSnapshotChoreography(t *testing.T) { if err := e.Rollback(context.Background()); err != nil { t.Fatalf("rollback: %v\n%s", err, strings.Join(f.Commands, "\n")) } + if strings.Count(out.String(), "✓ worker recreate") != 1 { + t.Fatalf("rollback must leave one completed workload step: %s", out.String()) + } seq := strings.Join(f.Commands, "\n") if !strings.Contains(seq, "--force-recreate --timeout 30 worker") { t.Fatalf("snapshot choreography (worker recreate) not replayed:\n%s", seq) diff --git a/internal/engine/secret_generation_rolling_test.go b/internal/engine/secret_generation_rolling_test.go index 0a6f1877..75eac544 100644 --- a/internal/engine/secret_generation_rolling_test.go +++ b/internal/engine/secret_generation_rolling_test.go @@ -226,6 +226,9 @@ func TestSecretGenerationRollsRollingWorkloads(t *testing.T) { if _, err := engine.SecretsPushBatch(context.Background(), generationPayloads()); err != nil { t.Fatalf("push: %v\n%s", err, strings.Join(fake.Commands, "\n")) } + if strings.Count(output.String(), "✓ web rolling") != 1 { + t.Fatalf("secret roll must leave one completed workload step: %s", output.String()) + } commands := strings.Join(fake.Commands, "\n") webUp := upCommandsFor(fake.Commands, "web") if len(webUp) == 0 { @@ -263,6 +266,9 @@ func TestSecretGenerationRollingKeepsServingReplicaWhenNewcomerNeverHealthy(t *t if _, err := engine.SecretsPushBatch(context.Background(), generationPayloads()); err == nil { t.Fatalf("unhealthy newcomer must fail the push:\n%s", strings.Join(fake.Commands, "\n")) } + if !strings.Contains(output.String(), "✗ web rolling") || strings.Contains(output.String(), "✓ web rolling") { + t.Fatalf("failed secret roll must leave a failed workload step: %s", output.String()) + } commands := strings.Join(fake.Commands, "\n") if !strings.Contains(commands, "docker rm -f W2") { t.Fatalf("unhealthy newcomer was not removed:\n%s", commands) diff --git a/internal/engine/secretspush.go b/internal/engine/secretspush.go index f865163d..2c58b566 100644 --- a/internal/engine/secretspush.go +++ b/internal/engine/secretspush.go @@ -740,7 +740,7 @@ func (e *Engine) cleanupOrphanSecretGenerations(ctx context.Context, releaseID s return e.mutateChecked(ctx, "clean orphaned secret generations", command) } -func (e *Engine) forceSecretGeneration(ctx context.Context, checkpoint release.SecretCheckpoint, workload, generation string) error { +func (e *Engine) forceSecretGeneration(ctx context.Context, checkpoint release.SecretCheckpoint, workload, generation string) (err error) { // Already converged. Two paths reach here that way: a resume after a crash, // and recovery after a roll whose unhealthy newcomer was removed without // any old replica being touched. Neither has anything to replace, and @@ -760,6 +760,8 @@ func (e *Engine) forceSecretGeneration(ctx context.Context, checkpoint release.S return err } composePath := generationDir + "/compose.yaml" + step := e.ui.Step(workload+" "+e.Spec.Workloads[workload].Mode(), true) + defer func() { step(err) }() // A rolling workload rotates its secret the way it takes a release: surge // one replica on the new generation, gate it healthy, retire one old. // Recreating instead destroyed every serving replica before the first diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 8a65da54..e7d37098 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -14,6 +14,7 @@ import ( "time" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "golang.org/x/term" ) @@ -24,10 +25,10 @@ type UI struct { verbose bool now func() time.Time - // one live spinner line at a time; println clears it so narrative and - // spinner never collide, and the spinner repaints on its next tick - spinLabel string - spinOn bool + // Nested steps share one live line. A child wait keeps its parent's measured + // progress visible; stopping it restores the parent without showing the cursor. + busy []*busyState + width func() int sHeader lipgloss.Style sOK lipgloss.Style @@ -35,6 +36,14 @@ type UI struct { sWarn lipgloss.Style sDim lipgloss.Style sBold lipgloss.Style + sActive lipgloss.Style +} + +type busyState struct { + label, detail string + completed, total int + started time.Time + stopped bool } func New(out io.Writer, verbose bool) *UI { @@ -43,24 +52,38 @@ func New(out io.Writer, verbose bool) *UI { if f, ok := out.(*os.File); ok { tty = term.IsTerminal(int(f.Fd())) } + tty = tty && animationAllowed() return &UI{ out: out, tty: tty, verbose: verbose, now: time.Now, + width: func() int { + if f, ok := out.(*os.File); ok { + if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 { + return w + } + } + return 80 + }, sHeader: r.NewStyle().Bold(true), sOK: r.NewStyle().Foreground(lipgloss.Color("2")), sFail: r.NewStyle().Foreground(lipgloss.Color("1")), sWarn: r.NewStyle().Foreground(lipgloss.Color("3")), sDim: r.NewStyle().Faint(true), sBold: r.NewStyle().Bold(true), + sActive: r.NewStyle().Foreground(lipgloss.Color("6")), } } func (u *UI) println(s string) { u.mu.Lock() defer u.mu.Unlock() - if u.spinOn { + u.printlnLocked(s) +} + +func (u *UI) printlnLocked(s string) { + if len(u.busy) > 0 { _, _ = io.WriteString(u.out, "\r\x1b[K") // clear the spinner line first } _, _ = io.WriteString(u.out, s+"\n") @@ -69,6 +92,9 @@ func (u *UI) println(s string) { // Header opens a section: ── title ─────────────── func (u *UI) Header(title string) { line := "── " + title + " " + strings.Repeat("─", max(0, 50-len(title))) + if u.tty { + line = ansi.Truncate(line, max(1, u.width()-1), "…") + } u.println(u.sHeader.Render(line)) } @@ -91,10 +117,10 @@ func (u *UI) Begin(label string) { // Done closes a step with its outcome and duration. func (u *UI) Done(label string, d time.Duration, err error) { if err != nil { - u.println(u.sFail.Render("✗ "+label) + u.sDim.Render(" "+FmtDur(d))) + u.println(u.sFail.Render("✗") + " " + label + u.sDim.Render(" "+FmtDur(d))) return } - u.println(u.sOK.Render("✓ "+label) + u.sDim.Render(" "+FmtDur(d))) + u.println(u.sOK.Render("✓") + " " + label + u.sDim.Render(" "+FmtDur(d))) } // Step times a step: call the returned func with the outcome. Announced steps @@ -173,17 +199,22 @@ func (u *UI) Diff(diff string) { } } -var spinFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +var spinFrames = []string{"▖", "▘", "▝", "▗"} const ( hideCursor = "\x1b[?25l" showCursor = "\x1b[?25h" ) -// RestoreCursor re-shows the terminal cursor if w is a TTY — for signal +func animationAllowed() bool { + return os.Getenv("TERM") != "dumb" && os.Getenv("ONEBOX_NO_ANIMATION") == "" && + (os.Getenv("CI") == "" || os.Getenv("CI") == "false") +} + +// RestoreCursor re-shows the terminal cursor if w is an animated TTY — for signal // handlers: an interrupt mid-spinner must not leave the terminal cursorless. func RestoreCursor(w io.Writer) { - if f, ok := w.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + if f, ok := w.(*os.File); ok && term.IsTerminal(int(f.Fd())) && animationAllowed() { _, _ = io.WriteString(w, showCursor) } } @@ -193,41 +224,62 @@ func RestoreCursor(w io.Writer) { // a TTY it prints one ⟳ line per distinct label — CI logs stay line-honest. // update relabels the spinner; stop erases it. func (u *UI) Busy(label string) (update func(string), stop func()) { + set, stop := u.startBusy(label, 0) + return func(label string) { set(0, label) }, stop +} + +// Progress displays completed units out of a known total. Small totals get one +// segment per unit; larger totals use a compact ten-segment bar. A total <= 0 +// falls back to a spinner. Off a TTY, only distinct count/detail updates print. +// Completion of the units does not imply success: the caller still owns Done. +func (u *UI) Progress(label string, total int) (update func(int, string), stop func()) { + return u.startBusy(label, max(0, total)) +} + +func (u *UI) startBusy(label string, total int) (func(int, string), func()) { + s := &busyState{label: label, total: total, started: u.now()} if !u.tty { - u.Begin(label) - last := label - return func(l string) { - if l != last { - u.Begin(l) - last = l + last := plainBusy(s) + u.Begin(last) + return func(completed int, text string) { + u.mu.Lock() + defer u.mu.Unlock() + if s.stopped { + return + } + setBusy(s, completed, text) + if line := plainBusy(s); line != last { + u.printlnLocked(u.sDim.Render("⟳ " + line)) + last = line + } + }, func() { + u.mu.Lock() + defer u.mu.Unlock() + s.stopped = true } - }, func() {} } u.mu.Lock() - u.spinLabel, u.spinOn = label, true - _, _ = io.WriteString(u.out, hideCursor) // the blinking cursor at line end is just noise + if len(u.busy) == 0 { + _, _ = io.WriteString(u.out, hideCursor) + } + u.busy = append(u.busy, s) + u.renderBusyLocked(0) u.mu.Unlock() - started := u.now() done := make(chan struct{}) finished := make(chan struct{}) go func() { defer close(finished) t := time.NewTicker(120 * time.Millisecond) defer t.Stop() - i := 0 + i := 1 for { select { case <-done: - u.mu.Lock() - _, _ = io.WriteString(u.out, "\r\x1b[K"+showCursor) - u.spinOn = false - u.mu.Unlock() return case <-t.C: u.mu.Lock() - if u.spinOn { - line := fmt.Sprintf("%s %s · %s elapsed", spinFrames[i%len(spinFrames)], u.spinLabel, FmtDur(u.now().Sub(started))) - _, _ = io.WriteString(u.out, "\r\x1b[K"+u.sDim.Render(line)) + if len(u.busy) > 0 && u.busy[len(u.busy)-1] == s { + u.renderBusyLocked(i) } u.mu.Unlock() i++ @@ -235,11 +287,99 @@ func (u *UI) Busy(label string) (update func(string), stop func()) { } }() var once sync.Once - return func(l string) { + return func(completed int, text string) { u.mu.Lock() - u.spinLabel = l + if !s.stopped { + setBusy(s, completed, text) + u.renderBusyLocked(0) + } u.mu.Unlock() }, func() { - once.Do(func() { close(done); <-finished }) + once.Do(func() { + u.mu.Lock() + s.stopped = true + for i, item := range u.busy { + if item == s { + u.busy = append(u.busy[:i], u.busy[i+1:]...) + break + } + } + if len(u.busy) == 0 { + _, _ = io.WriteString(u.out, "\r\x1b[K"+showCursor) + } else { + u.renderBusyLocked(0) + } + u.mu.Unlock() + close(done) + <-finished + }) + } +} + +func setBusy(s *busyState, completed int, text string) { + if s.total > 0 { + s.completed, s.detail = min(max(0, completed), s.total), text + } else { + s.label = text + } +} + +func plainBusy(s *busyState) string { + line := s.label + if s.total > 0 { + line += fmt.Sprintf(" · %d/%d", s.completed, s.total) + if s.detail != "" { + line += " · " + s.detail + } + } + return line +} + +func (u *UI) renderBusyLocked(frame int) { + _, _ = io.WriteString(u.out, "\r\x1b[K"+u.busyLineLocked(frame, max(1, u.width()-1))) +} + +// Keep one cell free to avoid automatic wrapping at the terminal's right edge. +// Timing and counts survive truncation; only the narrative text is shortened. +func (u *UI) busyLineLocked(frame, width int) string { + current := u.busy[len(u.busy)-1] + display := current + for i := len(u.busy) - 1; i >= 0; i-- { + if u.busy[i].total > 0 { + display = u.busy[i] + break } + } + detail := display.detail + if current != display { + detail = current.label + } + text := display.label + if detail != "" { + text += " · " + detail + } + text = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(text) + prefix := u.sActive.Render(spinFrames[frame%len(spinFrames)]) + " " + suffix := u.sDim.Render(" " + FmtDur(u.now().Sub(display.started))) + if display.total > 0 { + count := fmt.Sprintf(" %d/%d", display.completed, display.total) + bar := "" + if width >= 60 && display.total > 1 { + segments := min(display.total, 10) + filled := int(float64(display.completed) / float64(display.total) * float64(segments)) + bar = u.sActive.Render(strings.Repeat("■", filled)) + u.sDim.Render(strings.Repeat("□", segments-filled)) + } + suffix = " " + bar + u.sDim.Render(count) + suffix + } + available := width - lipgloss.Width(prefix) - lipgloss.Width(suffix) + if available < 1 { + // Drop narrative, spinner, and padding before measured information. + // When even the compact form cannot fit, prioritize the count. + compact := FmtDur(u.now().Sub(display.started)) + if display.total > 0 { + compact = fmt.Sprintf("%d/%d %s", display.completed, display.total, compact) + } + return u.sDim.Render(ansi.Truncate(compact, width, "…")) + } + return prefix + ansi.Truncate(text, available, "…") + suffix } diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 1ce28d69..dc8632bd 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + "github.com/charmbracelet/lipgloss" ) func TestPlainWriterGetsNoANSI(t *testing.T) { @@ -148,3 +150,156 @@ func TestBusyNonTTY(t *testing.T) { t.Fatalf("busy labels must print once each:\n%s", s) } } + +func TestProgressNonTTYReportsDistinctMeasuredCounts(t *testing.T) { + var out bytes.Buffer + u := New(&out, false) + update, stop := u.Progress("server rolling", 3) + update(-1, "starting replica 1") + update(2, "draining old replica") + update(2, "draining old replica") + update(99, "replicas converged") + stop() + update(1, "late update") + stop() + s := out.String() + if strings.ContainsAny(s, "\r\x1b") || strings.ContainsAny(s, "■□") { + t.Fatalf("plain progress must stay line-oriented: %q", s) + } + for _, want := range []string{"server rolling · 0/3", "server rolling · 2/3", "server rolling · 3/3"} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q in %s", want, s) + } + } + if strings.Count(s, "draining old replica") != 1 || strings.Contains(s, "late update") { + t.Fatalf("duplicate or stopped progress update: %s", s) + } +} + +func TestProgressUnknownTotalFallsBackToSpinner(t *testing.T) { + var out bytes.Buffer + u := New(&out, false) + update, stop := u.Progress("packing archive", 0) + defer stop() + update(42, "waiting for remote extraction") + if s := out.String(); strings.Contains(s, "/0") || !strings.Contains(s, "waiting for remote extraction") { + t.Fatalf("unknown total must not invent a percentage: %s", s) + } +} + +func TestNestedBusyKeepsProgressAndRestoresParent(t *testing.T) { + var out bytes.Buffer + u := New(&out, false) + u.tty = true + _, stopStep := u.Busy("server rolling ×3") + defer stopStep() + update, stopProgress := u.Progress("server rolling", 3) + defer stopProgress() + update(2, "assigning slots") + _, stopWait := u.Busy("replica 3 healthcheck") + defer stopWait() + u.mu.Lock() + line := u.busyLineLocked(0, 79) + u.mu.Unlock() + for _, want := range []string{"server rolling", "■■□", "2/3", "replica 3 healthcheck"} { + if !strings.Contains(line, want) { + t.Fatalf("nested wait lost %q: %s", want, line) + } + } + u.Infof("an interleaved log") + stopWait() + u.mu.Lock() + line = u.busyLineLocked(0, 79) + shown := strings.Contains(out.String(), showCursor) + u.mu.Unlock() + if shown || strings.Contains(line, "healthcheck") || !strings.Contains(line, "assigning slots") { + t.Fatalf("child stop must restore its parent with cursor hidden: %s", line) + } + stopProgress() + u.mu.Lock() + line = u.busyLineLocked(0, 79) + u.mu.Unlock() + if !strings.Contains(line, "server rolling ×3") || strings.Contains(line, "2/3") { + t.Fatalf("progress stop did not restore the outer step: %s", line) + } + stopStep() + if strings.Count(out.String(), hideCursor) != 1 || strings.Count(out.String(), showCursor) != 1 { + t.Fatalf("nested spinners must share cursor ownership: %q", out.String()) + } +} + +func TestStoppingOuterBusyDoesNotEraseChild(t *testing.T) { + var out bytes.Buffer + u := New(&out, false) + u.tty = true + updateOuter, stopOuter := u.Busy("outer step") + defer stopOuter() + _, stopChild := u.Busy("child wait") + defer stopChild() + stopOuter() + updateOuter("late outer update") + u.mu.Lock() + line := u.busyLineLocked(0, 79) + shown := strings.Contains(out.String(), showCursor) + u.mu.Unlock() + if shown || !strings.Contains(line, "child wait") || strings.Contains(line, "late outer") { + t.Fatalf("outer stop must leave child active: %s", line) + } + stopChild() + if strings.Count(out.String(), showCursor) != 1 { + t.Fatalf("cursor must return once the last activity stops: %q", out.String()) + } +} + +func TestBusyLineFitsNarrowAndUnicodeTerminals(t *testing.T) { + u := New(&bytes.Buffer{}, false) + u.busy = []*busyState{{ + label: "server 界界界界界界界界界界 rolling", detail: "healthcheck\nstarting", + completed: 2, total: 3, started: u.now().Add(-12 * time.Second), + }} + for _, width := range []int{1, 8, 20, 40, 79} { + line := u.busyLineLocked(0, width) + if lipgloss.Width(line) > width || strings.ContainsAny(line, "\r\n") { + t.Fatalf("width %d: spinner wraps or contains newlines: %q", width, line) + } + if width >= 8 && (!strings.Contains(line, "2/3") || !strings.Contains(line, "12s")) { + t.Fatalf("width %d: measured progress and timing must survive truncation: %s", width, line) + } + } +} + +func TestAnimationControls(t *testing.T) { + for _, tc := range []struct { + name, term, ci, noAnimation string + want bool + }{ + {"interactive", "xterm-256color", "", "", true}, + {"ci-false", "xterm-256color", "false", "", true}, + {"ci", "xterm-256color", "true", "", false}, + {"ci-uppercase-false", "xterm-256color", "FALSE", "", false}, + {"ci-zero", "xterm-256color", "0", "", false}, + {"ci-present", "xterm-256color", "provider", "", false}, + {"dumb", "dumb", "", "", false}, + {"static", "xterm-256color", "", "1", false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("TERM", tc.term) + t.Setenv("CI", tc.ci) + t.Setenv("ONEBOX_NO_ANIMATION", tc.noAnimation) + if got := animationAllowed(); got != tc.want { + t.Fatalf("animationAllowed() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestHeaderFitsInteractiveTerminal(t *testing.T) { + var out bytes.Buffer + u := New(&out, false) + u.tty = true + u.width = func() int { return 24 } + u.Header("deploy catalog → production · R42") + if line := strings.TrimSuffix(out.String(), "\n"); lipgloss.Width(line) > 23 { + t.Fatalf("header wraps in a narrow terminal: %q", line) + } +} diff --git a/scripts/ui-demo/main.go b/scripts/ui-demo/main.go new file mode 100644 index 00000000..e2e18567 --- /dev/null +++ b/scripts/ui-demo/main.go @@ -0,0 +1,105 @@ +// Run with `go run ./scripts/ui-demo` in a terminal. This simulates deployment +// output through the real UI renderer; it never contacts or modifies a host. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/labstack/onebox/internal/ui" +) + +func main() { + fail := flag.Bool("fail", false, "show a failed healthcheck and recovery message") + flag.Parse() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + u := ui.New(os.Stdout, false) + err := demo(ctx, u, *fail) + ui.RestoreCursor(os.Stdout) + if err != nil { + u.Failf("%s", err) + os.Exit(1) + } +} + +func demo(ctx context.Context, u *ui.UI, fail bool) error { + started := time.Now() + u.Header("deploy catalog → production · R42") + u.Infof("Simulated deployment") + for _, step := range []string{"transfer", "job migrate"} { + done := u.Step(step, true) + err := wait(ctx, 700*time.Millisecond) + done(err) + if err != nil { + return err + } + } + if err := roll(ctx, u, fail); err != nil { + return err + } + for _, step := range []string{"verify", "activate"} { + done := u.Step(step, true) + err := wait(ctx, 650*time.Millisecond) + done(err) + if err != nil { + return err + } + } + u.Successf("deployed R42 in %s", ui.FmtDur(time.Since(started))) + return nil +} + +func roll(ctx context.Context, u *ui.UI, fail bool) (err error) { + done := u.Step("server rolling ×3", true) + defer func() { done(err) }() + update, stop := u.Progress("server rolling", 3) + defer stop() + for replica := 1; replica <= 3; replica++ { + update(replica-1, fmt.Sprintf("starting replica %d", replica)) + if err := wait(ctx, 350*time.Millisecond); err != nil { + return err + } + _, stopWait := u.Busy(fmt.Sprintf("replica %d · waiting for healthcheck", replica)) + err = wait(ctx, 850*time.Millisecond) + stopWait() + if err != nil { + return err + } + if fail && replica == 2 { + u.Warnf("Existing replicas continue serving traffic") + return errors.New("replica 2 healthcheck failed; inspect the service, then use ob resume or ob abort") + } + _, stopDrain := u.Busy("draining old replica") + err = wait(ctx, 600*time.Millisecond) + stopDrain() + if err != nil { + return err + } + update(replica, "replica replaced") + if replica == 1 { + u.Infof("server: replica 1 replaced; existing traffic remains available") + } + if err := wait(ctx, 250*time.Millisecond); err != nil { + return err + } + } + return nil +} + +func wait(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/site/src/components/landing/Hero.astro b/site/src/components/landing/Hero.astro index d4fec656..0910c2d5 100644 --- a/site/src/components/landing/Hero.astro +++ b/site/src/components/landing/Hero.astro @@ -61,12 +61,14 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {}; type the release id to confirm: r-0042 $ ob deploy --plan ob-plan.json --approval ob-approval.json -staged r-0042 +✓ transfer 0.7s migrate provider atlas · 1 revision · changed=true -rolling shop-web-1 … healthy -rolling shop-web-2 … healthy -verified checks/url 200 · X-App-Ready: yes -serving r-0042 · r-0041 superseded +✓ job migrate 1.1s +✓ web rolling ×2 4.1s +✓ worker rolling 0.9s +✓ verify 0.6s +✓ activate 0.2s +✓ deployed r-0042 in 8.4s (prev r-0041) @@ -192,6 +194,7 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {}; --term-text: #cad4c6; --term-muted: #788373; --term-green: #8ed879; + --term-active: #78d9db; --term-warn: #e2b75d; border: 1px solid var(--term-border); border-radius: 8px; @@ -263,7 +266,7 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {}; font-weight: 400; } - .ob-term .dim { + :global([data-ob-transcript] .dim) { color: var(--term-muted); } @@ -275,6 +278,10 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {}; color: var(--term-green); } + :global([data-ob-transcript] .active) { + color: var(--term-active); + } + .ob-term .warn { color: var(--term-warn); } @@ -344,11 +351,29 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {}; let i = 0; const step = () => { if (i >= lines.length) return; - lines[i]?.removeAttribute("data-pending"); + const line = lines[i]; + line?.removeAttribute("data-pending"); i += 1; - // Fast enough that the hero is not a blank box on arrival — the whole - // transcript lands in under two seconds — but still paced, because the - // point is that a deploy reports itself rather than appearing. + // Show measured replica progress briefly, then replace that same line + // with its completed step. No-JS and reduced-motion visitors see the + // completed transcript directly. + if (line?.hasAttribute("data-ob-roll")) { + const completed = line.innerHTML; + const frames = ["▖", "▘", "▝", "▗"]; + let frame = 0; + const render = () => { + line.innerHTML = ` web rolling · healthcheck 1/2 3.2s`; + frame += 1; + }; + render(); + const timer = window.setInterval(render, 120); + window.setTimeout(() => { + window.clearInterval(timer); + line.innerHTML = completed; + step(); + }, 960); + return; + } window.setTimeout(step, i < 10 ? 55 : 90); }; step(); diff --git a/site/src/content/docs/start/first-deploy.mdx b/site/src/content/docs/start/first-deploy.mdx index 8592a9d2..c06e4f5d 100644 --- a/site/src/content/docs/start/first-deploy.mdx +++ b/site/src/content/docs/start/first-deploy.mdx @@ -140,16 +140,16 @@ read-only. ob deploy --plan ob-plan.json --approval ob-approval.json ``` - A successful deploy ends by naming the release that is serving and the one it - superseded: + Completed steps stay in the terminal with their durations. This condensed + example shows a successful deploy, ending with its release and predecessor: ```console - staged r-0042 - rolling shop-web-1 … healthy - rolling shop-web-2 … healthy - retain shop-worker · runtime unchanged - verified checks/url 200 · X-App-Ready: yes - serving r-0042 · r-0041 superseded + ✓ transfer 0.7s + ✓ web rolling ×2 4.1s + ✓ worker retain 0.0s + ✓ verify 0.6s + ✓ activate 0.2s + ✓ deployed r-0042 in 6.8s (prev r-0041 — `ob rollback`) ``` Read the observed state back once more: @@ -162,6 +162,33 @@ read-only. +## Reading deployment progress + +In an interactive terminal, a cyan rotating block marks the active step. The +label stays readable while elapsed time and supporting details are quieter. +Rolling workloads also show measured replica progress; healthchecks and drain +waits share that same live line: + +```console +▘ web rolling · waiting for healthcheck ■□ 1/2 3.2s +``` + +The count advances as replacements converge, including retirement of the old +replicas. The last segment fills after replica slot assignment succeeds. It is +a count of replica work, not a percentage of the entire deployment or an +estimate of time remaining. Transfers and other waits use a spinner because +their total work is not measured by the CLI. + +On narrow terminals, the bar gives way to the count and the label shortens to +keep the live line from wrapping. Completed steps remain in scrollback. + +Piped output, CI, and `TERM=dumb` use static progress lines. Any non-empty `CI` +value selects static output, except the exact value `CI=false`; this includes +`CI=0` and `CI=FALSE`. Set `ONEBOX_NO_ANIMATION=1` for static output in an +interactive terminal. `NO_COLOR` +disables colors independently of animation. JSON and NDJSON keep their +structured output contracts. + ## What you now have on the host