From 4d66ab20cbd0bcf290bc859ff9a865309e807cdb Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 4 Oct 2026 19:25:20 -0700 Subject: [PATCH 1/5] feat: modernize CLI deployment progress Keep replica counts visible during nested health and drain waits, and reserve completion until retirement and slot assignment succeed. Add readable active steps, static output controls, and a local demo; align documentation and website examples with the CLI. --- README.md | 10 +- docs/media/deploy-dark.svg | 13 +- docs/media/deploy-light.svg | 13 +- go.mod | 2 +- internal/engine/deploy.go | 6 +- internal/engine/roll.go | 18 +- internal/engine/roll_test.go | 42 +++++ internal/ui/ui.go | 188 ++++++++++++++++--- internal/ui/ui_test.go | 130 +++++++++++++ scripts/ui-demo/main.go | 105 +++++++++++ site/src/components/landing/Hero.astro | 44 ++++- site/src/content/docs/start/first-deploy.mdx | 41 +++- 12 files changed, 551 insertions(+), 61 deletions(-) create mode 100644 scripts/ui-demo/main.go 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..e10f845d 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 { 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/ui/ui.go b/internal/ui/ui.go index 8a65da54..80e72d20 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,39 @@ func New(out io.Writer, verbose bool) *UI { if f, ok := out.(*os.File); ok { tty = term.IsTerminal(int(f.Fd())) } + tty = tty && os.Getenv("TERM") != "dumb" && os.Getenv("ONEBOX_NO_ANIMATION") == "" && + (os.Getenv("CI") == "" || os.Getenv("CI") == "false") 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 +93,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 +118,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,7 +200,7 @@ func (u *UI) Diff(diff string) { } } -var spinFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +var spinFrames = []string{"▖", "▘", "▝", "▗"} const ( hideCursor = "\x1b[?25l" @@ -193,41 +220,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 +283,93 @@ 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 { + return ansi.Truncate(prefix+text+suffix, width, "…") + } + return prefix + ansi.Truncate(text, available, "…") + suffix } diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 1ce28d69..88a4670e 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,131 @@ 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 >= 20 && (!strings.Contains(line, "2/3") || !strings.Contains(line, "12s")) { + t.Fatalf("width %d: measured progress and timing must survive truncation: %s", width, line) + } + } +} + +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..f0c93f96 100644 --- a/site/src/components/landing/Hero.astro +++ b/site/src/components/landing/Hero.astro @@ -61,12 +61,13 @@ 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 +✓ 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 +193,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 +265,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 +277,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 +350,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..6c985970 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,31 @@ 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. 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 From b626e9635160956933b7bd967b11239349b318ea Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 4 Oct 2026 19:53:10 -0700 Subject: [PATCH 2/5] fix: preserve compact progress and static cursor output Prioritize replica counts and elapsed time when an extremely narrow terminal cannot fit the normal live line. Share animation controls with cursor restoration so static modes do not emit cursor control sequences. --- internal/ui/ui.go | 20 +++++++++++++++----- internal/ui/ui_test.go | 24 +++++++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 80e72d20..e7d37098 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -52,8 +52,7 @@ func New(out io.Writer, verbose bool) *UI { if f, ok := out.(*os.File); ok { tty = term.IsTerminal(int(f.Fd())) } - tty = tty && os.Getenv("TERM") != "dumb" && os.Getenv("ONEBOX_NO_ANIMATION") == "" && - (os.Getenv("CI") == "" || os.Getenv("CI") == "false") + tty = tty && animationAllowed() return &UI{ out: out, tty: tty, @@ -207,10 +206,15 @@ const ( 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) } } @@ -369,7 +373,13 @@ func (u *UI) busyLineLocked(frame, width int) string { } available := width - lipgloss.Width(prefix) - lipgloss.Width(suffix) if available < 1 { - return ansi.Truncate(prefix+text+suffix, width, "…") + // 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 88a4670e..633a0570 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -262,12 +262,34 @@ func TestBusyLineFitsNarrowAndUnicodeTerminals(t *testing.T) { if lipgloss.Width(line) > width || strings.ContainsAny(line, "\r\n") { t.Fatalf("width %d: spinner wraps or contains newlines: %q", width, line) } - if width >= 20 && (!strings.Contains(line, "2/3") || !strings.Contains(line, "12s")) { + 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}, + {"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) From 21ec8a7d0a52bbff9adb9a0d698e97763e75ae57 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 4 Oct 2026 20:47:52 -0700 Subject: [PATCH 3/5] test: verify CI presence disables terminal animation Cover CI=FALSE, CI=0, and arbitrary non-empty values explicitly. Document that only the exact CI=false value opts out of CI mode, matching the renderer and cursor restoration checks. --- internal/ui/ui_test.go | 3 +++ site/src/content/docs/start/first-deploy.mdx | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 633a0570..dc8632bd 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -276,6 +276,9 @@ func TestAnimationControls(t *testing.T) { {"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}, } { diff --git a/site/src/content/docs/start/first-deploy.mdx b/site/src/content/docs/start/first-deploy.mdx index 6c985970..c06e4f5d 100644 --- a/site/src/content/docs/start/first-deploy.mdx +++ b/site/src/content/docs/start/first-deploy.mdx @@ -182,8 +182,10 @@ 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. Set -`ONEBOX_NO_ANIMATION=1` for static output in an interactive terminal. `NO_COLOR` +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. From 8350e16835f2594f6875300f149fe2857036e164 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 4 Oct 2026 20:57:27 -0700 Subject: [PATCH 4/5] fix: retain workload results during recovery and rotation Direct rollback, interrupted-release recovery, and secret-generation replacement now own timed UI steps. Successful or failed rolls remain in scrollback after live progress clears, while normal deploy keeps its existing single completion step. --- internal/engine/deploy.go | 2 ++ internal/engine/recovery.go | 2 ++ internal/engine/recovery_test.go | 6 +++++- internal/engine/rollback_test.go | 3 +++ internal/engine/secret_generation_rolling_test.go | 6 ++++++ internal/engine/secretspush.go | 4 +++- 6 files changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/engine/deploy.go b/internal/engine/deploy.go index e10f845d..0ad5f201 100644 --- a/internal/engine/deploy.go +++ b/internal/engine/deploy.go @@ -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/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 From 5b845e8dc7c8d7d33f28a57ca62f980f506cebff Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 4 Oct 2026 21:02:53 -0700 Subject: [PATCH 5/5] docs: show completed migration in landing replay Include the timed migration result after its provider details so the website example matches the CLI and README transcript. --- site/src/components/landing/Hero.astro | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/components/landing/Hero.astro b/site/src/components/landing/Hero.astro index f0c93f96..0910c2d5 100644 --- a/site/src/components/landing/Hero.astro +++ b/site/src/components/landing/Hero.astro @@ -63,6 +63,7 @@ const { title = data.title, tagline, actions = [] } = data.hero ?? {}; $ ob deploy --plan ob-plan.json --approval ob-approval.json ✓ transfer 0.7s migrate provider atlas · 1 revision · changed=true +✓ job migrate 1.1s ✓ web rolling ×2 4.1s ✓ worker rolling 0.9s ✓ verify 0.6s