okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: replace the generic /quota page with a screen of latest limits, bars and per-limit trails8e3e173

main
5.9 KiB215 linesraw
1package tui
2
3import (
4	"context"
5	"errors"
6	"slices"
7	"strings"
8	"time"
9
10	"albedo/cli/internal/daemon"
11
12	tea "charm.land/bubbletea/v2"
13)
14
15type QuotaCancelMsg struct{}
16
17type quotaLoadedMsg struct {
18	Err      error
19	Readings []daemon.QuotaObservation
20	At       time.Time
21	Gen      int
22}
23
24// quotaHistoryMsg is the readings of one limit over the span the pane draws,
25// oldest first.
26type quotaHistoryMsg struct {
27	Err    error
28	Key    string
29	Points []quotaPoint
30	Gen    int
31}
32
33// quotaTrail is what the pane knows of a limit's past: the readings, that they
34// could not be had, or that they were asked for and have not come.
35type quotaTrail struct {
36	points  []quotaPoint
37	failed  bool
38	pending bool
39}
40
41// QuotaModel shows the latest reading of every limit of every account the
42// daemon polls, with the highlighted limit's recent past beside it.
43type QuotaModel struct {
44	Conn     *daemon.Connection
45	readings []daemon.QuotaObservation
46	trails   map[string]quotaTrail
47	at       time.Time // when the readings were fetched; every age and countdown is against it
48	pageStatus
49	listView
50}
51
52func NewQuotaModel(conn *daemon.Connection) QuotaModel {
53	return QuotaModel{
54		Conn:       conn,
55		trails:     map[string]quotaTrail{},
56		pageStatus: newPageStatus(true),
57		listView:   newListView("Search accounts and limits"),
58	}
59}
60
61func (m *QuotaModel) SetSize(width, height int) {
62	m.pageStatus.SetSize(width, height)
63	m.listView.setSize(width, height)
64}
65
66func (m QuotaModel) Init() tea.Cmd { return m.loadCmd(m.Generation) }
67
68func (m QuotaModel) load() (QuotaModel, tea.Cmd) {
69	m.Loading, m.Error = true, ""
70	m.Generation = nextPageGeneration()
71	return m, m.loadCmd(m.Generation)
72}
73
74func (m QuotaModel) loadCmd(gen int) tea.Cmd {
75	return func() tea.Msg {
76		if m.Conn == nil {
77			return quotaLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen}
78		}
79		readings, err := daemon.GetLatestQuota(context.Background(), m.Conn)
80		return quotaLoadedMsg{Readings: readings, Err: err, At: time.Now(), Gen: gen}
81	}
82}
83
84// fetchTrail asks for the trail of the limit under the cursor, once: a limit
85// with nothing to draw (it failed, or reported no percent) has no past to ask
86// for, and one already asked for waits for its answer.
87func (m *QuotaModel) fetchTrail() tea.Cmd {
88	r, ok := m.current()
89	if !ok || m.Loading || r.Error != nil || r.UsedPercent == nil {
90		return nil
91	}
92	key := quotaKey(r)
93	if _, asked := m.trails[key]; asked {
94		return nil
95	}
96	m.trails[key] = quotaTrail{pending: true}
97	return m.trailCmd(r, m.Generation)
98}
99
100// trailSince is how far back a limit's trail is read: as far as the pane draws.
101func (m QuotaModel) trailSince(r daemon.QuotaObservation) time.Time {
102	return m.at.Add(-quotaTrailSpan(r))
103}
104
105func (m QuotaModel) trailCmd(r daemon.QuotaObservation, gen int) tea.Cmd {
106	key, since := quotaKey(r), m.trailSince(r)
107	return func() tea.Msg {
108		if m.Conn == nil {
109			return quotaHistoryMsg{Err: errors.New("daemon connection unavailable"), Key: key, Gen: gen}
110		}
111		readings, err := daemon.GetQuotaTrail(context.Background(), m.Conn, daemon.QuotaTrailQuery{
112			Provider: r.Provider, Account: r.AccountID, Limit: r.LimitID, Since: since,
113		})
114		return quotaHistoryMsg{Points: quotaPoints(readings), Err: err, Key: key, Gen: gen}
115	}
116}
117
118// quotaPoints are the readings that carry a percent and a time, in the order
119// they came.
120func quotaPoints(readings []daemon.QuotaObservation) []quotaPoint {
121	points := make([]quotaPoint, 0, len(readings))
122	for _, r := range readings {
123		if at, ok := parseStamp(r.ObservedAt); ok && r.UsedPercent != nil {
124			points = append(points, quotaPoint{At: at, Percent: *r.UsedPercent})
125		}
126	}
127	return points
128}
129
130// current is the reading under the cursor.
131func (m QuotaModel) current() (daemon.QuotaObservation, bool) {
132	row, ok := m.highlighted()
133	if !ok {
134		return daemon.QuotaObservation{}, false
135	}
136	i := slices.IndexFunc(m.readings, func(r daemon.QuotaObservation) bool { return quotaKey(r) == row.key })
137	if i < 0 {
138		return daemon.QuotaObservation{}, false
139	}
140	return m.readings[i], true
141}
142
143// setReadings groups the limits by provider and account, which the daemon
144// does not: it orders by account alone.
145func (m *QuotaModel) setReadings(readings []daemon.QuotaObservation, at time.Time) {
146	m.readings = slices.Clone(readings)
147	slices.SortStableFunc(m.readings, func(a, b daemon.QuotaObservation) int {
148		return strings.Compare(quotaSection(a), quotaSection(b))
149	})
150	m.at = at
151	m.rebuild()
152}
153
154func (m *QuotaModel) rebuild() {
155	rows := make([]listEntry, len(m.readings))
156	for i, r := range m.readings {
157		rows[i] = m.entry(r)
158	}
159	m.setRows(rows)
160}
161
162func (m QuotaModel) entry(r daemon.QuotaObservation) listEntry {
163	return listEntry{
164		key:     quotaKey(r),
165		section: quotaSection(r),
166		name:    quotaName(r),
167		desc:    quotaDesc(r),
168		tag:     quotaTag(r, m.at),
169		search:  []string{r.Provider, r.AccountID, r.LimitID},
170		detail:  func(width int) []string { return m.details(r, width) },
171	}
172}
173
174// Update asks for the highlighted limit's trail after whatever else the
175// message did, so moving the cursor onto a limit is what loads its past.
176func (m QuotaModel) Update(msg tea.Msg) (QuotaModel, tea.Cmd) {
177	if key, ok := msg.(tea.KeyPressMsg); ok && (key.String() == "esc" || key.String() == "ctrl+c") {
178		return m, func() tea.Msg { return QuotaCancelMsg{} }
179	}
180	m, cmd := m.update(msg)
181	if trail := m.fetchTrail(); trail != nil {
182		cmd = tea.Batch(cmd, trail)
183	}
184	return m, cmd
185}
186
187func (m QuotaModel) update(msg tea.Msg) (QuotaModel, tea.Cmd) {
188	switch msg := msg.(type) {
189	case quotaLoadedMsg:
190		if !m.settle(msg.Gen, msg.Err, &m.Loading) {
191			return m, nil
192		}
193		m.trails = map[string]quotaTrail{}
194		m.setReadings(msg.Readings, msg.At)
195		return m, nil
196
197	case quotaHistoryMsg:
198		if msg.Gen != m.Generation {
199			return m, nil
200		}
201		m.trails[msg.Key] = quotaTrail{points: msg.Points, failed: msg.Err != nil}
202		m.rebuild()
203		return m, nil
204
205	case tea.KeyPressMsg:
206		if msg.String() == "ctrl+r" {
207			if !m.Loading {
208				return m.load()
209			}
210			return m, nil
211		}
212	}
213	cmd := m.listView.update(msg)
214	return m, cmd
215}