github.com/google/syzkaller@v0.0.0-20240517125934-c0f1611a36d6/dashboard/app/graphs.go (about)

     1  // Copyright 2020 syzkaller project authors. All rights reserved.
     2  // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
     3  
     4  package main
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"net/http"
    10  	"net/url"
    11  	"regexp"
    12  	"sort"
    13  	"strconv"
    14  	"time"
    15  
    16  	db "google.golang.org/appengine/v2/datastore"
    17  )
    18  
    19  type uiKernelHealthPage struct {
    20  	Header *uiHeader
    21  	Graph  *uiGraph
    22  }
    23  
    24  type uiBugLifetimesPage struct {
    25  	Header    *uiHeader
    26  	Lifetimes []uiBugLifetime
    27  }
    28  
    29  type uiBugLifetime struct {
    30  	Reported     time.Time
    31  	Fixed        float32
    32  	Fixed1y      float32
    33  	NotFixed     float32
    34  	Introduced   float32
    35  	Introduced1y float32
    36  }
    37  
    38  type uiManagersPage struct {
    39  	Header   *uiHeader
    40  	Managers *uiCheckbox
    41  	Metrics  *uiCheckbox
    42  	Months   *uiSlider
    43  	Graph    *uiGraph
    44  }
    45  
    46  type uiCrashesPage struct {
    47  	Header      *uiHeader
    48  	Graph       *uiGraph
    49  	Regexps     *uiMultiInput
    50  	GraphMonths *uiSlider
    51  	TableDays   *uiSlider
    52  	Table       *uiCrashPageTable
    53  }
    54  
    55  type uiGraph struct {
    56  	Headers []string
    57  	Columns []uiGraphColumn
    58  }
    59  
    60  type uiGraphColumn struct {
    61  	Hint string
    62  	Vals []uiGraphValue
    63  }
    64  
    65  type uiGraphValue struct {
    66  	Val    float32
    67  	IsNull bool
    68  	Hint   string
    69  }
    70  
    71  type uiCrashPageTable struct {
    72  	Title string
    73  	Rows  []*uiCrashSummary
    74  }
    75  
    76  type uiCrashSummary struct {
    77  	Title     string
    78  	Link      string
    79  	Count     int
    80  	Share     float32
    81  	GraphLink string
    82  }
    83  
    84  type uiCheckbox struct {
    85  	ID      string
    86  	Caption string
    87  	Values  []*uiCheckboxValue
    88  	vals    []string
    89  }
    90  
    91  type uiCheckboxValue struct {
    92  	ID       string
    93  	Caption  string
    94  	Selected bool
    95  }
    96  
    97  type uiSlider struct {
    98  	ID      string
    99  	Caption string
   100  	Val     int
   101  	Min     int
   102  	Max     int
   103  }
   104  
   105  type uiMultiInput struct {
   106  	ID      string
   107  	Caption string
   108  	Vals    []string
   109  }
   110  
   111  // nolint: dupl
   112  func handleKernelHealthGraph(c context.Context, w http.ResponseWriter, r *http.Request) error {
   113  	hdr, err := commonHeader(c, r, w, "")
   114  	if err != nil {
   115  		return err
   116  	}
   117  	bugs, err := loadGraphBugs(c, hdr.Namespace)
   118  	if err != nil {
   119  		return err
   120  	}
   121  	data := &uiKernelHealthPage{
   122  		Header: hdr,
   123  		Graph:  createBugsGraph(c, bugs),
   124  	}
   125  	return serveTemplate(w, "graph_bugs.html", data)
   126  }
   127  
   128  // nolint: dupl
   129  func handleGraphLifetimes(c context.Context, w http.ResponseWriter, r *http.Request) error {
   130  	hdr, err := commonHeader(c, r, w, "")
   131  	if err != nil {
   132  		return err
   133  	}
   134  	bugs, err := loadGraphBugs(c, hdr.Namespace)
   135  	if err != nil {
   136  		return err
   137  	}
   138  
   139  	var jobs []*Job
   140  	keys, err := db.NewQuery("Job").
   141  		Filter("Namespace=", hdr.Namespace).
   142  		Filter("Type=", JobBisectCause).
   143  		GetAll(c, &jobs)
   144  	if err != nil {
   145  		return err
   146  	}
   147  	causeBisects := make(map[string]*Job)
   148  	for i, job := range jobs {
   149  		if len(job.Commits) != 1 {
   150  			continue
   151  		}
   152  		causeBisects[keys[i].Parent().StringID()] = job
   153  	}
   154  	data := &uiBugLifetimesPage{
   155  		Header:    hdr,
   156  		Lifetimes: createBugLifetimes(c, bugs, causeBisects),
   157  	}
   158  	return serveTemplate(w, "graph_lifetimes.html", data)
   159  }
   160  
   161  func loadGraphBugs(c context.Context, ns string) ([]*Bug, error) {
   162  	filter := func(query *db.Query) *db.Query {
   163  		return query.Filter("Namespace=", ns)
   164  	}
   165  	bugs, _, err := loadAllBugs(c, filter)
   166  	if err != nil {
   167  		return nil, err
   168  	}
   169  	n := 0
   170  	fixes := make(map[string]bool)
   171  	lastReporting := getNsConfig(c, ns).lastActiveReporting()
   172  	for _, bug := range bugs {
   173  		if bug.Reporting[lastReporting].Reported.IsZero() {
   174  			if bug.Status == BugStatusOpen {
   175  				// These bugs are not released yet.
   176  				continue
   177  			}
   178  			bugReporting := lastReportedReporting(bug)
   179  			if bugReporting == nil || bugReporting.Auto && bug.Status == BugStatusInvalid {
   180  				// These bugs were auto-obsoleted before getting released.
   181  				continue
   182  			}
   183  		}
   184  		dup := false
   185  		for _, com := range bug.Commits {
   186  			if fixes[com] {
   187  				dup = true
   188  			}
   189  			fixes[com] = true
   190  		}
   191  		if dup {
   192  			continue
   193  		}
   194  		bugs[n] = bug
   195  		n++
   196  	}
   197  	return bugs[:n], nil
   198  }
   199  
   200  func createBugsGraph(c context.Context, bugs []*Bug) *uiGraph {
   201  	type BugStats struct {
   202  		Opened        int
   203  		Fixed         int
   204  		Closed        int
   205  		TotalReported int
   206  		TotalOpen     int
   207  		TotalFixed    int
   208  		TotalClosed   int
   209  	}
   210  	const timeWeek = 30 * 24 * time.Hour
   211  	now := timeNow(c)
   212  	m := make(map[int]*BugStats)
   213  	maxWeek := 0
   214  	bugStatsFor := func(t time.Time) *BugStats {
   215  		week := int(now.Sub(t) / (30 * 24 * time.Hour))
   216  		if week < 0 {
   217  			week = 0
   218  		}
   219  		if maxWeek < week {
   220  			maxWeek = week
   221  		}
   222  		bs := m[week]
   223  		if bs == nil {
   224  			bs = new(BugStats)
   225  			m[week] = bs
   226  		}
   227  		return bs
   228  	}
   229  	for _, bug := range bugs {
   230  		bugStatsFor(bug.FirstTime).Opened++
   231  		if !bug.Closed.IsZero() {
   232  			if bug.Status == BugStatusFixed {
   233  				bugStatsFor(bug.Closed).Fixed++
   234  			}
   235  			bugStatsFor(bug.Closed).Closed++
   236  		} else if len(bug.Commits) != 0 {
   237  			bugStatsFor(now).Fixed++
   238  			bugStatsFor(now).Closed++
   239  		}
   240  	}
   241  	var stats []BugStats
   242  	var prev BugStats
   243  	for i := maxWeek; i >= 0; i-- {
   244  		var bs BugStats
   245  		if p := m[i]; p != nil {
   246  			bs = *p
   247  		}
   248  		bs.TotalReported = prev.TotalReported + bs.Opened
   249  		bs.TotalFixed = prev.TotalFixed + bs.Fixed
   250  		bs.TotalClosed = prev.TotalClosed + bs.Closed
   251  		bs.TotalOpen = bs.TotalReported - bs.TotalClosed
   252  		stats = append(stats, bs)
   253  		prev = bs
   254  	}
   255  	var columns []uiGraphColumn
   256  	for week, bs := range stats {
   257  		col := uiGraphColumn{Hint: now.Add(time.Duration(week-len(stats)+1) * timeWeek).Format("Jan-06")}
   258  		col.Vals = append(col.Vals, uiGraphValue{Val: float32(bs.TotalOpen)})
   259  		col.Vals = append(col.Vals, uiGraphValue{Val: float32(bs.TotalReported)})
   260  		col.Vals = append(col.Vals, uiGraphValue{Val: float32(bs.TotalFixed)})
   261  		// col.Vals = append(col.Vals, uiGraphValue{Val: float32(bs.Opened)})
   262  		// col.Vals = append(col.Vals, uiGraphValue{Val: float32(bs.Fixed)})
   263  		columns = append(columns, col)
   264  	}
   265  	return &uiGraph{
   266  		Headers: []string{"open bugs", "total reported", "total fixed"},
   267  		Columns: columns,
   268  	}
   269  }
   270  
   271  func createBugLifetimes(c context.Context, bugs []*Bug, causeBisects map[string]*Job) []uiBugLifetime {
   272  	var res []uiBugLifetime
   273  	for i, bug := range bugs {
   274  		ui := uiBugLifetime{
   275  			// TODO: this is not the time when it was reported to the final reporting.
   276  			Reported: bug.FirstTime,
   277  		}
   278  		if bug.Status >= BugStatusInvalid {
   279  			continue
   280  		}
   281  		fixed := bug.FixTime
   282  		if fixed.IsZero() || bug.Status == BugStatusFixed && bug.Closed.Before(fixed) {
   283  			fixed = bug.Closed
   284  		}
   285  		if !fixed.IsZero() {
   286  			days := float32(fixed.Sub(ui.Reported)) / float32(24*time.Hour)
   287  			if days > 365 {
   288  				ui.Fixed1y = 365 + float32(i%7)
   289  			} else {
   290  				if days <= 0 {
   291  					days = 0.1
   292  				}
   293  				ui.Fixed = days
   294  			}
   295  		} else {
   296  			ui.NotFixed = 400 - float32(i%7)
   297  		}
   298  		if job := causeBisects[bug.keyHash(c)]; job != nil {
   299  			days := float32(job.Commits[0].Date.Sub(ui.Reported)) / float32(24*time.Hour)
   300  			if days < -365 {
   301  				ui.Introduced1y = -365 - float32(i%7)
   302  			} else {
   303  				if days >= 0 {
   304  					days = -0.1
   305  				}
   306  				ui.Introduced = days
   307  			}
   308  		}
   309  		res = append(res, ui)
   310  	}
   311  	return res
   312  }
   313  
   314  func handleGraphFuzzing(c context.Context, w http.ResponseWriter, r *http.Request) error {
   315  	hdr, err := commonHeader(c, r, w, "")
   316  	if err != nil {
   317  		return err
   318  	}
   319  	r.ParseForm()
   320  
   321  	allManagers, err := managerList(c, hdr.Namespace)
   322  	if err != nil {
   323  		return err
   324  	}
   325  	managers, err := createCheckBox(r, "Instances", allManagers)
   326  	if err != nil {
   327  		return err
   328  	}
   329  	metrics, err := createCheckBox(r, "Metrics", []string{
   330  		"MaxCorpus", "MaxCover", "MaxPCs", "TotalFuzzingTime",
   331  		"TotalCrashes", "CrashTypes", "SuppressedCrashes", "TotalExecs",
   332  		"ExecsPerSec", "TriagedPCs", "TriagedCoverage"})
   333  	if err != nil {
   334  		return err
   335  	}
   336  	data := &uiManagersPage{
   337  		Header:   hdr,
   338  		Managers: managers,
   339  		Metrics:  metrics,
   340  		Months:   createSlider(r, "Months", 1, 36),
   341  	}
   342  	data.Graph, err = createManagersGraph(c, hdr.Namespace, data.Managers.vals, data.Metrics.vals, data.Months.Val*30)
   343  	if err != nil {
   344  		return err
   345  	}
   346  	return serveTemplate(w, "graph_fuzzing.html", data)
   347  }
   348  
   349  func createManagersGraph(c context.Context, ns string, selManagers, selMetrics []string, days int) (*uiGraph, error) {
   350  	graph := &uiGraph{}
   351  	for _, mgr := range selManagers {
   352  		for _, metric := range selMetrics {
   353  			graph.Headers = append(graph.Headers, mgr+"-"+metric)
   354  		}
   355  	}
   356  	now := timeNow(c)
   357  	const day = 24 * time.Hour
   358  	// Step 1: fill the whole table with empty values to simplify subsequent logic
   359  	// when we fill random positions in the table.
   360  	for date := 0; date <= days; date++ {
   361  		col := uiGraphColumn{Hint: now.Add(time.Duration(date-days) * day).Format("02-01-2006")}
   362  		for range selManagers {
   363  			for range selMetrics {
   364  				col.Vals = append(col.Vals, uiGraphValue{Hint: "-"})
   365  			}
   366  		}
   367  		graph.Columns = append(graph.Columns, col)
   368  	}
   369  	// Step 2: fill in actual data.
   370  	for mgrIndex, mgr := range selManagers {
   371  		parentKey := mgrKey(c, ns, mgr)
   372  		var stats []*ManagerStats
   373  		_, err := db.NewQuery("ManagerStats").
   374  			Ancestor(parentKey).
   375  			GetAll(c, &stats)
   376  		if err != nil {
   377  			return nil, err
   378  		}
   379  		for _, stat := range stats {
   380  			dayIndex := days - int(now.Sub(dateTime(stat.Date))/day)
   381  			if dayIndex < 0 || dayIndex > days {
   382  				continue
   383  			}
   384  			for metricIndex, metric := range selMetrics {
   385  				val, canBeZero := extractMetric(stat, metric)
   386  				graph.Columns[dayIndex].Vals[mgrIndex*len(selMetrics)+metricIndex] = uiGraphValue{
   387  					Val:    float32(val),
   388  					IsNull: !canBeZero && val == 0,
   389  					Hint:   fmt.Sprintf("%.2f", val),
   390  				}
   391  			}
   392  		}
   393  	}
   394  	// Step 3: normalize data to [0..100] range.
   395  	// We visualize radically different values and they all should fit into a single graph.
   396  	// We normalize the same metric across all managers so that a single metric is still
   397  	// comparable across different managers.
   398  	if len(selMetrics) > 1 {
   399  		for metricIndex := range selMetrics {
   400  			max := float32(1)
   401  			for col := range graph.Columns {
   402  				for mgrIndex := range selManagers {
   403  					item := graph.Columns[col].Vals[mgrIndex*len(selMetrics)+metricIndex]
   404  					if item.IsNull {
   405  						continue
   406  					}
   407  					val := item.Val
   408  					if max < val {
   409  						max = val
   410  					}
   411  				}
   412  			}
   413  			for col := range graph.Columns {
   414  				for mgrIndex := range selManagers {
   415  					graph.Columns[col].Vals[mgrIndex*len(selMetrics)+metricIndex].Val /= max * 100
   416  				}
   417  			}
   418  		}
   419  	}
   420  	return graph, nil
   421  }
   422  
   423  func extractMetric(stat *ManagerStats, metric string) (val float64, canBeZero bool) {
   424  	switch metric {
   425  	case "MaxCorpus":
   426  		return float64(stat.MaxCorpus), false
   427  	case "MaxCover":
   428  		return float64(stat.MaxCover), false
   429  	case "MaxPCs":
   430  		return float64(stat.MaxPCs), false
   431  	case "TotalFuzzingTime":
   432  		return float64(stat.TotalFuzzingTime), true
   433  	case "TotalCrashes":
   434  		return float64(stat.TotalCrashes), true
   435  	case "CrashTypes":
   436  		return float64(stat.CrashTypes), true
   437  	case "SuppressedCrashes":
   438  		return float64(stat.SuppressedCrashes), true
   439  	case "TotalExecs":
   440  		return float64(stat.TotalExecs), true
   441  	case "ExecsPerSec":
   442  		timeSec := float64(stat.TotalFuzzingTime) / 1e9
   443  		if timeSec == 0 {
   444  			return 0, true
   445  		}
   446  		return float64(stat.TotalExecs) / timeSec, true
   447  	case "TriagedCoverage":
   448  		return float64(stat.TriagedCoverage), false
   449  	case "TriagedPCs":
   450  		return float64(stat.TriagedPCs), false
   451  	default:
   452  		panic(fmt.Sprintf("unknown metric %q", metric))
   453  	}
   454  }
   455  
   456  // createCheckBox additionally validates r.Form data and returns 400 error in case of mismatch.
   457  func createCheckBox(r *http.Request, caption string, values []string) (*uiCheckbox, error) {
   458  	// TODO: turn this into proper ID that can be used in HTML.
   459  	id := caption
   460  	for _, formVal := range r.Form[id] {
   461  		if !stringInList(values, formVal) {
   462  			return nil, ErrClientBadRequest
   463  		}
   464  	}
   465  	ui := &uiCheckbox{
   466  		ID:      id,
   467  		Caption: caption,
   468  		vals:    r.Form[id],
   469  	}
   470  	if len(ui.vals) == 0 {
   471  		ui.vals = []string{values[0]}
   472  	}
   473  	for _, val := range values {
   474  		ui.Values = append(ui.Values, &uiCheckboxValue{
   475  			ID: val,
   476  			// TODO: use this as caption and form ID.
   477  			Selected: stringInList(ui.vals, val),
   478  		})
   479  	}
   480  	return ui, nil
   481  }
   482  
   483  func createSlider(r *http.Request, caption string, min, max int) *uiSlider {
   484  	// TODO: turn this into proper ID that can be used in HTML.
   485  	id := caption
   486  	ui := &uiSlider{
   487  		ID:      id,
   488  		Caption: caption,
   489  		Val:     min,
   490  		Min:     min,
   491  		Max:     max,
   492  	}
   493  	if val, _ := strconv.Atoi(r.FormValue(id)); val >= min && val <= max {
   494  		ui.Val = val
   495  	}
   496  	return ui
   497  }
   498  
   499  func createMultiInput(r *http.Request, id, caption string) *uiMultiInput {
   500  	filteredValues := []string{}
   501  	for _, val := range r.Form[id] {
   502  		if val == "" {
   503  			continue
   504  		}
   505  		filteredValues = append(filteredValues, val)
   506  	}
   507  	return &uiMultiInput{
   508  		ID:      id,
   509  		Caption: caption,
   510  		Vals:    filteredValues,
   511  	}
   512  }
   513  
   514  func handleGraphCrashes(c context.Context, w http.ResponseWriter, r *http.Request) error {
   515  	hdr, err := commonHeader(c, r, w, "")
   516  	if err != nil {
   517  		return err
   518  	}
   519  	r.ParseForm()
   520  
   521  	data := &uiCrashesPage{
   522  		Header:      hdr,
   523  		Regexps:     createMultiInput(r, "regexp", "Regexps"),
   524  		GraphMonths: createSlider(r, "Months", 1, 36),
   525  		TableDays:   createSlider(r, "Days", 1, 30),
   526  	}
   527  
   528  	bugs, _, err := loadAllBugs(c, func(query *db.Query) *db.Query {
   529  		return query.Filter("Namespace=", hdr.Namespace)
   530  	})
   531  	if err != nil {
   532  		return err
   533  	}
   534  	accessLevel := accessLevel(c, r)
   535  	nbugs := 0
   536  	for _, bug := range bugs {
   537  		if accessLevel < bug.sanitizeAccess(c, accessLevel) {
   538  			continue
   539  		}
   540  		bugs[nbugs] = bug
   541  		nbugs++
   542  	}
   543  	bugs = bugs[:nbugs]
   544  	if len(data.Regexps.Vals) == 0 {
   545  		// If no data is passed, then at least show the graph for important crash types.
   546  		data.Regexps.Vals = []string{"^KASAN", "^KMSAN", "^KCSAN", "^SYZFAIL"}
   547  	}
   548  	if r.Form["show-graph"] != nil {
   549  		data.Graph, err = createCrashesGraph(c, hdr.Namespace, data.Regexps.Vals, data.GraphMonths.Val*30, bugs)
   550  		if err != nil {
   551  			return err
   552  		}
   553  	} else {
   554  		data.Table = createCrashesTable(c, hdr.Namespace, data.TableDays.Val, bugs)
   555  	}
   556  	return serveTemplate(w, "graph_crashes.html", data)
   557  }
   558  
   559  func createCrashesTable(c context.Context, ns string, days int, bugs []*Bug) *uiCrashPageTable {
   560  	const dayDuration = 24 * time.Hour
   561  	startDay := timeNow(c).Add(time.Duration(-days+1) * dayDuration)
   562  	table := &uiCrashPageTable{
   563  		Title: fmt.Sprintf("Top crashers of the last %d day(s)", days),
   564  	}
   565  	totalCount := 0
   566  	for _, bug := range bugs {
   567  		count := 0
   568  		for _, s := range bug.dailyStatsTail(startDay) {
   569  			count += s.CrashCount
   570  		}
   571  		if count == 0 {
   572  			continue
   573  		}
   574  		totalCount += count
   575  		titleRegexp := regexp.QuoteMeta(bug.Title)
   576  		table.Rows = append(table.Rows, &uiCrashSummary{
   577  			Title:     bug.Title,
   578  			Link:      bugLink(bug.keyHash(c)),
   579  			GraphLink: "?show-graph=1&Months=1&regexp=" + url.QueryEscape(titleRegexp),
   580  			Count:     count,
   581  		})
   582  	}
   583  	for id := range table.Rows {
   584  		table.Rows[id].Share = float32(table.Rows[id].Count) / float32(totalCount) * 100.0
   585  	}
   586  	// Order by descending crash count.
   587  	sort.SliceStable(table.Rows, func(i, j int) bool {
   588  		return table.Rows[i].Count > table.Rows[j].Count
   589  	})
   590  	return table
   591  }
   592  
   593  func createCrashesGraph(c context.Context, ns string, regexps []string, days int, bugs []*Bug) (*uiGraph, error) {
   594  	const dayDuration = 24 * time.Hour
   595  	graph := &uiGraph{Headers: regexps}
   596  	startDay := timeNow(c).Add(time.Duration(-days) * dayDuration)
   597  	// Step 1: fill the whole table with empty values.
   598  	dateToIdx := make(map[int]int)
   599  	for date := 0; date <= days; date++ {
   600  		day := startDay.Add(time.Duration(date) * dayDuration)
   601  		dateToIdx[timeDate(day)] = date
   602  		col := uiGraphColumn{Hint: day.Format("02-01-2006")}
   603  		for range regexps {
   604  			col.Vals = append(col.Vals, uiGraphValue{Hint: "-"})
   605  		}
   606  		graph.Columns = append(graph.Columns, col)
   607  	}
   608  	// Step 2: fill in crash counts.
   609  	totalCounts := make(map[int]int)
   610  	for _, bug := range bugs {
   611  		for _, stat := range bug.dailyStatsTail(startDay) {
   612  			pos, ok := dateToIdx[stat.Date]
   613  			if !ok {
   614  				continue
   615  			}
   616  			totalCounts[pos] += stat.CrashCount
   617  		}
   618  	}
   619  	for regexpID, val := range regexps {
   620  		r, err := regexp.Compile(val)
   621  		if err != nil {
   622  			return nil, err
   623  		}
   624  		for _, bug := range bugs {
   625  			if !r.MatchString(bug.Title) {
   626  				continue
   627  			}
   628  			for _, stat := range bug.DailyStats {
   629  				pos, ok := dateToIdx[stat.Date]
   630  				if !ok {
   631  					continue
   632  				}
   633  				graph.Columns[pos].Vals[regexpID].Val += float32(stat.CrashCount)
   634  			}
   635  		}
   636  	}
   637  	// Step 3: convert abs values to percents.
   638  	for date := 0; date <= days; date++ {
   639  		count := totalCounts[date]
   640  		if count == 0 {
   641  			continue
   642  		}
   643  		for id := range graph.Columns[date].Vals {
   644  			value := &graph.Columns[date].Vals[id]
   645  			abs := value.Val
   646  			value.Val = abs / float32(count) * 100.0
   647  			value.Hint = fmt.Sprintf("%.1f%% (%.0f)", value.Val, abs)
   648  		}
   649  	}
   650  	return graph, nil
   651  }