Row activity: developer guide¶
Show a spinner on the rows of a pkg/table, pkg/list or pkg/tree that
are being worked on, whether the work was started by the user or by something
else. The examples use a table; list and tree differ only in
the row type and where the spinner draws.
Name │ Sync │ Health
──────────────────────────────────────────────────
▸ payments-metrics-00 │ ⣾ Running │ Healthy
search-web-01 │ OutOfSync │ Degraded
media-web-02 │ ⣽ Refreshing │ Progressing
media-gateway-03 │ Synced │ Healthy
How it works¶
- The server is the source of truth. A row spins when the data you fetched says work is running on it, and stops when the data says the work is over.
- Work the user starts shows immediately. Between the keypress and the next poll, the data can't know yet. The table covers that gap with a claim: the row spins at once, and the claim ends when the truth can take over.
Everything below is one of those two, plus the plumbing that keeps them honest on a real network.
Step 1: show work the server reports¶
This is all a read-only dashboard needs.
type argoApp struct {
ID, Name, Sync, Health, Phase string
Rev int64
}
o := th.Table()
o.Columns = []table.Column{
{Title: "Name", Flex: 1},
{Title: "Sync", Width: 14}, // wide enough for "⣾ Refreshing"
{Title: "Health", Width: 12},
}
o.ActivityColumn = "Sync" // where the spinner draws
o.BusyWhen = func(r table.KeyedRow) (string, bool) {
phase := r.Data.(argoApp).Phase
return phase, phase == "Running"
}
s.table = table.New(o)
Fetch with a token taken when the request is issued, and apply the reply with
ApplyRead:
type fetchedMsg struct {
read activity.Read
apps []argoApp
err error
}
func (s *Screen) fetch() tea.Cmd {
rd := s.table.BeginRead()
return func() tea.Msg {
apps, err := s.client.ListApps() // check the HTTP status in here
return fetchedMsg{rd, apps, err}
}
}
case fetchedMsg:
rows := make([]table.KeyedRow, len(m.apps))
for i, a := range m.apps {
rows[i] = table.KeyedRow{Key: a.ID, Cells: []string{a.Name, a.Sync, a.Health}, Data: a}
}
wasFailing := s.table.ReadsFailing()
if s.table.ApplyRead(m.read, rows, m.err) {
s.poll.MarkRefreshed() // only a read that landed counts
} else if m.err != nil && !wasFailing {
return s, app.ErrorOf(m.err) // once, when reads start failing
}
Drive fetch from a pkg/poll tick (rule 24). Report an outage once, when
reads start failing, and let ReadsFailing() carry it on the title after that.
Posting the error on every failed poll puts a fresh error on the statusbar
every interval for as long as the server is down.
Choosing the field for BusyWhen. It has to be a field that says
"working" while the work runs. It doesn't have to be a column: it's read from
Data.
| API | Busy while | Not this |
|---|---|---|
| Argo CD | status.operationState.phase == Running |
sync.status: that's a comparison, and it never says Syncing |
| Kubernetes Deployment | observedGeneration < generation, or a rollout still in progress |
status.conditions alone |
| A jobs API | status in (queued, running) |
the resource the job acts on |
Busy-ness from another endpoint (GET /jobs?status=running) |
merge it into Data before you push rows |
(there is no second entrance) |
BusyWhen returns the status for every row, busy or not. The table shows it
as the label while the row is busy, and compares it to notice when the server
acted. activity.Busy("running", "pending") and
activity.Settled("succeeded", "failed") build the common predicates.
Step 2: add verbs the user can run¶
For each verb, answer one question: who knows when the work ends?
| The work ends when… | Mode | Example |
|---|---|---|
| the server's status says so; the request only acknowledges | activity.Observed |
Argo sync POST → 202 at once |
| the request answers | activity.Held |
Argo refresh GET that holds the connection |
| a job id the request returned says so | activity.Held |
POST → {job}, then poll GET /jobs/{id} |
Every verb has the same shape: Dispatch when the user picks it, and Done
when the request answers.
Observed: a POST that answers at once¶
func (s *Screen) Actions() action.Set {
keys := s.table.Selection()
return action.Set{Targets: keys, Actions: []action.Action{
// Multi: without it the menu disables the verb once a second row is
// marked (action.Set.Arity is len(Targets)).
{Label: "Sync", Multi: true, Do: func() tea.Cmd { return s.sync(keys) }},
}}
}
type answeredMsg struct {
op activity.Op
err error
}
func (s *Screen) sync(keys []string) tea.Cmd {
// One operation per row: DispatchEach calls this once per key.
return s.table.DispatchEach(keys, "Running", activity.Observed,
func(op activity.Op, k string) tea.Cmd {
return func() tea.Msg { return answeredMsg{op, s.client.Sync(k)} } // 202 = nil error
})
}
case answeredMsg:
switch s.table.Done(m.op, m.err) {
case activity.Withdrawn: // refused: this row stops
return s, app.ErrorOf(m.err)
case activity.Acknowledged: // accepted: still running, polls take over
return s, app.Info("Sync requested")
}
case activity.UnobservedMsg:
return s, app.Info("Sync finished between polls")
Done tells you what the reply means. It returns activity.Acknowledged
for an Observed verb (the work has only started, so say "requested"),
activity.Ended for a Held one (the work is over), and activity.Withdrawn
when the request failed. Switch on it. A reply handler shared by a sync and a
refresh that says "completed" on every reply is wrong for the sync, and nothing
else will tell you.
One operation per row. DispatchEach gives each row its own operation
and its own request, so each gets its own Done. If one row's request is
refused, only that row stops. One operation over the whole selection puts every
row behind the first refusal; use Dispatch with several keys only when a
single request acts on all of them.
A Do verb gets no receipt from the shell (Action.Receipt applies only to
Run verbs), so post "requested" yourself, as above.
UnobservedMsg arrives through the table's Update, on the turn after the read
that ended the claim. Forward every message to the table (rule 6) and match it
in your Update. Its Label is the claim's label ("Running"), not the
verb, so name the verb yourself: m.Label + " finished" reads "Running
finished".
Claim with the server's own word ("Running"), so the row reads the same from
the keypress to the poll that confirms it.
Held: a request that blocks until the work is done¶
func (s *Screen) refresh(keys []string) tea.Cmd {
return s.table.DispatchEach(keys, "Refreshing", activity.Held,
func(op activity.Op, k string) tea.Cmd {
return func() tea.Msg { return refreshedMsg{op, s.client.Refresh(k)} } // blocks ~2s
})
}
case refreshedMsg:
switch s.table.Done(m.op, m.err) { // the spinner ends here, not at a poll
case activity.Withdrawn:
return s, app.ErrorOf(m.err)
case activity.Ended:
return s, s.poll.Refresh() // show what the refresh found now
}
Held: a job handle¶
The same as a blocking request. The command that follows the job only returns once the job has finished:
return tea.Batch(spin, func() tea.Msg {
id, err := s.client.StartSync(key)
if err == nil {
err = s.client.WaitJob(id) // poll GET /jobs/{id} until terminal
}
return jobDoneMsg{op, err}
})
The title during an outage¶
ReadsFailing() and poll.LastRefresh() answer different questions, so show
both. "reads failing" says the rows can't be refreshed; "refreshed Ns ago" says
how stale they are. The count comes from the last read that landed, so it keeps
climbing through an outage, which is when the user needs it most.
func (s *Screen) titleText() string {
title := "applications"
if s.table.ReadsFailing() {
title += " · reads failing"
}
if last := s.poll.LastRefresh(); !last.IsZero() {
title += fmt.Sprintf(" · refreshed %ds ago", int(time.Since(last).Seconds()))
}
return title
}
Call SetTitle(s.titleText()) after every read and on a one-second tea.Tick,
so N moves between polls.
Theme swaps¶
SetTheme rebuilds the table (rule 4), so carry the rows and the activity
across it:
func (s *Screen) SetTheme(t theme.Theme) {
rows, act := s.table.KeyedRows(), s.table.ActivityState()
o := t.Table()
// … the same options as before …
s.table = table.New(o)
s.table.SetKeyedRows(rows) // rows first
_ = s.table.SetActivityState(act) // then the spinners that belong on them
}
SetActivityState returns the spinner's first tick, and SetTheme has nowhere
to return it. Dropping it is safe: the table re-arms a stalled spinner on the
next message it receives.
What the user sees¶
| Situation | Row shows |
|---|---|
| The server reports the row working | ⣾ Running (the server's word), animated |
| The user just picked a verb, no read yet | ⣾ Running / ⣾ Refreshing (your label), animated |
| A read confirms the work | the server's label; your claim steps aside silently |
| The work ends | the cell's own value again (Synced, OutOfSync, …) |
| Work finished before any poll saw it | the row stops, and you get UnobservedMsg |
| Reads are failing | rows busy on the last good read show a static ? Running in the muted colour; open requests keep spinning |
The request was refused (Done with an error) |
the row stops immediately |
The table draws no outcome glyph (✓ / ✗). When the spinner stops, the cell
shows the row's own value, which already says Failed in your app's colours.
Settings¶
| Option | Default | Set it when |
|---|---|---|
ActivityColumn |
none (a 2-cell gutter) | always; name the column the spinner replaces |
Column.Width on that column |
content-auto | always; the indicator never widens a column |
BusyWhen |
none | always |
Revision |
none | your API has a value that moves when an operation finishes (Argo operationState.finishedAt). Without it, a sync that starts and ends at the same status (Succeeded → Succeeded) waits out Settle and reports Changed: false. |
Activity.Settle |
0 | the server reports new work after a lag (a reconciler such as a real Argo controller): 1–2. Leave it at 0 when the server reports the work inline, as demoapi does; the examples leave it at 0. It counts reads that repeat the pre-dispatch status before the claim gives up. It's a number of reads, not a duration. A verb facing a different server than the rest overrides it for its own claims: DispatchWith / DispatchEachWith with activity.DispatchOptions{Settle: &n}. |
Messages and methods¶
table.Dispatch(keys, label, mode) (activity.Op, tea.Cmd) |
start a claim; batch the returned command (it's the spinner's first tick) |
table.DispatchEach(keys, label, mode, request) tea.Cmd |
one operation per key; request(op, key) returns that key's command |
table.Done(op, err) activity.Outcome |
the request answered; returns Acknowledged (Observed: still running), Ended (Held: over) or Withdrawn (failed) |
table.BeginRead() activity.Read |
stamp a fetch when it is issued |
table.ApplyRead(rd, rows, err) bool |
apply a reply; false means it was dropped (stale or failed) |
table.SetKeyedRows(rows) |
apply a stream event (watch/SSE); no token needed |
table.ReadsFailing() bool |
the newest read failed; put it on the title |
table.ActivityCount() int |
how many rows are working; for the title |
table.KeyedRows() |
the rows as last applied, Data included; for a SetTheme rebuild (list: KeyedItems(), tree: Root()) |
table.ActivityState() / SetActivityState(s) |
carry state across SetTheme (rule 4) |
activity.UnobservedMsg{Op, Keys, Label, Changed} |
an Observed verb ended without any read seeing it busy |
What the table does for you¶
You don't need to write any of this:
- Out-of-order replies. A reply overtaken by a newer one is dropped.
- Reads that race your own write. A poll issued before your POST answered is applied, but it can't end that POST's spinner. That's where the "row blinks off and back on" bug came from.
- Outages. A failed read ends claims that were waiting on a read, and marks observed work Unknown instead of leaving it animating.
- Refused requests.
Donewith an error stops the spinner at once.
What it can't do¶
- See work somebody else started and finished between two polls. With
Revisionset, the row's revision moves, but the table only reports that for operations you dispatched. - Repair a read path that goes backwards. A replica or cache that answers busy, then settled, then busy again is telling the truth each time; no client-side ordering fixes that.
- Windowed tables. Under
SetWindowrows have no keys, and activity is inert.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
| No row ever spins | BusyWhen reads a field that is never busy while work runs (Argo sync.status), or rows were pushed with SetRows instead of keyed rows |
| The row spins, blinks off, then on again | the fetch skipped BeginRead / ApplyRead and used SetKeyedRows |
| Refresh stops spinning after the first poll | it was dispatched Observed; a blocking request is Held |
| Sync stops the moment the 202 lands | it was dispatched Held; a POST that hands work off is Observed |
| A quick sync looks ignored | handle activity.UnobservedMsg; set Revision if the status ends where it began |
The spinner shows ⣾ with no word |
the activity column is too narrow for the label; give it a Width |
| The table blanks during an outage | the fetch decoded an error body as an empty list; check the HTTP status and return an error |
| The statusbar says "completed" while the row still spins | the reply handler ignores what Done returns; switch on it (Acknowledged is not finished) |
| One refused row stops every marked row's spinner | the selection was dispatched as one operation; dispatch one per row |
| An outage posts an error every poll | post only when ReadsFailing() goes from false to true |
| The menu says "one item at a time" with rows marked | the verb lacks Multi: true; a set with several Targets is a multi-selection |
| "refreshed Ns ago" vanishes during an outage | the title shows "reads failing" instead of the freshness count; show both |
| "Running finished between polls" | UnobservedMsg.Label is the claim's label; name the verb yourself |
List and tree¶
The same methods, with the component's own row type:
pkg/table |
pkg/list |
pkg/tree |
|
|---|---|---|---|
| Row type | table.KeyedRow{Key, Cells, Data} |
list.KeyedItem{Key, Display, Data} |
your tree.Node |
| Key | Key |
Key |
the node's path ("cluster/web") |
BusyWhen reads |
KeyedRow |
KeyedItem |
tree.Node |
| Apply a read | ApplyRead(rd, rows, err) |
ApplyRead(rd, items, err) |
ApplyRead(rd, root, err) |
| Apply a stream event | SetKeyedRows |
SetKeyedItems |
SetRoot |
| Where the spinner draws | the ActivityColumn cell |
a badge at the row's right edge | a badge at the row's right edge |
o := th.List()
o.BusyWhen = func(it list.KeyedItem) (string, bool) {
phase := it.Data.(argoApp).Phase
return phase, phase == "Running"
}
o := th.Tree()
o.BusyWhen = func(n tree.Node) (string, bool) {
app, ok := n.(appNode) // your Node carries what the server reported
return app.phase, ok && app.phase == "Running"
}
A tree evaluates every node, collapsed ones included, so expanding a branch reveals a spinner that is already turning.
Migrating from the first API¶
The first version of this feature had the screen keep the read ordering itself
(generation counters, a count of outstanding writes, RetractAll on a failed
read) and offered two entrances (ActivityWhen or SetBusy). All of that is
gone. Screens written against it will not compile until they move over. Most
moves are mechanical.
Call by call¶
| Before | Now | Notes |
|---|---|---|
Options.ActivityWhen func(Row) (label, busy) (table) |
Options.BusyWhen func(KeyedRow) (status, busy) |
Reads the whole keyed row, Data included, so the field that means "working" needn't be a column. Return the status for every row, not only busy ones. |
Options.ActivityWhen func(string) (label, busy) (list) |
Options.BusyWhen func(KeyedItem) (status, busy) |
KeyedItem gained Data. |
Options.ActivityWhen func(Node) (label, busy) (tree) |
Options.BusyWhen func(Node) (status, busy) |
Same argument; the status now also ends claims when it changes. |
SetBusy(map[key]label) |
put the busy-ness in each row's Data and read it in BusyWhen |
One entrance. The screen keeps the map (e.g. from GET /jobs?status=running) and folds it into the rows it pushes. |
Expect(keys, label) |
DispatchEach(keys, label, mode, request), or Dispatch(keys, label, mode) |
Choose activity.Observed (the server's status says when the work ends) or activity.Held (the request, or a job it returned, does). One operation per row unless a single request acts on all the keys. |
Retract(keys...) after a refused write |
Done(op, err) with the error |
Returns activity.Withdrawn. |
| a successful write's reply (nothing to call before) | Done(op, nil) |
Required now. Without it an Observed claim is never acknowledged and a Held one never ends. It returns Acknowledged (Observed: accepted, still running) or Ended (Held: over), so switch on it when writing the reply's message. |
RetractAll() after a failed read |
ApplyRead(rd, nil, err) |
Withdraws the claims waiting on a read and marks observed work Unknown. |
s.gen++ / if m.gen < s.seen \|\| s.writing > 0 { drop } / SetKeyedRows(rows) |
rd := BeginRead() when the fetch is issued, ApplyRead(rd, rows, err) when it lands |
Delete the gen, seen and writing fields and the claimed map. A watch/SSE stream still uses plain SetKeyedRows. |
Options.Activity.Settle compared the ActivityColumn cell |
Settle compares BusyWhen's status (plus Revision) |
Behaviour only. Set Options.Revision if a quick operation can end at the status it started from. |
activity.Busy / Settled returned "" for a non-busy value |
they return the value either way | Only matters if you compared the returned label to "". |
action.Set{Targets: keys} with verbs lacking Multi |
add Multi: true to verbs that act on several rows |
The menu now counts len(Targets) (Set.Arity). A set with several Targets used to read as one target, so non-Multi verbs ran on every marked row. They are now disabled with "one item at a time". |
action.Set.Count |
optional when Targets is set |
Validate now flags a Count that disagrees with Targets, not a missing one. |
activity.Set.Derive / Observe / Expect / Retract / RetractAll / Scope / Expecting |
removed | Only code that drove a Set directly is affected; screens go through their component. Set.Dispatch no longer takes an at map. |
New, with nothing to replace: Options.Revision, ReadsFailing(),
activity.UnobservedMsg, KeyedRows() / KeyedItems() / Root(), and the
Unknown state rows take on while reads fail.
Before and after¶
A polled table with one Sync verb. Before:
o.ActivityColumn = "Sync"
settled := activity.Settled("Synced", "OutOfSync")
o.ActivityWhen = func(c table.Row) (string, bool) { return settled(c[colSync]) }
case action.ChosenMsg:
s.gen++; s.writing++
s.claimed[action.RunKey(m.Action, m.Target)] = m.Targets
cmds = append(cmds, s.table.Expect(m.Targets, "Syncing"))
case runner.Captured:
s.writing--; s.gen++
if m.Err != nil { s.table.Retract(s.claimed[m.Tag]...) }
case fetchedMsg:
if m.gen < s.seen || s.writing > 0 { break }
s.seen = m.gen
if m.err != nil { s.table.RetractAll(); break }
s.table.SetKeyedRows(rowsOf(m.apps))
After:
o.ActivityColumn = "Sync"
o.BusyWhen = func(r table.KeyedRow) (string, bool) {
phase := r.Data.(argoApp).Phase // what is busy while work runs
return phase, phase == "Running"
}
o.Revision = func(r table.KeyedRow) string { return r.Data.(argoApp).FinishedAt }
// the verb: {Label: "Sync", Multi: true, Do: func() tea.Cmd { return s.sync(keys) }}
func (s *Screen) sync(keys []string) tea.Cmd {
return s.table.DispatchEach(keys, "Running", activity.Observed,
func(op activity.Op, k string) tea.Cmd {
return func() tea.Msg { return answeredMsg{op, s.client.Sync(k)} }
})
}
case answeredMsg:
switch s.table.Done(m.op, m.err) {
case activity.Withdrawn: return s, app.ErrorOf(m.err)
case activity.Acknowledged: return s, app.Info("Sync requested")
}
case activity.UnobservedMsg:
return s, app.Info("Sync finished between polls")
case fetchedMsg:
wasFailing := s.table.ReadsFailing()
if s.table.ApplyRead(m.read, rowsOf(m.apps), m.err) {
s.poll.MarkRefreshed()
} else if m.err != nil && !wasFailing {
return s, app.ErrorOf(m.err)
}
Check the predicate, not just the calls. If the old ActivityWhen watched
a status that never says "working" while the work runs, such as Argo's
sync.status, a mechanical port keeps the bug: no row will ever spin. Point
BusyWhen at the field that is busy during the work
(status.operationState.phase for Argo). See Step 1.
Examples¶
examples/patterns/activityrecipes: one shape per tab (observe, sync, refresh, job handle), each small enough to copy.examples/patterns/activity: all of them on one screen, with marking and the output console.