| 1 | // The Running tab's and the instances API's shared list controls: a |
| 2 | // substring match, a health-word filter, and an opaque after-this-host |
| 3 | // cursor. GET-param driven and server-computed on purpose — a filter the |
| 4 | // server applies is one the API and CLI can apply identically (every carlos |
| 5 | // command works for a user with no box access), and a bookmarked filtered |
| 6 | // URL keeps meaning the same thing. |
| 7 | package console |
| 8 | |
| 9 | import ( |
| 10 | "encoding/base64" |
| 11 | "encoding/json" |
| 12 | "net/url" |
| 13 | "sort" |
| 14 | "strings" |
| 15 | ) |
| 16 | |
| 17 | // instancePageSize is the Running tab's page length. |
| 18 | const instancePageSize = 50 |
| 19 | |
| 20 | // healthWords is every word healthWord can emit, in the order the header |
| 21 | // lists them. A new word joins healthWord, this list, and nothing else — |
| 22 | // slugs, filters and links all derive from here. Waking/Crashed/Never |
| 23 | // started are sidecarHealthWord's own vocabulary (overview.go); Running and |
| 24 | // Asleep are shared with the instance family, not duplicated here. |
| 25 | var healthWords = []string{"Running", "Serving", "Setting up", "Asleep", "No readings", "Not responding", "Failed", "Waking", "Crashed", "Never started"} |
| 26 | |
| 27 | // instanceQuery is one request's list controls. Zero value = everything, |
| 28 | // first page. |
| 29 | type instanceQuery struct { |
| 30 | Channel string |
| 31 | Group bool |
| 32 | |
| 33 | Q string // substring match on host, trimmed |
| 34 | Health string // canonical health word ("" = all) |
| 35 | Cursor string // opaque; list resumes after the host inside it |
| 36 | } |
| 37 | |
| 38 | func parseInstanceQuery(v url.Values) instanceQuery { |
| 39 | return instanceQuery{ |
| 40 | Channel: strings.TrimSpace(v.Get("channel")), |
| 41 | Group: v.Get("group") == "channel", |
| 42 | Q: strings.TrimSpace(v.Get("q")), |
| 43 | Health: healthFromSlug(v.Get("health")), |
| 44 | Cursor: v.Get("cursor"), |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // healthSlug is a health word's URL form: "Not responding" ↔ |
| 49 | // "not-responding". |
| 50 | func healthSlug(word string) string { |
| 51 | return strings.ReplaceAll(strings.ToLower(word), " ", "-") |
| 52 | } |
| 53 | |
| 54 | func healthTone(word string) string { |
| 55 | switch word { |
| 56 | case "Running", "Serving": |
| 57 | return "ok" |
| 58 | case "Failed", "Not responding": |
| 59 | return "attention" |
| 60 | case "Asleep", "Setting up": |
| 61 | return "notdeployed" |
| 62 | default: |
| 63 | return "unknown" |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func hasInstanceChannel(channels []string, selected string) bool { |
| 68 | for _, channel := range channels { |
| 69 | if channel == selected { |
| 70 | return true |
| 71 | } |
| 72 | } |
| 73 | return false |
| 74 | } |
| 75 | |
| 76 | // healthFromSlug inverts healthSlug. An unknown slug returns "" — no filter: |
| 77 | // a mistyped URL shows everything rather than a mysteriously empty list. |
| 78 | func healthFromSlug(slug string) string { |
| 79 | for _, w := range healthWords { |
| 80 | if healthSlug(w) == slug { |
| 81 | return w |
| 82 | } |
| 83 | } |
| 84 | return "" |
| 85 | } |
| 86 | |
| 87 | type instanceCursor struct { |
| 88 | Channel string `json:"channel,omitempty"` |
| 89 | Grouped bool `json:"grouped,omitempty"` |
| 90 | |
| 91 | Host string `json:"host"` |
| 92 | } |
| 93 | |
| 94 | func encodeInstanceCursor(host string) string { |
| 95 | b, _ := json.Marshal(instanceCursor{Host: host}) |
| 96 | return base64.RawURLEncoding.EncodeToString(b) |
| 97 | } |
| 98 | |
| 99 | // decodeInstanceCursor degrades on any malformed input to "no cursor" — |
| 100 | // page one, never an error page (applog.DecodeCursor's rule). |
| 101 | func decodeInstanceCursor(s string) (string, bool) { |
| 102 | b, err := base64.RawURLEncoding.DecodeString(s) |
| 103 | if err != nil { |
| 104 | return "", false |
| 105 | } |
| 106 | var c instanceCursor |
| 107 | if json.Unmarshal(b, &c) != nil || c.Host == "" { |
| 108 | return "", false |
| 109 | } |
| 110 | return c.Host, true |
| 111 | } |
| 112 | |
| 113 | // healthLink is one header chip: a word, its count over ALL the app's rows, |
| 114 | // and the link that filters to it. Precomputed — templates render facts. |
| 115 | type healthLink struct { |
| 116 | Word, Slug string |
| 117 | Count int |
| 118 | Active bool |
| 119 | URL string |
| 120 | } |
| 121 | |
| 122 | // instancePage is one screen of the list: the rows to render plus the |
| 123 | // numbers and links the header states. |
| 124 | type instancePage struct { |
| 125 | Rows []InstanceRow |
| 126 | Matching []InstanceRow // rows matching Q+Health+Channel, across all pages |
| 127 | Counts map[string]int // health word -> count over ALL rows |
| 128 | Total int // rows matching Q+Health+Channel, across all pages |
| 129 | Links []healthLink // non-zero words, healthWords order |
| 130 | NextCursor string // "" on the last page |
| 131 | NextURL string // the whole next-page href; "" on the last page |
| 132 | } |
| 133 | |
| 134 | // pageInstanceRows applies q to rows (which arrive in host order and stay in |
| 135 | // it) and cuts one page. Counts cover every row regardless of filter — the |
| 136 | // header's chips are the app's whole health story, and double as filters. |
| 137 | func pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePage { |
| 138 | p := instancePage{Counts: map[string]int{}} |
| 139 | var matched []InstanceRow |
| 140 | for _, r := range rows { |
| 141 | p.Counts[r.HealthWord]++ |
| 142 | if q.Q != "" && !strings.Contains(r.Host, q.Q) { |
| 143 | continue |
| 144 | } |
| 145 | if q.Channel != "" && r.Channel != q.Channel { |
| 146 | continue |
| 147 | } |
| 148 | if q.Health != "" && r.HealthWord != q.Health { |
| 149 | continue |
| 150 | } |
| 151 | matched = append(matched, r) |
| 152 | } |
| 153 | if q.Group { |
| 154 | sort.SliceStable(matched, func(i, j int) bool { |
| 155 | if matched[i].Channel != matched[j].Channel { |
| 156 | return matched[i].Channel < matched[j].Channel |
| 157 | } |
| 158 | return matched[i].Host < matched[j].Host |
| 159 | }) |
| 160 | } |
| 161 | p.Total = len(matched) |
| 162 | p.Matching = matched |
| 163 | start := 0 |
| 164 | if after, ok := decodePageCursor(q.Cursor, q.Group); ok { |
| 165 | for i, r := range matched { |
| 166 | if (!q.Group && r.Host > after.Host) || (q.Group && (r.Channel > after.Channel || (r.Channel == after.Channel && r.Host > after.Host))) { |
| 167 | start = i |
| 168 | break |
| 169 | } |
| 170 | start = i + 1 |
| 171 | } |
| 172 | } |
| 173 | end := start + limit |
| 174 | if end < len(matched) { |
| 175 | p.NextCursor = encodePageCursor(matched[end-1], q.Group) |
| 176 | } else { |
| 177 | end = len(matched) |
| 178 | } |
| 179 | p.Rows = matched[start:end] |
| 180 | for _, w := range healthWords { |
| 181 | if p.Counts[w] == 0 { |
| 182 | continue |
| 183 | } |
| 184 | linkQuery := q |
| 185 | linkQuery.Health, linkQuery.Cursor = w, "" |
| 186 | p.Links = append(p.Links, healthLink{Word: w, Slug: healthSlug(w), Count: p.Counts[w], Active: q.Health == w, URL: instanceQuerySuffix(linkQuery)}) |
| 187 | } |
| 188 | if p.NextCursor != "" { |
| 189 | next := q |
| 190 | next.Cursor = p.NextCursor |
| 191 | p.NextURL = instanceQuerySuffix(next) |
| 192 | } |
| 193 | return p |
| 194 | } |
| 195 | |