okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
8e3e173
main
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 { 18Err error 19Readings []daemon.QuotaObservation 20At time.Time 21Gen int 22} 23 24// quotaHistoryMsg is the readings of one limit over the span the pane draws, 25// oldest first. 26type quotaHistoryMsg struct { 27Err error 28Key string 29Points []quotaPoint 30Gen 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 { 36points []quotaPoint 37failed bool 38pending 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 { 44Conn * daemon.Connection 45readings []daemon.QuotaObservation 46trails map [string ]quotaTrail 47at time.Time // when the readings were fetched; every age and countdown is against it 48pageStatus 49listView 50} 51 52func NewQuotaModel (conn * daemon.Connection )QuotaModel { 53return QuotaModel { 54Conn :conn , 55trails :map [string ]quotaTrail {}, 56pageStatus :newPageStatus (true ), 57listView :newListView ("Search accounts and limits" ), 58 } 59} 60 61func (m * QuotaModel )SetSize (width ,height int ) { 62m .pageStatus .SetSize (width ,height ) 63m .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 ) { 69m .Loading ,m .Error = true ,"" 70m .Generation = nextPageGeneration () 71return m ,m .loadCmd (m .Generation ) 72} 73 74func (m QuotaModel )loadCmd (gen int ) tea.Cmd { 75return func () tea.Msg { 76if m .Conn == nil { 77return quotaLoadedMsg {Err :errors .New ("daemon connection unavailable" ),Gen :gen } 78 } 79readings ,err := daemon .GetLatestQuota (context .Background (),m .Conn ) 80return 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 { 88r ,ok := m .current () 89if ! ok || m .Loading || r .Error != nil || r .UsedPercent == nil { 90return nil 91 } 92key := quotaKey (r ) 93if _ ,asked := m .trails [key ];asked { 94return nil 95 } 96m .trails [key ]= quotaTrail {pending :true } 97return 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 { 102return m .at .Add (- quotaTrailSpan (r )) 103} 104 105func (m QuotaModel )trailCmd (r daemon.QuotaObservation ,gen int ) tea.Cmd { 106key ,since := quotaKey (r ),m .trailSince (r ) 107return func () tea.Msg { 108if m .Conn == nil { 109return quotaHistoryMsg {Err :errors .New ("daemon connection unavailable" ),Key :key ,Gen :gen } 110 } 111readings ,err := daemon .GetQuotaTrail (context .Background (),m .Conn , daemon.QuotaTrailQuery { 112Provider :r .Provider ,Account :r .AccountID ,Limit :r .LimitID ,Since :since , 113 }) 114return 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 { 121points := make ([]quotaPoint ,0 ,len (readings )) 122for _ ,r := range readings { 123if at ,ok := parseStamp (r .ObservedAt );ok && r .UsedPercent != nil { 124points = append (points ,quotaPoint {At :at ,Percent :* r .UsedPercent }) 125 } 126 } 127return points 128} 129 130// current is the reading under the cursor. 131func (m QuotaModel )current () (daemon.QuotaObservation ,bool ) { 132row ,ok := m .highlighted () 133if ! ok { 134return daemon.QuotaObservation {},false 135 } 136i := slices .IndexFunc (m .readings ,func (r daemon.QuotaObservation )bool {return quotaKey (r )== row .key }) 137if i < 0 { 138return daemon.QuotaObservation {},false 139 } 140return 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 ) { 146m .readings = slices .Clone (readings ) 147slices .SortStableFunc (m .readings ,func (a ,b daemon.QuotaObservation )int { 148return strings .Compare (quotaSection (a ),quotaSection (b )) 149 }) 150m .at = at 151m .rebuild () 152} 153 154func (m * QuotaModel )rebuild () { 155rows := make ([]listEntry ,len (m .readings )) 156for i ,r := range m .readings { 157rows [i ]= m .entry (r ) 158 } 159m .setRows (rows ) 160} 161 162func (m QuotaModel )entry (r daemon.QuotaObservation )listEntry { 163return listEntry { 164key :quotaKey (r ), 165section :quotaSection (r ), 166name :quotaName (r ), 167desc :quotaDesc (r ), 168tag :quotaTag (r ,m .at ), 169search : []string {r .Provider ,r .AccountID ,r .LimitID }, 170detail :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 ) { 177if key ,ok := msg .(tea.KeyPressMsg );ok && (key .String ()== "esc" || key .String ()== "ctrl+c" ) { 178return m ,func () tea.Msg {return QuotaCancelMsg {} } 179 } 180m ,cmd := m .update (msg ) 181if trail := m .fetchTrail ();trail != nil { 182cmd = tea .Batch (cmd ,trail ) 183 } 184return m ,cmd 185} 186 187func (m QuotaModel )update (msg tea.Msg ) (QuotaModel , tea.Cmd ) { 188switch msg := msg .(type ) { 189case quotaLoadedMsg : 190if ! m .settle (msg .Gen ,msg .Err ,& m .Loading ) { 191return m ,nil 192 } 193m .trails = map [string ]quotaTrail {} 194m .setReadings (msg .Readings ,msg .At ) 195return m ,nil 196 197case quotaHistoryMsg : 198if msg .Gen != m .Generation { 199return m ,nil 200 } 201m .trails [msg .Key ]= quotaTrail {points :msg .Points ,failed :msg .Err != nil } 202m .rebuild () 203return m ,nil 204 205case tea.KeyPressMsg : 206if msg .String ()== "ctrl+r" { 207if ! m .Loading { 208return m .load () 209 } 210return m ,nil 211 } 212 } 213cmd := m .listView .update (msg ) 214return m ,cmd 215}