Skip to content
Draft
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
48 changes: 48 additions & 0 deletions cli/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
Expand Down Expand Up @@ -485,6 +486,53 @@ func TestSyncCommands_Golden(t *testing.T) {
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/timeline_with_units", outBuf.Bytes(), nil)
})

t.Run("timeline_watch", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()

ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitLong))
defer cancel()

client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
require.NoError(t, err)
defer client.Close()
err = client.SyncStart(ctx, "dep-unit")
require.NoError(t, err)

inv, _ := clitest.New(t, "exp", "sync", "timeline", "--watch", "--socket-path", path)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()

waiter := clitest.StartWithWaiter(t, inv.WithContext(ctx))

// Events recorded before the watch started are rendered first.
pty.ExpectMatch(ctx, "registered (pending)")
pty.ExpectMatch(ctx, "pending → started")

// Events recorded while watching are streamed as new rows.
err = client.SyncComplete(ctx, "dep-unit")
require.NoError(t, err)
pty.ExpectMatch(ctx, "started → completed")

// Interrupting the watch is a clean exit.
cancel()
waiter.RequireSuccess()
})

t.Run("timeline_watch_rejects_json", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()

ctx := testutil.Context(t, testutil.WaitShort)

inv, _ := clitest.New(t, "exp", "sync", "timeline", "--watch", "--output", "json", "--socket-path", path)
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "--watch only supports text output")
})

t.Run("timeline_json_format", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
Expand Down
57 changes: 56 additions & 1 deletion cli/sync_timeline.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package cli

import (
"context"
"errors"
"fmt"
"strings"
"time"

"golang.org/x/xerrors"

Expand All @@ -13,6 +16,8 @@ import (
)

func (*RootCmd) syncTimeline(socketPath *string) *serpent.Command {
var watch bool

formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TextFormat(),
Expand All @@ -29,10 +34,14 @@ func (*RootCmd) syncTimeline(socketPath *string) *serpent.Command {
cmd := &serpent.Command{
Use: "timeline",
Short: "Show a timeline of unit state changes",
Long: "Show every recorded unit state transition and dependency declaration across all units, in the order they occurred. The default output is an ASCII graph of the dependency DAG in event-time order; use --output json for the raw event list.",
Long: "Show every recorded unit state transition and dependency declaration across all units, in the order they occurred. The default output is an ASCII graph of the dependency DAG in event-time order; use --output json for the raw event list. With --watch, new rows are streamed as events occur until interrupted.",
Handler: func(i *serpent.Invocation) error {
ctx := i.Context()

if watch && formatter.FormatID() != "text" {
return xerrors.New("--watch only supports text output")
}

opts := []agentsocket.Option{}
if *socketPath != "" {
opts = append(opts, agentsocket.WithPath(*socketPath))
Expand All @@ -53,6 +62,10 @@ func (*RootCmd) syncTimeline(socketPath *string) *serpent.Command {
return xerrors.Errorf("get timeline failed: %w", err)
}

if watch {
return watchTimeline(ctx, i, client, events)
}

out, err := formatter.Format(ctx, events)
if err != nil {
return xerrors.Errorf("format timeline: %w", err)
Expand All @@ -64,10 +77,52 @@ func (*RootCmd) syncTimeline(socketPath *string) *serpent.Command {
},
}

cmd.Options = serpent.OptionSet{
{
Flag: "watch",
FlagShorthand: "w",
Description: "Stream new rows as events occur until interrupted.",
Value: serpent.BoolOf(&watch),
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}

// syncWatchPollInterval is how often --watch polls the agent for new
// events.
const syncWatchPollInterval = time.Second

// watchTimeline renders the events seen so far, then polls the agent and
// streams newly produced graph rows until the context is canceled.
func watchTimeline(ctx context.Context, i *serpent.Invocation, client *agentsocket.Client, events []agentsocket.UnitEvent) error {
renderer := newTimelineRenderer()
_, _ = fmt.Fprint(i.Stdout, renderer.renderEvents(unitEventsFromSocket(events)))

ticker := time.NewTicker(syncWatchPollInterval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
// Interrupting a watch is the normal way to end it.
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return ctx.Err()
case <-ticker.C:
events, err := client.SyncTimeline(ctx)
if err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return xerrors.Errorf("get timeline failed: %w", err)
}
_, _ = fmt.Fprint(i.Stdout, renderer.renderEvents(unitEventsFromSocket(events)))
}
}
}

// unitEventsFromSocket converts socket client event wrappers back to
// unit.Event values for rendering and derivation.
func unitEventsFromSocket(events []agentsocket.UnitEvent) []unit.Event {
Expand Down
29 changes: 29 additions & 0 deletions cli/sync_timeline_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"strings"
"testing"
"time"

Expand Down Expand Up @@ -61,6 +62,34 @@ func TestRenderTimeline(t *testing.T) {
* [+8.0s] dev-server started → completed`, renderTimeline(events))
})

t.Run("IncrementalMatchesOneShot", func(t *testing.T) {
t.Parallel()

events := []unit.Event{
{Seq: 1, Time: at(0), Kind: unit.EventStatusChange, Unit: "db-migrate", From: unit.StatusNotRegistered, To: unit.StatusPending},
{Seq: 2, Time: at(100 * time.Millisecond), Kind: unit.EventStatusChange, Unit: "db-migrate", From: unit.StatusPending, To: unit.StatusStarted},
{Seq: 3, Time: at(100 * time.Millisecond), Kind: unit.EventStatusChange, Unit: "dev-server", From: unit.StatusNotRegistered, To: unit.StatusPending},
{Seq: 4, Time: at(100 * time.Millisecond), Kind: unit.EventDependencyAdded, Unit: "dev-server", DependsOn: "db-migrate", RequiredStatus: unit.StatusComplete},
{Seq: 5, Time: at(2300 * time.Millisecond), Kind: unit.EventStatusChange, Unit: "db-migrate", From: unit.StatusStarted, To: unit.StatusComplete},
{Seq: 6, Time: at(2400 * time.Millisecond), Kind: unit.EventStatusChange, Unit: "dev-server", From: unit.StatusPending, To: unit.StatusStarted},
{Seq: 7, Time: at(8 * time.Second), Kind: unit.EventStatusChange, Unit: "dev-server", From: unit.StatusStarted, To: unit.StatusComplete},
}

oneShot := newTimelineRenderer().renderEvents(events)

// Feeding the log in overlapping chunks, as --watch does when it
// repeatedly fetches the full event list, must produce identical
// output: already-seen events are skipped by sequence number.
incremental := newTimelineRenderer()
var got strings.Builder
got.WriteString(incremental.renderEvents(events[:2]))
got.WriteString(incremental.renderEvents(events[:2])) // no new events
got.WriteString(incremental.renderEvents(events[:5])) // overlap + connector boundary
got.WriteString(incremental.renderEvents(events))

require.Equal(t, oneShot, got.String())
})

t.Run("ReadyFlipNotDrawnWhenStillBlocked", func(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading