Skip to content

Commit 212df67

Browse files
committed
syz-cluster: display statistics
Add a web dashboard page with the main statistics concerning patch series fuzzing. Improve the navigation bar on top of the page.
1 parent 22ec146 commit 212df67

File tree

5 files changed

+283
-8
lines changed

5 files changed

+283
-8
lines changed

syz-cluster/dashboard/handler.go

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type dashboardHandler struct {
2727
sessionRepo *db.SessionRepository
2828
sessionTestRepo *db.SessionTestRepository
2929
findingRepo *db.FindingRepository
30+
statsRepo *db.StatsRepository
3031
blobStorage blob.Storage
3132
templates map[string]*template.Template
3233
}
@@ -37,7 +38,7 @@ var templates embed.FS
3738
func newHandler(env *app.AppEnvironment) (*dashboardHandler, error) {
3839
perFile := map[string]*template.Template{}
3940
var err error
40-
for _, name := range []string{"index.html", "series.html"} {
41+
for _, name := range []string{"index.html", "series.html", "graphs.html"} {
4142
perFile[name], err = template.ParseFS(templates,
4243
"templates/base.html", "templates/templates.html", "templates/"+name)
4344
if err != nil {
@@ -53,6 +54,7 @@ func newHandler(env *app.AppEnvironment) (*dashboardHandler, error) {
5354
sessionRepo: db.NewSessionRepository(env.Spanner),
5455
sessionTestRepo: db.NewSessionTestRepository(env.Spanner),
5556
findingRepo: db.NewFindingRepository(env.Spanner),
57+
statsRepo: db.NewStatsRepository(env.Spanner),
5658
}, nil
5759
}
5860

@@ -69,6 +71,7 @@ func (h *dashboardHandler) Mux() *http.ServeMux {
6971
mux.HandleFunc("/patches/{id}", errToStatus(h.patchContent))
7072
mux.HandleFunc("/findings/{id}/{key}", errToStatus(h.findingInfo))
7173
mux.HandleFunc("/builds/{id}/{key}", errToStatus(h.buildInfo))
74+
mux.HandleFunc("/stats", errToStatus(h.statsPage))
7275
mux.HandleFunc("/", errToStatus(h.seriesList))
7376
staticFiles, err := fs.Sub(staticFs, "static")
7477
if err != nil {
@@ -211,6 +214,34 @@ func (h *dashboardHandler) seriesInfo(w http.ResponseWriter, r *http.Request) er
211214
return h.renderTemplate(w, "series.html", data)
212215
}
213216

217+
func (h *dashboardHandler) statsPage(w http.ResponseWriter, r *http.Request) error {
218+
type StatsPageData struct {
219+
Processed []*db.CountPerWeek
220+
Findings []*db.CountPerWeek
221+
Delay []*db.DelayPerWeek
222+
Distribution []*db.StatusPerWeek
223+
}
224+
var data StatsPageData
225+
var err error
226+
data.Processed, err = h.statsRepo.ProcessedSeriesPerWeek(r.Context())
227+
if err != nil {
228+
return fmt.Errorf("failed to query processed series data: %w", err)
229+
}
230+
data.Findings, err = h.statsRepo.FindingsPerWeek(r.Context())
231+
if err != nil {
232+
return fmt.Errorf("failed to query findings data: %w", err)
233+
}
234+
data.Delay, err = h.statsRepo.DelayPerWeek(r.Context())
235+
if err != nil {
236+
return fmt.Errorf("failed to query delay data: %w", err)
237+
}
238+
data.Distribution, err = h.statsRepo.SessionStatusPerWeek(r.Context())
239+
if err != nil {
240+
return fmt.Errorf("failed to query distribution data: %w", err)
241+
}
242+
return h.renderTemplate(w, "graphs.html", data)
243+
}
244+
214245
func groupFindings(findings []*db.Finding) map[string][]*db.Finding {
215246
ret := map[string][]*db.Finding{}
216247
for _, finding := range findings {
@@ -221,12 +252,14 @@ func groupFindings(findings []*db.Finding) map[string][]*db.Finding {
221252

222253
func (h *dashboardHandler) renderTemplate(w http.ResponseWriter, name string, data any) error {
223254
type page struct {
224-
Title string
225-
Data any
255+
Title string
256+
Template string
257+
Data any
226258
}
227259
return h.templates[name].ExecuteTemplate(w, "base.html", page{
228-
Title: h.title,
229-
Data: data,
260+
Title: h.title,
261+
Template: name,
262+
Data: data,
230263
})
231264
}
232265

syz-cluster/dashboard/templates/base.html

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
<title>{{.Title}}</title>
1010
</head>
1111
<body>
12-
<nav class="navbar navbar-light bg-light justify-content-start">
12+
<nav class="navbar navbar-light navbar-expand-lg bg-light justify-content-start">
1313
<a class="navbar-brand px-3" href="#">{{.Title}}</a>
14-
<div class="">
14+
<div class="collapse navbar-collapse">
1515
<ul class="navbar-nav">
16-
<li class="nav-item active">
16+
<li class="nav-item{{if eq .Template "index.html"}} active{{end}}">
1717
<a class="nav-link" href="/">All Series</a>
1818
</li>
19+
<li class="nav-item{{if eq .Template "graphs.html"}} active{{end}}">
20+
<a class="nav-link" href="/stats">Statistics</a>
21+
</li>
1922
</ul>
2023
</div>
2124
</nav>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
{{define "content"}}
2+
<main class="container-fluid my-4">
3+
<div class="chart-wrapper mb-5">
4+
<h2 class="h4">Processed Series (weekly)</h2>
5+
<p class="text-muted small">Click and drag to zoom into a time range. Right-click to reset.</p>
6+
<div id="processed_chart_div" style="width: 100%; height: 400px;"></div>
7+
</div>
8+
9+
<hr class="my-4">
10+
11+
<div class="chart-wrapper mb-4">
12+
<h2 class="h4">Findings (weekly)</h2>
13+
<p class="text-muted small">Click and drag to zoom into a time range. Right-click to reset.</p>
14+
<div id="findings_chart_div" style="width: 100%; height: 400px;"></div>
15+
</div>
16+
17+
<hr class="my-4">
18+
19+
<div class="chart-wrapper mb-4">
20+
<h2 class="h4">Wait Time before Fuzzing (avg hours, weekly)</h2>
21+
<p class="text-muted small">How many hours have passed since the moment the series was published and the moment we started fuzzing it.</p>
22+
<p class="text-muted small">Click and drag to zoom into a time range. Right-click to reset.</p>
23+
<div id="avg_wait_chart_div" style="width: 100%; height: 400px;"></div>
24+
</div>
25+
26+
<hr class="my-4">
27+
28+
<div class="chart-wrapper mb-4">
29+
<h2 class="h4">Status Distribution (weekly)</h2>
30+
<p class="text-muted small">Click and drag to zoom into a time range. Right-click to reset.</p>
31+
<div id="distribution_chart_div" style="width: 100%; height: 400px;"></div>
32+
</div>
33+
</main>
34+
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
35+
<script type="text/javascript">
36+
google.charts.load('current', {'packages':['corechart']});
37+
google.charts.setOnLoadCallback(drawAllCharts);
38+
39+
const processedData = [
40+
{{range .Processed}}
41+
[new Date({{.Date.Format "2006-01-02"}}), {{.Count}}],
42+
{{end}}
43+
];
44+
45+
const findingsData = [
46+
{{range .Findings}}
47+
[new Date({{.Date.Format "2006-01-02"}}), {{.Count}}],
48+
{{end}}
49+
];
50+
51+
const delayData = [
52+
{{range .Delay}}
53+
[new Date({{.Date.Format "2006-01-02"}}), {{.DelayHours}}],
54+
{{end}}
55+
];
56+
57+
const distributionData = [
58+
{{range .Distribution}}
59+
[new Date({{.Date.Format "2006-01-02"}}), {{.Finished}}, {{.Skipped}}],
60+
{{end}}
61+
];
62+
63+
function drawAllCharts() {
64+
drawChart('processed_chart_div', processedData, '#007bff');
65+
drawChart('findings_chart_div', findingsData, '#dc3545');
66+
drawChart('avg_wait_chart_div', delayData, 'black');
67+
drawDistributionChart();
68+
}
69+
70+
function drawChart(chartDivId, chartData, chartColor) {
71+
const data = new google.visualization.DataTable();
72+
data.addColumn('date', 'Date');
73+
data.addColumn('number', 'Count');
74+
data.addRows(chartData);
75+
const options = {
76+
legend: { position: 'none' },
77+
hAxis: { title: 'Date', format: 'MMM dd, yyyy', gridlines: { color: 'transparent' } },
78+
vAxis: { title: 'Count', minValue: 0 },
79+
colors: [chartColor],
80+
curveType: 'function',
81+
explorer: {
82+
actions: ['dragToZoom', 'rightClickToReset'],
83+
axis: 'horizontal',
84+
keepInBounds: true,
85+
maxZoomIn: 4.0
86+
}
87+
};
88+
89+
const chart = new google.visualization.LineChart(document.getElementById(chartDivId));
90+
chart.draw(data, options);
91+
}
92+
93+
function drawDistributionChart() {
94+
const data = new google.visualization.DataTable();
95+
data.addColumn('date', 'Date');
96+
data.addColumn('number', 'Processed');
97+
data.addColumn('number', 'Skipped');
98+
data.addRows(distributionData);
99+
const options = {
100+
isStacked: 'percent',
101+
legend: { position: 'top', maxLines: 2 },
102+
hAxis: { title: 'Date', format: 'MMM dd, yyyy', gridlines: { color: 'transparent' } },
103+
vAxis: {
104+
minValue: 0,
105+
format: '#%'
106+
},
107+
colors: ['#28a745', '#ffc107'],
108+
areaOpacity: 0.8
109+
};
110+
const chart = new google.visualization.AreaChart(document.getElementById('distribution_chart_div'));
111+
chart.draw(data, options);
112+
}
113+
</script>
114+
{{end}}

syz-cluster/pkg/db/stats_repo.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright 2025 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 db
5+
6+
import (
7+
"context"
8+
"time"
9+
10+
"cloud.google.com/go/spanner"
11+
)
12+
13+
type StatsRepository struct {
14+
client *spanner.Client
15+
}
16+
17+
func NewStatsRepository(client *spanner.Client) *StatsRepository {
18+
return &StatsRepository{
19+
client: client,
20+
}
21+
}
22+
23+
type CountPerWeek struct {
24+
Date time.Time `spanner:"Date"`
25+
Count int64 `spanner:"Count"`
26+
}
27+
28+
func (repo *StatsRepository) ProcessedSeriesPerWeek(ctx context.Context) (
29+
[]*CountPerWeek, error) {
30+
return readEntities[CountPerWeek](ctx, repo.client.Single(), spanner.Statement{
31+
SQL: `SELECT
32+
TIMESTAMP_TRUNC(Sessions.FinishedAt, WEEK) as Date,
33+
COUNT(*) as Count
34+
FROM Series
35+
JOIN Sessions ON Sessions.ID = Series.LatestSessionID
36+
WHERE FinishedAt IS NOT NULL
37+
GROUP BY Date
38+
ORDER BY Date`,
39+
})
40+
}
41+
42+
func (repo *StatsRepository) FindingsPerWeek(ctx context.Context) (
43+
[]*CountPerWeek, error) {
44+
return readEntities[CountPerWeek](ctx, repo.client.Single(), spanner.Statement{
45+
SQL: `SELECT
46+
TIMESTAMP_TRUNC(Sessions.FinishedAt, WEEK) as Date,
47+
COUNT(*) as Count
48+
FROM Findings
49+
JOIN Sessions ON Sessions.ID = Findings.SessionID
50+
GROUP BY Date
51+
ORDER BY Date`,
52+
})
53+
}
54+
55+
type StatusPerWeek struct {
56+
Date time.Time `spanner:"Date"`
57+
Finished int64 `spanner:"Finished"`
58+
Skipped int64 `spanner:"Skipped"`
59+
}
60+
61+
func (repo *StatsRepository) SessionStatusPerWeek(ctx context.Context) (
62+
[]*StatusPerWeek, error) {
63+
return readEntities[StatusPerWeek](ctx, repo.client.Single(), spanner.Statement{
64+
SQL: `SELECT
65+
TIMESTAMP_TRUNC(Sessions.FinishedAt, WEEK) as Date,
66+
COUNTIF(Sessions.SkipReason IS NULL) as Finished,
67+
COUNTIF(Sessions.SkipReason IS NOT NULL) as Skipped
68+
FROM Series
69+
JOIN Sessions ON Sessions.ID = Series.LatestSessionID
70+
WHERE FinishedAt IS NOT NULL
71+
GROUP BY Date
72+
ORDER BY Date`,
73+
})
74+
}
75+
76+
type DelayPerWeek struct {
77+
Date time.Time `spanner:"Date"`
78+
DelayHours float64 `spanner:"AvgDelayHours"`
79+
}
80+
81+
func (repo *StatsRepository) DelayPerWeek(ctx context.Context) (
82+
[]*DelayPerWeek, error) {
83+
return readEntities[DelayPerWeek](ctx, repo.client.Single(), spanner.Statement{
84+
SQL: `SELECT
85+
TIMESTAMP_TRUNC(Sessions.StartedAt, WEEK) as Date,
86+
AVG(TIMESTAMP_DIFF(Sessions.StartedAt,Sessions.CreatedAt, HOUR)) as AvgDelayHours
87+
FROM Sessions
88+
WHERE StartedAt IS NOT NULL
89+
GROUP BY Date
90+
ORDER BY Date`,
91+
})
92+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2025 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 db
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestStatsSQLs(t *testing.T) {
13+
// Ideally, there should be some proper tests, but for now let's at least
14+
// check that the SQL queries themselves have no errors.
15+
// That already brings a lot of value.
16+
client, ctx := NewTransientDB(t)
17+
18+
// Add some data to test field decoding as well.
19+
dtd := &dummyTestData{t, ctx, client}
20+
session := dtd.dummySession(dtd.dummySeries())
21+
dtd.startSession(session)
22+
dtd.finishSession(session)
23+
24+
statsRepo := NewStatsRepository(client)
25+
_, err := statsRepo.ProcessedSeriesPerWeek(ctx)
26+
assert.NoError(t, err)
27+
_, err = statsRepo.FindingsPerWeek(ctx)
28+
assert.NoError(t, err)
29+
_, err = statsRepo.SessionStatusPerWeek(ctx)
30+
assert.NoError(t, err)
31+
_, err = statsRepo.DelayPerWeek(ctx)
32+
assert.NoError(t, err)
33+
}

0 commit comments

Comments
 (0)