golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/relui/web.go (about)

     1  // Copyright 2020 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package relui
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"database/sql"
    11  	"encoding/json"
    12  	"errors"
    13  	"fmt"
    14  	"html/template"
    15  	"io"
    16  	"log"
    17  	"net/http"
    18  	"net/url"
    19  	"path"
    20  	"reflect"
    21  	"strconv"
    22  	"strings"
    23  	"time"
    24  
    25  	"github.com/google/uuid"
    26  	"github.com/jackc/pgx/v4"
    27  	"github.com/julienschmidt/httprouter"
    28  	"golang.org/x/build/internal/metrics"
    29  	"golang.org/x/build/internal/relui/db"
    30  	"golang.org/x/build/internal/task"
    31  	"golang.org/x/build/internal/workflow"
    32  	"golang.org/x/exp/slices"
    33  )
    34  
    35  const DatetimeLocalLayout = "2006-01-02T15:04"
    36  
    37  // SiteHeader configures the relui site header.
    38  type SiteHeader struct {
    39  	Title     string // Site title. For example, "Go Releases".
    40  	CSSClass  string // Site header CSS class name. Optional.
    41  	Subtitle  string
    42  	NameParam string
    43  }
    44  
    45  // Server implements the http handlers for relui.
    46  type Server struct {
    47  	db        db.PGDBTX
    48  	m         *metricsRouter
    49  	w         *Worker
    50  	scheduler *Scheduler
    51  	baseURL   *url.URL // nil means "/".
    52  	header    SiteHeader
    53  	// mux used if baseURL is set
    54  	bm *http.ServeMux
    55  
    56  	templates       *template.Template
    57  	homeTmpl        *template.Template
    58  	newWorkflowTmpl *template.Template
    59  }
    60  
    61  // NewServer initializes a server with the provided connection pool,
    62  // worker, base URL and site header.
    63  //
    64  // The base URL may be nil, which is the same as "/".
    65  func NewServer(p db.PGDBTX, w *Worker, baseURL *url.URL, header SiteHeader, ms *metrics.Service) *Server {
    66  	s := &Server{
    67  		db:        p,
    68  		m:         &metricsRouter{router: httprouter.New()},
    69  		w:         w,
    70  		scheduler: NewScheduler(p, w),
    71  		baseURL:   baseURL,
    72  		header:    header,
    73  	}
    74  	if err := s.scheduler.Resume(context.Background()); err != nil {
    75  		log.Fatalf("s.scheduler.Resume() = %v", err)
    76  	}
    77  	helpers := map[string]interface{}{
    78  		"allWorkflowsCount":     s.allWorkflowsCount,
    79  		"baseLink":              s.BaseLink,
    80  		"hasPrefix":             strings.HasPrefix,
    81  		"pathBase":              path.Base,
    82  		"prettySize":            prettySize,
    83  		"sidebarWorkflows":      s.sidebarWorkflows,
    84  		"unmarshalResultDetail": unmarshalResultDetail,
    85  	}
    86  	s.templates = template.Must(template.New("").Funcs(helpers).ParseFS(templates, "templates/*.html"))
    87  	s.homeTmpl = s.mustLookup("home.html")
    88  	s.newWorkflowTmpl = s.mustLookup("new_workflow.html")
    89  	s.m.GET("/workflows/:id", s.showWorkflowHandler)
    90  	s.m.POST("/workflows/:id/stop", s.stopWorkflowHandler)
    91  	s.m.POST("/workflows/:id/tasks/:name/retry", s.retryTaskHandler)
    92  	s.m.POST("/workflows/:id/tasks/:name/approve", s.approveTaskHandler)
    93  	s.m.POST("/schedules/:id/delete", s.deleteScheduleHandler)
    94  	s.m.Handler(http.MethodGet, "/metrics", ms)
    95  	s.m.Handler(http.MethodGet, "/new_workflow", http.HandlerFunc(s.newWorkflowHandler))
    96  	s.m.Handler(http.MethodPost, "/workflows", http.HandlerFunc(s.createWorkflowHandler))
    97  	s.m.ServeFiles("/static/*filepath", http.FS(static))
    98  	s.m.Handler(http.MethodGet, "/", http.HandlerFunc(s.homeHandler))
    99  	if baseURL != nil && baseURL.Path != "/" && baseURL.Path != "" {
   100  		nosuffix := strings.TrimSuffix(baseURL.Path, "/")
   101  		s.bm = new(http.ServeMux)
   102  		s.bm.Handle(nosuffix+"/", http.StripPrefix(nosuffix, s.m))
   103  		s.bm.Handle("/", s.m)
   104  	}
   105  	return s
   106  }
   107  
   108  func (s *Server) allWorkflowsCount() int64 {
   109  	count, err := db.New(s.db).WorkflowCount(context.Background())
   110  	if err != nil {
   111  		panic(fmt.Sprintf("allWorkflowsCount: %q", err))
   112  	}
   113  	return count
   114  }
   115  
   116  func (s *Server) sidebarWorkflows(nameParam string) []db.WorkflowSidebarRow {
   117  	sb, err := db.New(s.db).WorkflowSidebar(context.Background())
   118  	if err != nil {
   119  		panic(fmt.Sprintf("sidebarWorkflows: %q", err))
   120  	}
   121  	var filtered []db.WorkflowSidebarRow
   122  	others := db.WorkflowSidebarRow{Name: sql.NullString{String: "Others", Valid: true}}
   123  	for _, row := range sb {
   124  		if s.w.dh.Definition(row.Name.String) == nil {
   125  			others.Count += row.Count
   126  			continue
   127  		}
   128  		filtered = append(filtered, row)
   129  	}
   130  	filtered = append(filtered, others)
   131  	// Add a new row when on the newWorkflowsHandler if the workflow has never been run.
   132  	if s.w.dh.Definition(nameParam) != nil && slices.IndexFunc(filtered, func(row db.WorkflowSidebarRow) bool { return row.Name.String == nameParam }) == -1 {
   133  		filtered = append(filtered, db.WorkflowSidebarRow{Name: sql.NullString{String: nameParam, Valid: true}})
   134  	}
   135  	return filtered
   136  }
   137  
   138  func (s *Server) mustLookup(name string) *template.Template {
   139  	t := template.Must(template.Must(s.templates.Clone()).ParseFS(templates, path.Join("templates", name))).Lookup(name)
   140  	if t == nil {
   141  		panic(fmt.Errorf("template %q not found", name))
   142  	}
   143  	return t
   144  }
   145  
   146  func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   147  	if s.bm != nil {
   148  		s.bm.ServeHTTP(w, r)
   149  		return
   150  	}
   151  	s.m.ServeHTTP(w, r)
   152  }
   153  
   154  func BaseLink(baseURL *url.URL) func(target string, extras ...string) string {
   155  	return func(target string, extras ...string) string {
   156  		u, err := url.Parse(target)
   157  		if err != nil {
   158  			log.Printf("BaseLink: url.Parse(%q) = %v, %v", target, u, err)
   159  			return path.Join(append([]string{target}, extras...)...)
   160  		}
   161  		u.Path = path.Join(append([]string{u.Path}, extras...)...)
   162  		if baseURL == nil || u.IsAbs() {
   163  			return u.String()
   164  		}
   165  		u.Scheme = baseURL.Scheme
   166  		u.Host = baseURL.Host
   167  		u.Path = path.Join(baseURL.Path, u.Path)
   168  		return u.String()
   169  	}
   170  }
   171  
   172  func (s *Server) BaseLink(target string, extras ...string) string {
   173  	return BaseLink(s.baseURL)(target, extras...)
   174  }
   175  
   176  type homeResponse struct {
   177  	SiteHeader        SiteHeader
   178  	ActiveWorkflows   []db.Workflow
   179  	InactiveWorkflows []db.Workflow
   180  	Schedules         []ScheduleEntry
   181  }
   182  
   183  // homeHandler renders the homepage.
   184  func (s *Server) homeHandler(w http.ResponseWriter, r *http.Request) {
   185  	q := db.New(s.db)
   186  
   187  	names, err := q.WorkflowNames(r.Context())
   188  	if err != nil {
   189  		log.Printf("homeHandler: %v", err)
   190  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   191  		return
   192  	}
   193  	var others []string
   194  	for _, name := range names {
   195  		if s.w.dh.Definition(name) != nil {
   196  			continue
   197  		}
   198  		others = append(others, name)
   199  	}
   200  
   201  	name := r.URL.Query().Get("name")
   202  	hr := &homeResponse{SiteHeader: s.header}
   203  	hr.SiteHeader.NameParam = name
   204  	var ws []db.Workflow
   205  	switch name {
   206  	case "all", "All", "":
   207  		ws, err = q.Workflows(r.Context())
   208  		hr.SiteHeader.NameParam = "All Workflows"
   209  		hr.Schedules = s.scheduler.Entries()
   210  	case "others", "Others":
   211  		ws, err = q.WorkflowsByNames(r.Context(), others)
   212  		hr.SiteHeader.NameParam = "Others"
   213  		hr.Schedules = s.scheduler.Entries(others...)
   214  	default:
   215  		ws, err = q.WorkflowsByName(r.Context(), sql.NullString{String: name, Valid: true})
   216  		hr.Schedules = s.scheduler.Entries(name)
   217  	}
   218  	if err != nil {
   219  		log.Printf("homeHandler: %v", err)
   220  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   221  		return
   222  	}
   223  	for _, w := range ws {
   224  		if ok := s.w.workflowRunning(w.ID); ok {
   225  			hr.ActiveWorkflows = append(hr.ActiveWorkflows, w)
   226  			continue
   227  		}
   228  		hr.InactiveWorkflows = append(hr.InactiveWorkflows, w)
   229  	}
   230  	out := bytes.Buffer{}
   231  	if err := s.homeTmpl.Execute(&out, hr); err != nil {
   232  		log.Printf("homeHandler: %v", err)
   233  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   234  		return
   235  	}
   236  	io.Copy(w, &out)
   237  }
   238  
   239  type showWorkflowResponse struct {
   240  	SiteHeader SiteHeader
   241  	Workflow   db.Workflow
   242  	Tasks      []db.TasksForWorkflowSortedRow
   243  	// TaskLogs is a map of all logs for a db.Task, keyed on
   244  	// (db.Task).Name
   245  	TaskLogs map[string][]db.TaskLog
   246  }
   247  
   248  func (s *Server) showWorkflowHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
   249  	id, err := uuid.Parse(params.ByName("id"))
   250  	if err != nil {
   251  		log.Printf("showWorkflowHandler(_, _, %v) uuid.Parse(%v): %v", params, params.ByName("id"), err)
   252  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   253  		return
   254  	}
   255  	resp, err := s.buildShowWorkflowResponse(r.Context(), id)
   256  	if err != nil {
   257  		log.Printf("showWorkflowHandler: %v", err)
   258  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   259  		return
   260  	}
   261  	out := bytes.Buffer{}
   262  	if err := s.mustLookup("show_workflow.html").Execute(&out, resp); err != nil {
   263  		log.Printf("showWorkflowHandler: %v", err)
   264  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   265  		return
   266  	}
   267  	io.Copy(w, &out)
   268  }
   269  
   270  func (s *Server) buildShowWorkflowResponse(ctx context.Context, id uuid.UUID) (*showWorkflowResponse, error) {
   271  	q := db.New(s.db)
   272  	w, err := q.Workflow(ctx, id)
   273  	if err != nil {
   274  		return nil, err
   275  	}
   276  	tasks, err := q.TasksForWorkflowSorted(ctx, id)
   277  	if err != nil {
   278  		return nil, err
   279  	}
   280  	tlogs, err := q.TaskLogsForWorkflow(ctx, id)
   281  	if err != nil {
   282  		return nil, err
   283  	}
   284  	sr := &showWorkflowResponse{
   285  		SiteHeader: s.header,
   286  		TaskLogs:   make(map[string][]db.TaskLog),
   287  		Tasks:      tasks,
   288  		Workflow:   w,
   289  	}
   290  	sr.SiteHeader.Subtitle = w.Name.String
   291  	sr.SiteHeader.NameParam = w.Name.String
   292  	for _, l := range tlogs {
   293  		sr.TaskLogs[l.TaskName] = append(sr.TaskLogs[l.TaskName], l)
   294  	}
   295  	return sr, nil
   296  }
   297  
   298  type newWorkflowResponse struct {
   299  	SiteHeader      SiteHeader
   300  	Definitions     map[string]*workflow.Definition
   301  	Name            string
   302  	ScheduleTypes   []ScheduleType
   303  	Schedule        ScheduleType
   304  	ScheduleMinTime string
   305  }
   306  
   307  func (n *newWorkflowResponse) Selected() *workflow.Definition {
   308  	return n.Definitions[n.Name]
   309  }
   310  
   311  // newWorkflowHandler presents a form for creating a new workflow.
   312  func (s *Server) newWorkflowHandler(w http.ResponseWriter, r *http.Request) {
   313  	out := bytes.Buffer{}
   314  	name := r.FormValue("workflow.name")
   315  	resp := &newWorkflowResponse{
   316  		SiteHeader:      s.header,
   317  		Definitions:     s.w.dh.Definitions(),
   318  		Name:            name,
   319  		ScheduleTypes:   ScheduleTypes,
   320  		Schedule:        ScheduleImmediate,
   321  		ScheduleMinTime: time.Now().UTC().Format(DatetimeLocalLayout),
   322  	}
   323  	resp.SiteHeader.NameParam = name
   324  	selectedSchedule := ScheduleType(r.FormValue("workflow.schedule"))
   325  	if slices.Contains(ScheduleTypes, selectedSchedule) {
   326  		resp.Schedule = selectedSchedule
   327  	}
   328  	if err := s.newWorkflowTmpl.Execute(&out, resp); err != nil {
   329  		log.Printf("newWorkflowHandler: %v", err)
   330  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   331  		return
   332  	}
   333  	io.Copy(w, &out)
   334  }
   335  
   336  // createWorkflowHandler persists a new workflow in the datastore, and
   337  // starts the workflow in a goroutine.
   338  func (s *Server) createWorkflowHandler(w http.ResponseWriter, r *http.Request) {
   339  	name := r.FormValue("workflow.name")
   340  	d := s.w.dh.Definition(name)
   341  	if d == nil {
   342  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   343  		return
   344  	}
   345  	params := make(map[string]interface{})
   346  	for _, p := range d.Parameters() {
   347  		switch p.Type().String() {
   348  		case "string":
   349  			v := r.FormValue(fmt.Sprintf("workflow.params.%s", p.Name()))
   350  			if err := p.Valid(v); err != nil {
   351  				http.Error(w, err.Error(), http.StatusBadRequest)
   352  				return
   353  			}
   354  			params[p.Name()] = v
   355  		case "[]string":
   356  			v := r.Form[fmt.Sprintf("workflow.params.%s", p.Name())]
   357  			if err := p.Valid(v); err != nil {
   358  				http.Error(w, err.Error(), http.StatusBadRequest)
   359  				return
   360  			}
   361  			params[p.Name()] = v
   362  		case "task.Date":
   363  			t, err := time.Parse("2006-01-02", r.FormValue(fmt.Sprintf("workflow.params.%s", p.Name())))
   364  			if err != nil {
   365  				http.Error(w, fmt.Sprintf("parameter %q parsing error: %v", p.Name(), err), http.StatusBadRequest)
   366  				return
   367  			}
   368  			v := task.Date{Year: t.Year(), Month: t.Month(), Day: t.Day()}
   369  			if err := p.Valid(v); err != nil {
   370  				http.Error(w, err.Error(), http.StatusBadRequest)
   371  				return
   372  			}
   373  			params[p.Name()] = v
   374  		case "bool":
   375  			vStr := r.FormValue(fmt.Sprintf("workflow.params.%s", p.Name()))
   376  			var v bool
   377  			switch vStr {
   378  			case "on":
   379  				v = true
   380  			case "":
   381  				v = false
   382  			default:
   383  				http.Error(w, fmt.Sprintf("parameter %q has an unexpected value %q", p.Name(), vStr), http.StatusBadRequest)
   384  				return
   385  			}
   386  			if err := p.Valid(v); err != nil {
   387  				http.Error(w, err.Error(), http.StatusBadRequest)
   388  				return
   389  			}
   390  			params[p.Name()] = v
   391  		default:
   392  			http.Error(w, fmt.Sprintf("parameter %q has an unsupported type %q", p.Name(), p.Type()), http.StatusInternalServerError)
   393  			return
   394  		}
   395  	}
   396  	sched := Schedule{Type: ScheduleType(r.FormValue("workflow.schedule"))}
   397  	if sched.Type != ScheduleImmediate {
   398  		switch sched.Type {
   399  		case ScheduleOnce:
   400  			t, err := time.ParseInLocation(DatetimeLocalLayout, r.FormValue("workflow.schedule.datetime"), time.UTC)
   401  			if err != nil || t.Before(time.Now()) {
   402  				http.Error(w, fmt.Sprintf("parameter %q parsing error: %v", "workflow.schedule.datetime", err), http.StatusBadRequest)
   403  				return
   404  			}
   405  			sched.Once = t
   406  		case ScheduleCron:
   407  			sched.Cron = r.FormValue("workflow.schedule.cron")
   408  		}
   409  		if err := sched.Valid(); err != nil {
   410  			http.Error(w, fmt.Sprintf("parameter %q parsing error: %v", "workflow.schedule", err), http.StatusBadRequest)
   411  			return
   412  		}
   413  		if _, err := s.scheduler.Create(r.Context(), sched, name, params); err != nil {
   414  			http.Error(w, fmt.Sprintf("failed to create schedule: %v", err), http.StatusInternalServerError)
   415  			return
   416  		}
   417  		http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther)
   418  		return
   419  	}
   420  	id, err := s.w.StartWorkflow(r.Context(), name, params, 0)
   421  	if err != nil {
   422  		log.Printf("s.w.StartWorkflow(%v, %v, %v): %v", r.Context(), d, params, err)
   423  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   424  		return
   425  	}
   426  	http.Redirect(w, r, s.BaseLink("/workflows", id.String()), http.StatusSeeOther)
   427  }
   428  
   429  func (s *Server) retryTaskHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
   430  	id, err := uuid.Parse(params.ByName("id"))
   431  	if err != nil {
   432  		log.Printf("retryTaskHandler(_, _, %v) uuid.Parse(%v): %v", params, params.ByName("id"), err)
   433  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   434  		return
   435  	}
   436  	if err := s.w.RetryTask(r.Context(), id, params.ByName("name")); err != nil {
   437  		log.Printf("s.w.RetryTask(_, %q): %v", id, err)
   438  	}
   439  	http.Redirect(w, r, s.BaseLink("/workflows", id.String()), http.StatusSeeOther)
   440  }
   441  
   442  func (s *Server) approveTaskHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
   443  	id, err := uuid.Parse(params.ByName("id"))
   444  	if err != nil {
   445  		log.Printf("approveTaskHandler(_, _, %v) uuid.Parse(%v): %v", params, params.ByName("id"), err)
   446  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   447  		return
   448  	}
   449  	q := db.New(s.db)
   450  	t, err := q.ApproveTask(r.Context(), db.ApproveTaskParams{
   451  		WorkflowID: id,
   452  		Name:       params.ByName("name"),
   453  		ApprovedAt: sql.NullTime{Time: time.Now(), Valid: true},
   454  	})
   455  	if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
   456  		http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
   457  		return
   458  	} else if err != nil {
   459  		log.Printf("q.ApproveTask(_, %q) = %v, %v", id, t, err)
   460  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   461  		return
   462  	}
   463  	s.w.l.Logger(id, t.Name).Printf("USER-APPROVED")
   464  	http.Redirect(w, r, s.BaseLink("/workflows", id.String()), http.StatusSeeOther)
   465  }
   466  
   467  func (s *Server) stopWorkflowHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
   468  	id, err := uuid.Parse(params.ByName("id"))
   469  	if err != nil {
   470  		log.Printf("stopWorkflowHandler(_, _, %v) uuid.Parse(%v): %v", params, params.ByName("id"), err)
   471  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   472  		return
   473  	}
   474  	if !s.w.cancelWorkflow(id) {
   475  		http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
   476  		return
   477  	}
   478  	http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther)
   479  }
   480  
   481  func (s *Server) deleteScheduleHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
   482  	id, err := strconv.Atoi(params.ByName("id"))
   483  	if err != nil {
   484  		log.Printf("deleteScheduleHandler(_, _, %v) strconv.Atoi(%q) = %d, %v", params, params.ByName("id"), id, err)
   485  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   486  		return
   487  	}
   488  	err = s.scheduler.Delete(r.Context(), id)
   489  	if err == ErrScheduleNotFound {
   490  		http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
   491  		return
   492  	} else if err != nil {
   493  		log.Printf("deleteScheduleHandler(_, _, %v) s.scheduler.Delete(_, %d) = %v", params, id, err)
   494  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   495  		return
   496  	}
   497  
   498  	http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther)
   499  }
   500  
   501  // resultDetail contains unmarshalled results from a workflow task, or
   502  // workflow output. Only one field is expected to be populated.
   503  //
   504  // The UI implementation uses Kind to determine which result type to
   505  // render.
   506  type resultDetail struct {
   507  	Artifact artifact
   508  	Outputs  map[string]*resultDetail
   509  	JSON     map[string]interface{}
   510  	String   string
   511  	Number   float64
   512  	Slice    []*resultDetail
   513  	Boolean  bool
   514  	Unknown  interface{}
   515  }
   516  
   517  func (r *resultDetail) Kind() string {
   518  	v := reflect.ValueOf(r)
   519  	if v.IsZero() {
   520  		return ""
   521  	}
   522  	v = v.Elem()
   523  	for i := 0; i < v.NumField(); i++ {
   524  		if v.Field(i).IsZero() {
   525  			continue
   526  		}
   527  		return v.Type().Field(i).Name
   528  	}
   529  	return ""
   530  }
   531  
   532  func (r *resultDetail) UnmarshalJSON(result []byte) error {
   533  	v := reflect.ValueOf(r).Elem()
   534  	for i := 0; i < v.NumField(); i++ {
   535  		f := v.Field(i)
   536  		if err := json.Unmarshal(result, f.Addr().Interface()); err == nil {
   537  			if f.IsZero() {
   538  				continue
   539  			}
   540  			return nil
   541  		}
   542  	}
   543  	return errors.New("unknown result type")
   544  }
   545  
   546  func unmarshalResultDetail(result string) *resultDetail {
   547  	ret := new(resultDetail)
   548  	if err := json.Unmarshal([]byte(result), &ret); err != nil {
   549  		ret.String = err.Error()
   550  	}
   551  	return ret
   552  }
   553  
   554  func prettySize(size int) string {
   555  	const mb = 1 << 20
   556  	if size == 0 {
   557  		return ""
   558  	}
   559  	if size < mb {
   560  		// All Go releases are >1mb, but handle this case anyway.
   561  		return fmt.Sprintf("%v bytes", size)
   562  	}
   563  	return fmt.Sprintf("%.0fMiB", float64(size)/mb)
   564  }