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
13.0 KiB422 linesraw
1package tui
2
3import (
4	"fmt"
5	"math"
6	"strings"
7	"time"
8
9	"albedo/cli/internal/daemon"
10
11	"charm.land/lipgloss/v2"
12	"github.com/charmbracelet/x/ansi"
13)
14
15// A reading older than this was not polled on schedule: the daemon polls at
16// least every ten minutes, and a failing account backs off to an hour.
17const quotaStale = 30 * time.Minute
18
19const (
20	quotaBarSlots = 10
21	// Warm from here, red from quotaHot. The daemon polls busier from the same 80.
22	quotaWarm = 80.0
23	quotaHot  = 95.0
24	// The pane's trail shows at least a day, so a short window's resets show.
25	quotaTrailMin = 24 * time.Hour
26	quotaTrailMax = 7 * 24 * time.Hour
27)
28
29// quotaPoint is one reading of a limit's percent.
30type quotaPoint struct {
31	At      time.Time
32	Percent float64
33}
34
35type quotaStanding int
36
37const (
38	quotaFresh   quotaStanding = iota
39	quotaOld                   // polled long ago, or past its own reset
40	quotaFailed                // the provider reported an error
41	quotaUnknown               // answered without a percentage
42)
43
44func quotaKey(r daemon.QuotaObservation) string {
45	return r.Provider + "/" + r.AccountID + "/" + r.LimitID
46}
47
48func quotaSection(r daemon.QuotaObservation) string {
49	if r.AccountID == r.Provider {
50		return r.Provider
51	}
52	return r.Provider + " · " + r.AccountID
53}
54
55// quotaName is what a row is called. A poll that failed with no limits has no
56// label of its own: its section already names the account, so the row says
57// what happened.
58func quotaName(r daemon.QuotaObservation) string {
59	switch {
60	case r.Label != "":
61		return r.Label
62	case r.Error != nil:
63		return "last poll failed"
64	case r.LimitID != "":
65		return r.LimitID
66	}
67	return "no limits reported"
68}
69
70// quotaWindow is a limit's window as a noun phrase: the provider's labels read
71// "5h window" or "7 days", and the pane says "the <window> window".
72func quotaWindow(r daemon.QuotaObservation) string {
73	if r.WindowLabel == nil {
74		return ""
75	}
76	return strings.TrimSpace(strings.TrimSuffix(*r.WindowLabel, " window"))
77}
78
79// quotaDesc is a limit's window, unless its label already says it.
80func quotaDesc(r daemon.QuotaObservation) string {
81	if r.WindowLabel == nil || *r.WindowLabel == r.Label {
82		return ""
83	}
84	return *r.WindowLabel
85}
86
87func parseStamp(s string) (time.Time, bool) {
88	t, err := time.Parse(time.RFC3339, s)
89	return t, err == nil
90}
91
92func resetOf(r daemon.QuotaObservation) (time.Time, bool) {
93	if r.ResetsAt == nil {
94		return time.Time{}, false
95	}
96	return parseStamp(*r.ResetsAt)
97}
98
99func standingOf(r daemon.QuotaObservation, at time.Time) quotaStanding {
100	seen, _ := parseStamp(r.ObservedAt)
101	resets, hasReset := resetOf(r)
102	switch {
103	case r.Error != nil:
104		return quotaFailed
105	case r.UsedPercent == nil:
106		return quotaUnknown
107	case at.Sub(seen) > quotaStale || (hasReset && !resets.After(at)):
108		return quotaOld
109	}
110	return quotaFresh
111}
112
113// span writes a duration as 40m, 2h14m or 3d4h: two units at most.
114func quotaSpan(d time.Duration) string {
115	d = max(d, 0)
116	units := func(big, small time.Duration, bigName, smallName string) string {
117		out := fmt.Sprintf("%d%s", d/big, bigName)
118		if rest := (d % big) / small; rest > 0 {
119			out += fmt.Sprintf("%d%s", rest, smallName)
120		}
121		return out
122	}
123	switch {
124	case d < time.Minute:
125		return "<1m"
126	case d < time.Hour:
127		return fmt.Sprintf("%dm", d/time.Minute)
128	case d < 24*time.Hour:
129		return units(time.Hour, time.Minute, "h", "m")
130	}
131	return units(24*time.Hour, time.Hour, "d", "h")
132}
133
134// quotaInk is the color a percent is drawn in: the brand ramp until it runs
135// warm, then yellow, then red.
136func quotaInk(percent, t float64) lipgloss.Style {
137	switch {
138	case percent >= quotaHot:
139		return DefaultStyles.Error
140	case percent >= quotaWarm:
141		return DefaultStyles.Warning
142	}
143	return brandInk(t)
144}
145
146// quotaBar fills slots toward percent. A reading that is not current keeps
147// its fill in the faintest ink, so it cannot pass for one.
148func quotaBar(percent float64, current bool) string {
149	filled := int(math.Round(percent / 100 * quotaBarSlots))
150	if percent > 0 {
151		filled = max(filled, 1)
152	}
153	var b strings.Builder
154	for i := range quotaBarSlots {
155		switch {
156		case i >= filled:
157			b.WriteString(DefaultStyles.Decor.Render("▱"))
158		case !current:
159			b.WriteString(DefaultStyles.Faint.Render("▰"))
160		default:
161			b.WriteString(quotaInk(percent, float64(i)/(quotaBarSlots-1)).Render("▰"))
162		}
163	}
164	return b.String()
165}
166
167// quotaTag is the right edge of a row: the bar, the percent and when it
168// resets, or why there is no bar.
169func quotaTag(r daemon.QuotaObservation, at time.Time) string {
170	switch standing := standingOf(r, at); standing {
171	case quotaFailed:
172		return DefaultStyles.Error.Render("no reading")
173	case quotaUnknown:
174		return DefaultStyles.Faint.Render("no percentage")
175	default:
176		percent := *r.UsedPercent
177		seen, _ := parseStamp(r.ObservedAt)
178		note := ""
179		switch resets, ok := resetOf(r); {
180		case standing == quotaOld:
181			note = "seen " + quotaSpan(at.Sub(seen))
182		case ok:
183			note = "in " + quotaSpan(resets.Sub(at))
184		}
185		percentText := fmt.Sprintf("%3.0f%%", percent)
186		if standing == quotaOld {
187			percentText = DefaultStyles.Faint.Render(percentText)
188		}
189		return quotaBar(percent, standing == quotaFresh) + " " + percentText + "  " + DefaultStyles.Faint.Render(svCell(note, 9, false))
190	}
191}
192
193// summary is the highlighted limit on one line, for a terminal with no room
194// for the pane.
195func (m QuotaModel) summary(r daemon.QuotaObservation) string {
196	var parts []string
197	switch standingOf(r, m.at) {
198	case quotaFailed:
199		parts = append(parts, "last poll failed")
200	case quotaUnknown:
201		parts = append(parts, "no percentage reported")
202	default:
203		parts = append(parts, fmt.Sprintf("%.0f%% used", *r.UsedPercent))
204		if resets, ok := resetOf(r); ok && resets.After(m.at) {
205			parts = append(parts, "resets in "+quotaSpan(resets.Sub(m.at)))
206		}
207	}
208	return DefaultStyles.Faint.Render(strings.Join(parts, " · "))
209}
210
211// details is the pane: what the reading says, then the limit's recent past.
212func (m QuotaModel) details(r daemon.QuotaObservation, width int) []string {
213	subtitle := r.Provider + " · " + r.AccountID
214	if r.Plan != nil {
215		subtitle += " · " + *r.Plan
216	}
217	lines := paneTitle(quotaName(r), subtitle, width)
218	seen, _ := parseStamp(r.ObservedAt)
219	standing := standingOf(r, m.at)
220
221	if r.UsedPercent != nil {
222		used := fmt.Sprintf("%.0f%%", *r.UsedPercent)
223		if window := quotaWindow(r); window != "" {
224			used += " of the " + window + " window"
225		}
226		lines = append(lines, factRows("used", used, width)...)
227	}
228	if resets, ok := resetOf(r); ok {
229		when := resets.In(m.at.Location()).Format("15:04")
230		if resets.Sub(m.at) >= 24*time.Hour {
231			when = resets.In(m.at.Location()).Format("Mon 15:04")
232		}
233		text := "already reset"
234		if resets.After(m.at) {
235			text = "in " + quotaSpan(resets.Sub(m.at)) + " · " + when
236		}
237		lines = append(lines, factRows("resets", text, width)...)
238	}
239	lines = append(lines, factRows("seen", quotaSpan(m.at.Sub(seen))+" ago", width)...)
240	if r.Scope != nil {
241		lines = append(lines, factRows("scope", *r.Scope, width)...)
242	}
243	if r.Error != nil {
244		lines = append(lines, factRows("error", r.Error.Detail, width)...)
245	}
246	if standing == quotaFailed || standing == quotaUnknown {
247		return lines
248	}
249
250	trail, asked := m.trails[quotaKey(r)]
251	switch {
252	case !asked || trail.pending:
253		return append(lines, "", DefaultStyles.Faint.Render("reading history…"))
254	case trail.failed:
255		return append(lines, "", DefaultStyles.Faint.Render("history unavailable"))
256	case len(trail.points) < 2:
257		return append(lines, "", DefaultStyles.Faint.Render("not enough readings yet"))
258	}
259	from := quotaTrailFrom(trail.points, m.trailSince(r))
260	span := m.at.Sub(from)
261	reach := quotaReach(span)
262	lines = append(lines, "", DefaultStyles.Muted.Render("last "+reach))
263	lines = append(lines, quotaSpark(trail.points, from, m.at, width)...)
264	lines = append(lines, quotaAxis(reach+" ago", "now", width))
265	if pace := quotaPace(trail.points, r, m.at); pace != "" {
266		lines = append(lines, "")
267		lines = append(lines, factRows("pace", pace, width)...)
268	}
269	return lines
270}
271
272// quotaTrailSpan is how far back a limit's trail reaches: its window, but
273// never so little that a short window shows no reset.
274func quotaTrailSpan(r daemon.QuotaObservation) time.Duration {
275	span := quotaTrailMin
276	if r.WindowSeconds != nil {
277		span = time.Duration(*r.WindowSeconds) * time.Second
278	}
279	return min(max(span, quotaTrailMin), quotaTrailMax)
280}
281
282// quotaTrailFrom is where a trail's picture starts: the bound it was asked for,
283// or the first reading when the daemon had fewer than that to give (a busy
284// limit fills the client's row cap before it reaches back as far). A first
285// reading within a poll of the bound still counts as reaching it, so an
286// ordinary trail keeps its round label.
287func quotaTrailFrom(points []quotaPoint, since time.Time) time.Time {
288	if len(points) > 0 && points[0].At.Sub(since) > quotaStale {
289		return points[0].At
290	}
291	return since
292}
293
294// quotaReach names a trail's span in whole hours up to two days, where a
295// day count would read as a different scale.
296func quotaReach(span time.Duration) string {
297	if span <= 48*time.Hour {
298		return fmt.Sprintf("%dh", span/time.Hour)
299	}
300	return quotaSpan(span)
301}
302
303func quotaAxis(left, right string, width int) string {
304	gap := max(1, width-ansi.StringWidth(left)-ansi.StringWidth(right))
305	return DefaultStyles.Faint.Render(left + strings.Repeat(" ", gap) + right)
306}
307
308// quotaSpark draws the points between from and to as two rows of bars, so a
309// column has sixteen levels. A column holds the last reading at or before its
310// end, blank before the first reading and once the last one is stale: a
311// limit that stopped being polled shows the gap.
312func quotaSpark(points []quotaPoint, from, to time.Time, width int) []string {
313	bars := []rune("▁▂▃▄▅▆▇█")
314	step := to.Sub(from) / time.Duration(width)
315	top, bottom := make([]string, width), make([]string, width)
316	next, last, lastAt, seen := 0, 0.0, time.Time{}, false
317	for col := range width {
318		end := from.Add(step * time.Duration(col+1))
319		for next < len(points) && !points[next].At.After(end) {
320			last, lastAt, seen = points[next].Percent, points[next].At, true
321			next++
322		}
323		top[col], bottom[col] = " ", " "
324		if !seen || end.Sub(lastAt) > quotaStale {
325			continue
326		}
327		level := int(math.Round(last / 100 * 16))
328		if last > 0 {
329			level = max(level, 1)
330		}
331		ink := quotaInk(last, last/100)
332		switch {
333		case level == 0:
334			bottom[col] = DefaultStyles.Decor.Render("▁")
335		case level > 8:
336			bottom[col], top[col] = ink.Render("█"), ink.Render(string(bars[level-9]))
337		default:
338			bottom[col] = ink.Render(string(bars[level-1]))
339		}
340	}
341	return []string{strings.Join(top, ""), strings.Join(bottom, "")}
342}
343
344// quotaPace says where the limit is heading, from how fast the current
345// window filled over the last hour and a half. It says nothing when there is
346// too little since the window began to tell.
347func quotaPace(points []quotaPoint, r daemon.QuotaObservation, at time.Time) string {
348	const lookback, enough, still = 90 * time.Minute, 20 * time.Minute, 0.5 // percent an hour
349	var recent []quotaPoint
350	for _, p := range points {
351		switch {
352		case at.Sub(p.At) > lookback:
353		case len(recent) > 0 && p.Percent < recent[len(recent)-1].Percent:
354			recent = recent[:0] // the window reset; only what follows counts
355			recent = append(recent, p)
356		default:
357			recent = append(recent, p)
358		}
359	}
360	if len(recent) < 2 || recent[len(recent)-1].At.Sub(recent[0].At) < enough {
361		return ""
362	}
363	first, last := recent[0], recent[len(recent)-1]
364	rate := (last.Percent - first.Percent) / last.At.Sub(first.At).Hours()
365	if rate < still {
366		return "holding steady"
367	}
368	resets, ok := resetOf(r)
369	if !ok {
370		return fmt.Sprintf("~%.0f%% an hour", rate)
371	}
372	left := resets.Sub(at)
373	untilFull := time.Duration((100 - last.Percent) / rate * float64(time.Hour))
374	if untilFull < left {
375		return "full in ~" + quotaSpan(untilFull)
376	}
377	return fmt.Sprintf("ends near %.0f%%", last.Percent+rate*left.Hours())
378}
379
380func (m QuotaModel) footer(width int) string {
381	hints := []hint{{"↑↓", "move"}, {"ctrl+r", "refresh"}, {"esc", "back"}}
382	if m.Error != "" {
383		hints = []hint{{"ctrl+r", "retry"}, {"esc", "back"}}
384	}
385	var status string
386	urgent := true
387	switch {
388	case m.Loading:
389		status, urgent = DefaultStyles.Faint.Render("loading…"), false
390	case m.Error != "":
391		status = DefaultStyles.Error.Render(m.Error)
392	case len(m.readings) > 0:
393		status, urgent = DefaultStyles.Faint.Render(m.counts()), false
394	}
395	return footerLine(width, hints, status, urgent)
396}
397
398func (m QuotaModel) counts() string {
399	accounts := map[string]bool{}
400	for _, r := range m.readings {
401		accounts[quotaSection(r)] = true
402	}
403	return counted(len(accounts), "account") + DefaultStyles.Decor.Render(" · ") + counted(len(m.readings), "limit")
404}
405
406func (m QuotaModel) View() string {
407	lv := m.listView
408	switch {
409	case m.Loading:
410		lv.Empty = "loading quota…"
411	case m.Error != "":
412		lv.Empty = "Could not read quota from the daemon."
413	default:
414		lv.Empty = "No quota readings yet. The daemon polls each account every few minutes."
415	}
416	title := brand("albedo") + " " + DefaultStyles.Muted.Render("/quota")
417	f := lv.frame(title, DefaultStyles.Faint.Render("provider limits"), m.footer(max(1, m.Width)))
418	if r, ok := m.current(); ok {
419		f.summary = m.summary(r)
420	}
421	return f.view(m.Width, m.Height)
422}