okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
8e3e173
main
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 ( 20quotaBarSlots = 10 21// Warm from here, red from quotaHot. The daemon polls busier from the same 80. 22quotaWarm = 80.0 23quotaHot = 95.0 24// The pane's trail shows at least a day, so a short window's resets show. 25quotaTrailMin = 24 * time .Hour 26quotaTrailMax = 7 * 24 * time .Hour 27) 28 29// quotaPoint is one reading of a limit's percent. 30type quotaPoint struct { 31At time.Time 32Percent float64 33} 34 35type quotaStanding int 36 37const ( 38quotaFresh quotaStanding = iota 39quotaOld // polled long ago, or past its own reset 40quotaFailed // the provider reported an error 41quotaUnknown // answered without a percentage 42) 43 44func quotaKey (r daemon.QuotaObservation )string { 45return r .Provider + "/" + r .AccountID + "/" + r .LimitID 46} 47 48func quotaSection (r daemon.QuotaObservation )string { 49if r .AccountID == r .Provider { 50return r .Provider 51 } 52return 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 { 59switch { 60case r .Label != "" : 61return r .Label 62case r .Error != nil : 63return "last poll failed" 64case r .LimitID != "" : 65return r .LimitID 66 } 67return "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 { 73if r .WindowLabel == nil { 74return "" 75 } 76return 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 { 81if r .WindowLabel == nil || * r .WindowLabel == r .Label { 82return "" 83 } 84return * r .WindowLabel 85} 86 87func parseStamp (s string ) (time.Time ,bool ) { 88t ,err := time .Parse (time .RFC3339 ,s ) 89return t ,err == nil 90} 91 92func resetOf (r daemon.QuotaObservation ) (time.Time ,bool ) { 93if r .ResetsAt == nil { 94return time.Time {},false 95 } 96return parseStamp (* r .ResetsAt ) 97} 98 99func standingOf (r daemon.QuotaObservation ,at time.Time )quotaStanding { 100seen ,_ := parseStamp (r .ObservedAt ) 101resets ,hasReset := resetOf (r ) 102switch { 103case r .Error != nil : 104return quotaFailed 105case r .UsedPercent == nil : 106return quotaUnknown 107case at .Sub (seen )> quotaStale || (hasReset && ! resets .After (at )): 108return quotaOld 109 } 110return quotaFresh 111} 112 113// span writes a duration as 40m, 2h14m or 3d4h: two units at most. 114func quotaSpan (d time.Duration )string { 115d = max (d ,0 ) 116units := func (big ,small time.Duration ,bigName ,smallName string )string { 117out := fmt .Sprintf ("%d%s" ,d / big ,bigName ) 118if rest := (d % big )/ small ;rest > 0 { 119out += fmt .Sprintf ("%d%s" ,rest ,smallName ) 120 } 121return out 122 } 123switch { 124case d < time .Minute : 125return "<1m" 126case d < time .Hour : 127return fmt .Sprintf ("%dm" ,d / time .Minute ) 128case d < 24 * time .Hour : 129return units (time .Hour ,time .Minute ,"h" ,"m" ) 130 } 131return 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 { 137switch { 138case percent >= quotaHot : 139return DefaultStyles .Error 140case percent >= quotaWarm : 141return DefaultStyles .Warning 142 } 143return 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 { 149filled := int (math .Round (percent / 100 * quotaBarSlots )) 150if percent > 0 { 151filled = max (filled ,1 ) 152 } 153var b strings.Builder 154for i := range quotaBarSlots { 155switch { 156case i >= filled : 157b .WriteString (DefaultStyles .Decor .Render ("▱" )) 158case ! current : 159b .WriteString (DefaultStyles .Faint .Render ("▰" )) 160default : 161b .WriteString (quotaInk (percent ,float64 (i )/ (quotaBarSlots - 1 )).Render ("▰" )) 162 } 163 } 164return 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 { 170switch standing := standingOf (r ,at );standing { 171case quotaFailed : 172return DefaultStyles .Error .Render ("no reading" ) 173case quotaUnknown : 174return DefaultStyles .Faint .Render ("no percentage" ) 175default : 176percent := * r .UsedPercent 177seen ,_ := parseStamp (r .ObservedAt ) 178note := "" 179switch resets ,ok := resetOf (r ); { 180case standing == quotaOld : 181note = "seen " + quotaSpan (at .Sub (seen )) 182case ok : 183note = "in " + quotaSpan (resets .Sub (at )) 184 } 185percentText := fmt .Sprintf ("%3.0f%%" ,percent ) 186if standing == quotaOld { 187percentText = DefaultStyles .Faint .Render (percentText ) 188 } 189return 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 { 196var parts []string 197switch standingOf (r ,m .at ) { 198case quotaFailed : 199parts = append (parts ,"last poll failed" ) 200case quotaUnknown : 201parts = append (parts ,"no percentage reported" ) 202default : 203parts = append (parts ,fmt .Sprintf ("%.0f%% used" ,* r .UsedPercent )) 204if resets ,ok := resetOf (r );ok && resets .After (m .at ) { 205parts = append (parts ,"resets in " + quotaSpan (resets .Sub (m .at ))) 206 } 207 } 208return 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 { 213subtitle := r .Provider + " · " + r .AccountID 214if r .Plan != nil { 215subtitle += " · " + * r .Plan 216 } 217lines := paneTitle (quotaName (r ),subtitle ,width ) 218seen ,_ := parseStamp (r .ObservedAt ) 219standing := standingOf (r ,m .at ) 220 221if r .UsedPercent != nil { 222used := fmt .Sprintf ("%.0f%%" ,* r .UsedPercent ) 223if window := quotaWindow (r );window != "" { 224used += " of the " + window + " window" 225 } 226lines = append (lines ,factRows ("used" ,used ,width )... ) 227 } 228if resets ,ok := resetOf (r );ok { 229when := resets .In (m .at .Location ()).Format ("15:04" ) 230if resets .Sub (m .at )>= 24 * time .Hour { 231when = resets .In (m .at .Location ()).Format ("Mon 15:04" ) 232 } 233text := "already reset" 234if resets .After (m .at ) { 235text = "in " + quotaSpan (resets .Sub (m .at ))+ " · " + when 236 } 237lines = append (lines ,factRows ("resets" ,text ,width )... ) 238 } 239lines = append (lines ,factRows ("seen" ,quotaSpan (m .at .Sub (seen ))+ " ago" ,width )... ) 240if r .Scope != nil { 241lines = append (lines ,factRows ("scope" ,* r .Scope ,width )... ) 242 } 243if r .Error != nil { 244lines = append (lines ,factRows ("error" ,r .Error .Detail ,width )... ) 245 } 246if standing == quotaFailed || standing == quotaUnknown { 247return lines 248 } 249 250trail ,asked := m .trails [quotaKey (r )] 251switch { 252case ! asked || trail .pending : 253return append (lines ,"" ,DefaultStyles .Faint .Render ("reading history…" )) 254case trail .failed : 255return append (lines ,"" ,DefaultStyles .Faint .Render ("history unavailable" )) 256case len (trail .points )< 2 : 257return append (lines ,"" ,DefaultStyles .Faint .Render ("not enough readings yet" )) 258 } 259from := quotaTrailFrom (trail .points ,m .trailSince (r )) 260span := m .at .Sub (from ) 261reach := quotaReach (span ) 262lines = append (lines ,"" ,DefaultStyles .Muted .Render ("last " + reach )) 263lines = append (lines ,quotaSpark (trail .points ,from ,m .at ,width )... ) 264lines = append (lines ,quotaAxis (reach + " ago" ,"now" ,width )) 265if pace := quotaPace (trail .points ,r ,m .at );pace != "" { 266lines = append (lines ,"" ) 267lines = append (lines ,factRows ("pace" ,pace ,width )... ) 268 } 269return 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 { 275span := quotaTrailMin 276if r .WindowSeconds != nil { 277span = time .Duration (* r .WindowSeconds )* time .Second 278 } 279return 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 { 288if len (points )> 0 && points [0 ].At .Sub (since )> quotaStale { 289return points [0 ].At 290 } 291return 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 { 297if span <= 48 * time .Hour { 298return fmt .Sprintf ("%dh" ,span / time .Hour ) 299 } 300return quotaSpan (span ) 301} 302 303func quotaAxis (left ,right string ,width int )string { 304gap := max (1 ,width - ansi .StringWidth (left )- ansi .StringWidth (right )) 305return 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 { 313bars := []rune ("▁▂▃▄▅▆▇█" ) 314step := to .Sub (from )/ time .Duration (width ) 315top ,bottom := make ([]string ,width ),make ([]string ,width ) 316next ,last ,lastAt ,seen := 0 ,0.0 , time.Time {},false 317for col := range width { 318end := from .Add (step * time .Duration (col + 1 )) 319for next < len (points )&& ! points [next ].At .After (end ) { 320last ,lastAt ,seen = points [next ].Percent ,points [next ].At ,true 321next ++ 322 } 323top [col ],bottom [col ]= " " ," " 324if ! seen || end .Sub (lastAt )> quotaStale { 325continue 326 } 327level := int (math .Round (last / 100 * 16 )) 328if last > 0 { 329level = max (level ,1 ) 330 } 331ink := quotaInk (last ,last / 100 ) 332switch { 333case level == 0 : 334bottom [col ]= DefaultStyles .Decor .Render ("▁" ) 335case level > 8 : 336bottom [col ],top [col ]= ink .Render ("█" ),ink .Render (string (bars [level - 9 ])) 337default : 338bottom [col ]= ink .Render (string (bars [level - 1 ])) 339 } 340 } 341return []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 { 348const lookback ,enough ,still = 90 * time .Minute ,20 * time .Minute ,0.5 // percent an hour 349var recent []quotaPoint 350for _ ,p := range points { 351switch { 352case at .Sub (p .At )> lookback : 353case len (recent )> 0 && p .Percent < recent [len (recent )- 1 ].Percent : 354recent = recent [:0 ]// the window reset; only what follows counts 355recent = append (recent ,p ) 356default : 357recent = append (recent ,p ) 358 } 359 } 360if len (recent )< 2 || recent [len (recent )- 1 ].At .Sub (recent [0 ].At )< enough { 361return "" 362 } 363first ,last := recent [0 ],recent [len (recent )- 1 ] 364rate := (last .Percent - first .Percent )/ last .At .Sub (first .At ).Hours () 365if rate < still { 366return "holding steady" 367 } 368resets ,ok := resetOf (r ) 369if ! ok { 370return fmt .Sprintf ("~%.0f%% an hour" ,rate ) 371 } 372left := resets .Sub (at ) 373untilFull := time .Duration ((100 - last .Percent )/ rate * float64 (time .Hour )) 374if untilFull < left { 375return "full in ~" + quotaSpan (untilFull ) 376 } 377return fmt .Sprintf ("ends near %.0f%%" ,last .Percent + rate * left .Hours ()) 378} 379 380func (m QuotaModel )footer (width int )string { 381hints := []hint {{"↑↓" ,"move" }, {"ctrl+r" ,"refresh" }, {"esc" ,"back" }} 382if m .Error != "" { 383hints = []hint {{"ctrl+r" ,"retry" }, {"esc" ,"back" }} 384 } 385var status string 386urgent := true 387switch { 388case m .Loading : 389status ,urgent = DefaultStyles .Faint .Render ("loading…" ),false 390case m .Error != "" : 391status = DefaultStyles .Error .Render (m .Error ) 392case len (m .readings )> 0 : 393status ,urgent = DefaultStyles .Faint .Render (m .counts ()),false 394 } 395return footerLine (width ,hints ,status ,urgent ) 396} 397 398func (m QuotaModel )counts ()string { 399accounts := map [string ]bool {} 400for _ ,r := range m .readings { 401accounts [quotaSection (r )]= true 402 } 403return counted (len (accounts ),"account" )+ DefaultStyles .Decor .Render (" · " )+ counted (len (m .readings ),"limit" ) 404} 405 406func (m QuotaModel )View ()string { 407lv := m .listView 408switch { 409case m .Loading : 410lv .Empty = "loading quota…" 411case m .Error != "" : 412lv .Empty = "Could not read quota from the daemon." 413default : 414lv .Empty = "No quota readings yet. The daemon polls each account every few minutes." 415 } 416title := brand ("albedo" )+ " " + DefaultStyles .Muted .Render ("/quota" ) 417f := lv .frame (title ,DefaultStyles .Faint .Render ("provider limits" ),m .footer (max (1 ,m .Width ))) 418if r ,ok := m .current ();ok { 419f .summary = m .summary (r ) 420 } 421return f .view (m .Width ,m .Height ) 422}