DEV Community

r0tshk6dim
r0tshk6dim

Posted on

Keeping a Bubble Tea Dashboard in Sync with soroi

An incident console gets more demanding once it has several panes. The queue shows open incidents, a detail pane follows the selected row, and a summary groups incidents by severity. A responder acknowledges an incident, and the new owner needs to appear everywhere that displays it.

GitHub: Waterkyuu/soroi

Bubble Tea gives the screen a clear message loop. The application still has to decide who loads the queue, which results can be reused, and what each pane should show while another read is running.

soroi is a typed query client for Go. It fits this part of a terminal application: several components reading the same remote data, with writes that make the cached view stale. The incident service still owns the actual HTTP requests and acknowledgement rules.

A queue that several panes can read

Suppose each pane is a separate component with its own subscription lifetime. They all need the open queue for the same team, so they can use the same client, key, and loader:

client := soroi.NewClient(soroi.Options{
    StaleTime: 5 * time.Second,
    Timeout:   5 * time.Second,
})

key := fmt.Sprintf("team:%s:incidents:open", teamID)
load := func(ctx context.Context) ([]Incident, error) {
    return api.ListOpen(ctx, teamID)
}

panels := map[string]*soroi.QueryHandle[[]Incident]{
    "list":    soroi.Query(client, key, load),
    "detail":  soroi.Query(client, key, load),
    "summary": soroi.Query(client, key, load),
}
Enter fullscreen mode Exit fullscreen mode

Within this client, overlapping loads for that key share the same work. A pane that subscribes while the result is still fresh can reuse it. The detail pane can select an incident from this dataset without making another request for fields the queue already contains.

The team ID belongs in the key. A different team, permission scope, or server-side filter needs a distinct key when it changes the result. Also, keep the cached slice read-only; an acknowledgement should go through the service.

When every pane always lives and closes with the parent screen, a single parent subscription can be simpler. Independent handles are useful when the components have their own lifetimes.

Let Bubble Tea receive the changes

A query subscription needs a small bridge into Bubble Tea. Each command waits for one notification and returns a message. It never changes the model from a background goroutine.

// incidentsMsg carries one pane's query state onto the UI loop.
type incidentsMsg struct {
    // panel identifies the component receiving this snapshot.
    panel string
    // state contains read-only data and the current loading result.
    state soroi.Snapshot[[]Incident]
}

// waitForIncidents delivers one notification to Bubble Tea.
func waitForIncidents(panel string, query *soroi.QueryHandle[[]Incident]) tea.Cmd {
    return func() tea.Msg {
        state, ok := <-query.Updates()
        if !ok {
            return nil
        }
        return incidentsMsg{panel: panel, state: state}
    }
}
Enter fullscreen mode Exit fullscreen mode

The screen starts a receive for each mounted pane:

// Init starts the mounted panes' subscription commands.
func (m incidentBoard) Init() tea.Cmd {
    return tea.Batch(
        waitForIncidents("list", m.panels["list"]),
        waitForIncidents("detail", m.panels["detail"]),
        waitForIncidents("summary", m.panels["summary"]),
    )
}
Enter fullscreen mode Exit fullscreen mode

When Update receives a snapshot, it stores it and returns the next receive command for that pane:

case incidentsMsg:
    m.states[msg.panel] = msg.state
    if msg.panel == "list" && m.selectedID == "" && len(msg.state.Data) > 0 {
        m.selectedID = msg.state.Data[0].ID
    }
    return m, waitForIncidents(msg.panel, m.panels[msg.panel])
Enter fullscreen mode Exit fullscreen mode

That last return keeps the subscription going after the initial load. The queue and detail can briefly show different phases because their messages are processed separately. These notifications carry the latest query state; intermediate states can be skipped. Incident history belongs in the service's audit trail.

Keep the queue visible when a refresh fails

During an incident, a failed refresh should leave the responder with the last known queue and a visible error. HasData, Fetching, and Err let the screen express that directly:

// renderIncidents preserves the queue while reporting refresh failures.
func renderIncidents(state soroi.Snapshot[[]Incident]) string {
    var out strings.Builder
    switch {
    case !state.HasData && state.Err != nil:
        fmt.Fprintf(&out, "Could not load incidents: %v\n", state.Err)
    case !state.HasData:
        out.WriteString("Loading incidents...\n")
    case state.Err != nil:
        fmt.Fprintf(&out, "Refresh failed; showing previous incidents: %v\n", state.Err)
    case state.Fetching:
        out.WriteString("Refreshing incidents...\n")
    }
    for _, incident := range state.Data {
        fmt.Fprintf(&out, "%s  %s  %s  %s\n",
            incident.ID, incident.Severity, incident.Service, incident.Owner)
    }
    return out.String()
}
Enter fullscreen mode Exit fullscreen mode

An initial failure has no queue to display. A later failure can retain the previous data. Keeping those cases separate avoids turning a temporary API outage into an empty incident list that looks like everything has been resolved.

Acknowledge an incident, then reload the queue

The acknowledgement is an HTTP write. It belongs in a tea.Cmd so that moving around the terminal stays responsive while the request runs.

// acknowledgedMsg reports the result of the acknowledgement request.
type acknowledgedMsg struct {
    // err is the service or transport error, if the write failed.
    err error
}

// acknowledge runs the business write outside the UI loop.
func acknowledge(parent context.Context, api *incidentAPI, id string) tea.Cmd {
    return func() tea.Msg {
        ctx, cancel := context.WithTimeout(parent, 3*time.Second)
        defer cancel()
        return acknowledgedMsg{err: api.Acknowledge(ctx, id)}
    }
}
Enter fullscreen mode Exit fullscreen mode

On completion, Update clears the pending state and invalidates the shared queue only if the write succeeded:

case acknowledgedMsg:
    m.saving = false
    m.actionErr = msg.err
    if msg.err == nil {
        m.actionErr = m.client.Invalidate(m.key)
    }
    return m, nil
Enter fullscreen mode Exit fullscreen mode

Invalidation marks the queue stale and starts a refresh for the active observers. The panes keep their previous incidents while that read runs, then receive the updated owner through their subscriptions.

The key handler prevents repeated acknowledgements while one is pending. Pressing r requests a refresh of the same queue:

case "a":
    if m.saving || m.selectedID == "" {
        return m, nil
    }
    m.saving = true
    m.actionErr = nil
    return m, acknowledge(m.ctx, m.api, m.selectedID)
case "r":
    m.actionErr = m.client.Invalidate(m.key)
    return m, nil
Enter fullscreen mode Exit fullscreen mode

The five-second freshness window doesn't poll the service. Changes made by other responders become visible after a later read or explicit invalidation. A live feed or polling schedule would still need to trigger that work. If the screen also loads a separate incident timeline, invalidate its key when the write affects it; soroi doesn't infer that relationship.

Keep selection and cleanup predictable

A refreshed queue can come back in a different order. The selected incident should be tracked by ID, so that a responder doesn't accidentally acknowledge a different row after a refresh. The detail pane resolves that ID against its own snapshot:

state := m.states["detail"]
if !state.HasData {
    return "Waiting for incident details..."
}
if m.selectedID == "" {
    return "Select an incident."
}
for _, incident := range state.Data {
    if incident.ID == m.selectedID {
        return fmt.Sprintf("%s: %s\nOwner: %s",
            incident.ID, incident.Service, incident.Owner)
    }
}
return "The selected incident is no longer in the open queue."
Enter fullscreen mode Exit fullscreen mode

The application also owns the client and the handles it creates. Release them on terminal exit and on startup failure:

// runBoard releases subscriptions and shared work when the terminal exits.
func runBoard(api *incidentAPI, teamID string) error {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    client := soroi.NewClient(soroi.Options{StaleTime: 5 * time.Second})
    defer client.Close()

    board := newBoard(ctx, api, client, teamID)
    for _, query := range board.panels {
        defer query.Close()
    }
    _, err := tea.NewProgram(board, tea.WithContext(ctx)).Run()
    return err
}
Enter fullscreen mode Exit fullscreen mode

Closing a handle releases its waiting receive command. Closing the shared client cancels its query work. Business commands use the program's context too, so an acknowledgement doesn't keep running indefinitely after the operator exits.

This pattern fits terminal tools whose components have independent lifetimes and keep reading the same remote data. A screen with one data consumer can stay with a regular tea.Cmd; there's little shared query work to coordinate.

Top comments (0)