| 1 | // Package report renders the bakeoff's comparison page from results. |
| 2 | package report |
| 3 | |
| 4 | import ( |
| 5 | _ "embed" |
| 6 | "fmt" |
| 7 | "html/template" |
| 8 | "io" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | |
| 12 | b "bakeoff/harness" |
| 13 | ) |
| 14 | |
| 15 | //go:embed report.html.tmpl |
| 16 | var pageSrc string |
| 17 | |
| 18 | //go:embed style.tmpl |
| 19 | var styleSrc string |
| 20 | |
| 21 | //go:embed writeup.html.tmpl |
| 22 | var writeupSrc string |
| 23 | |
| 24 | var page = template.Must(template.New("page").Funcs(template.FuncMap{ |
| 25 | "md": func(c Copy, key string) template.HTML { return markdown(c.T(key)) }, |
| 26 | "t": func(c Copy, key string) string { return c.T(key) }, |
| 27 | "opt": func(c Copy, key string) string { return c[key] }, |
| 28 | // cp is a string for a page element; in a copy-review preview it |
| 29 | // carries data-copy so the reviewer can click it. |
| 30 | "cp": func(c Copy, key string, preview bool) template.HTML { |
| 31 | text := template.HTMLEscapeString(c.T(key)) |
| 32 | if !preview { |
| 33 | return template.HTML(text) |
| 34 | } |
| 35 | return template.HTML(`<span data-copy="` + key + `">` + text + `</span>`) |
| 36 | }, |
| 37 | "ms": ms, |
| 38 | "mb": mb, |
| 39 | "num": num, |
| 40 | "usd": usd, |
| 41 | "secs": secs, |
| 42 | "live": live, |
| 43 | "total": func(r b.AppResult) b.Tokens { return r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity) }, |
| 44 | "wake": func(r b.AppResult) float64 { return medianOf(r.Wake) }, |
| 45 | "peak": func(r b.AppResult, sc string) float64 { return knee(r, sc).PeakRPS }, |
| 46 | "loc": func(c b.CodeStats) int { return c.Server + c.Templates + c.JS }, |
| 47 | "ran": func(x b.Behaviour) int { return x.Passed + x.Failed }, |
| 48 | "float": func(n int64) float64 { return float64(n) }, |
| 49 | "inc": func(i int) int { return i + 1 }, |
| 50 | }).Parse(pageSrc + styleSrc)) |
| 51 | |
| 52 | var writeupPage = template.Must(template.Must(page.Clone()).New("writeup").Parse(writeupSrc)) |
| 53 | |
| 54 | // RenderWriteup writes the write-up page from its Markdown, with the |
| 55 | // report's styles; the title is the Markdown's first heading. |
| 56 | func RenderWriteup(src string, copy Copy, w io.Writer) error { |
| 57 | title := strings.TrimSpace(strings.TrimLeft(strings.SplitN(strings.TrimSpace(src), "\n", 2)[0], "# ")) |
| 58 | return writeupPage.ExecuteTemplate(w, "writeup", struct { |
| 59 | Title string |
| 60 | Body template.HTML |
| 61 | Copy Copy |
| 62 | }{title, markdown(src), copy}) |
| 63 | } |
| 64 | |
| 65 | func live(r b.AppResult) bool { return strings.HasPrefix(r.Status, "LIVE") } |
| 66 | |
| 67 | func ms(v float64) string { |
| 68 | if v >= 100 { |
| 69 | return fmt.Sprintf("%.0f ms", v) |
| 70 | } |
| 71 | return fmt.Sprintf("%.1f ms", v) |
| 72 | } |
| 73 | func mb(v float64) string { return fmt.Sprintf("%.1f MB", v/1e6) } |
| 74 | func usd(v float64) string { return fmt.Sprintf("$%.2f", v) } |
| 75 | func kb(v float64) string { return fmt.Sprintf("%.1f MB", v/1024) } |
| 76 | func secs(v float64) string { |
| 77 | if v >= 120 { |
| 78 | return fmt.Sprintf("%.1f min", v/60) |
| 79 | } |
| 80 | return fmt.Sprintf("%.1f s", v) |
| 81 | } |
| 82 | func num(v float64) string { |
| 83 | s := fmt.Sprintf("%.0f", v) |
| 84 | for i := len(s) - 3; i > 0 && s[i-1] != '-'; i -= 3 { |
| 85 | s = s[:i] + "," + s[i:] |
| 86 | } |
| 87 | return s |
| 88 | } |
| 89 | |
| 90 | func medianOf(v []float64) float64 { |
| 91 | if len(v) == 0 { |
| 92 | return 0 |
| 93 | } |
| 94 | c := append([]float64(nil), v...) |
| 95 | sort.Float64s(c) |
| 96 | return c[len(c)/2] |
| 97 | } |
| 98 | |
| 99 | func knee(r b.AppResult, scenario string) b.KneeResult { |
| 100 | var sweep []b.LoadResult |
| 101 | for _, l := range r.Load { |
| 102 | if l.Scenario == scenario { |
| 103 | sweep = append(sweep, l) |
| 104 | } |
| 105 | } |
| 106 | return b.Knee(sweep) |
| 107 | } |
| 108 | |
| 109 | // kneeChart shows, per app, where it stopped scaling; an app that never |
| 110 | // did shows that instead of a bar, and errors are named after the value. |
| 111 | func kneeChart(rs []b.AppResult, c Copy, scenario, id string) Chart { |
| 112 | ch := chart(rs, c, id, func(v float64) string { return c.T("unit.connections", "n", num(v)) }, |
| 113 | func(r b.AppResult) (float64, bool) { |
| 114 | k := knee(r, scenario) |
| 115 | return float64(k.Concurrency), k.Found |
| 116 | }) |
| 117 | for i, r := range rs { |
| 118 | k := knee(r, scenario) |
| 119 | switch { |
| 120 | case !live(r): |
| 121 | case k.ErrorsAt > 0 && k.ErrorsStatus == "503": |
| 122 | ch.Rows[i].Suffix = c.T("label.errors_from", "n", num(float64(k.ErrorsAt))) |
| 123 | case k.ErrorsAt > 0: |
| 124 | ch.Rows[i].Suffix = c.T("label.failed_from", "n", num(float64(k.ErrorsAt))) |
| 125 | case !k.Found && k.MaxTested > 0: |
| 126 | ch.Rows[i].Missing = c.T("label.still_scaling", "n", num(float64(k.MaxTested))) |
| 127 | } |
| 128 | } |
| 129 | return ch |
| 130 | } |
| 131 | |
| 132 | // chart builds the chart whose title and note are chart.<id>.title and |
| 133 | // chart.<id>.note; get returns ok=false when the app has no measurement. |
| 134 | func chart(rs []b.AppResult, c Copy, id string, format func(float64) string, get func(b.AppResult) (float64, bool)) Chart { |
| 135 | ch := Chart{ID: id, Title: c.T("chart." + id + ".title"), Note: c.T("chart." + id + ".note"), Format: format} |
| 136 | for _, r := range rs { |
| 137 | row := Row{Language: r.Language} |
| 138 | if !live(r) { |
| 139 | row.Missing = c.T("label.blocked") |
| 140 | } else if v, ok := get(r); ok { |
| 141 | row.Value = v |
| 142 | } else { |
| 143 | row.Missing = c.T("label.not_measured") |
| 144 | } |
| 145 | ch.Rows = append(ch.Rows, row) |
| 146 | } |
| 147 | return ch |
| 148 | } |
| 149 | |
| 150 | func charts(rs []b.AppResult, c Copy) []Chart { |
| 151 | pos := func(v float64) (float64, bool) { return v, v > 0 } |
| 152 | loc := Chart{ID: "loc", Title: c.T("chart.loc.title"), Note: c.T("chart.loc.note"), |
| 153 | Format: func(v float64) string { return num(v) }, |
| 154 | Legend: []string{c.T("chart.loc.server"), c.T("chart.loc.templates"), c.T("chart.loc.js")}, |
| 155 | LegendIDs: []string{"chart.loc.server", "chart.loc.templates", "chart.loc.js"}} |
| 156 | for _, r := range rs { |
| 157 | row := Row{Language: r.Language} |
| 158 | code := r.Code |
| 159 | if !live(r) { |
| 160 | row.Missing = c.T("label.blocked") |
| 161 | } else if code.Server+code.Templates+code.JS == 0 { |
| 162 | row.Missing = c.T("label.not_measured") |
| 163 | } else { |
| 164 | row.Parts = []float64{float64(code.Server), float64(code.Templates), float64(code.JS)} |
| 165 | row.Value = float64(code.Server + code.Templates + code.JS) |
| 166 | } |
| 167 | loc.Rows = append(loc.Rows, row) |
| 168 | } |
| 169 | all := func(r b.AppResult) b.Tokens { return r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity) } |
| 170 | return []Chart{ |
| 171 | chart(rs, c, "ttfb_empty", ms, func(r b.AppResult) (float64, bool) { return pos(r.TTFBEmpty.P50) }), |
| 172 | chart(rs, c, "ttfb_50", ms, func(r b.AppResult) (float64, bool) { return pos(r.TTFB50.P50) }), |
| 173 | chart(rs, c, "wake", ms, func(r b.AppResult) (float64, bool) { return pos(medianOf(r.Wake)) }), |
| 174 | chart(rs, c, "reads_peak", num, func(r b.AppResult) (float64, bool) { return pos(knee(r, "read").PeakRPS) }), |
| 175 | kneeChart(rs, c, "read", "reads_knee"), |
| 176 | chart(rs, c, "toggles_peak", num, func(r b.AppResult) (float64, bool) { return pos(knee(r, "toggle").PeakRPS) }), |
| 177 | kneeChart(rs, c, "toggle", "toggles_knee"), |
| 178 | chart(rs, c, "reads_after", num, func(r b.AppResult) (float64, bool) { return pos(knee(r, "read-after").PeakRPS) }), |
| 179 | kneeChart(rs, c, "read-after", "reads_after_knee"), |
| 180 | chart(rs, c, "memory_idle", kb, func(r b.AppResult) (float64, bool) { return pos(float64(r.Memory.IdleKB)) }), |
| 181 | chart(rs, c, "memory_peak", kb, func(r b.AppResult) (float64, bool) { return pos(float64(r.Memory.PeakKB)) }), |
| 182 | chart(rs, c, "binary", mb, func(r b.AppResult) (float64, bool) { return pos(float64(r.Build.Bytes)) }), |
| 183 | chart(rs, c, "build_cold", secs, func(r b.AppResult) (float64, bool) { return pos(r.Build.ColdSeconds) }), |
| 184 | chart(rs, c, "build_edit", secs, func(r b.AppResult) (float64, bool) { return pos(r.Build.EditSeconds) }), |
| 185 | loc, |
| 186 | chart(rs, c, "cost", usd, func(r b.AppResult) (float64, bool) { return pos(all(r).CostUSD) }), |
| 187 | chart(rs, c, "tokens", num, func(r b.AppResult) (float64, bool) { return pos(float64(all(r).Output)) }), |
| 188 | chart(rs, c, "readability", func(v float64) string { return c.T("unit.out_of_ten", "n", fmt.Sprintf("%.0f", v)) }, |
| 189 | func(r b.AppResult) (float64, bool) { return pos(r.Readability.Overall) }), |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // Render writes the report page. |
| 194 | func Render(rs []b.AppResult, copy Copy, w io.Writer) error { return render(rs, copy, w, false) } |
| 195 | |
| 196 | // RenderPreview writes the page for copy review: every string that sits |
| 197 | // in its own element carries data-copy="<id>". |
| 198 | func RenderPreview(rs []b.AppResult, copy Copy, w io.Writer) error { return render(rs, copy, w, true) } |
| 199 | |
| 200 | func render(rs []b.AppResult, copy Copy, w io.Writer, preview bool) error { |
| 201 | return page.Execute(w, struct { |
| 202 | Results []b.AppResult |
| 203 | Charts []Chart |
| 204 | Copy Copy |
| 205 | Preview bool |
| 206 | }{rs, charts(rs, copy), copy, preview}) |
| 207 | } |
| 208 | |