Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Commit

Permalink
Add a per-realm-per-user chart on the stats page.
Browse files Browse the repository at this point in the history
Fixes #799
  • Loading branch information
jeremyfaller committed Oct 14, 2020
1 parent 7757e0a commit 435cbc3
Show file tree
Hide file tree
Showing 6 changed files with 271 additions and 10 deletions.
53 changes: 49 additions & 4 deletions cmd/server/assets/realmadmin/show.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

{{$realm := .realm}}
{{$stats := .stats}}
{{$names := .names}}
{{$userStats := .userStats}}

<!doctype html>
<html lang="en">
Expand All @@ -24,7 +26,7 @@ <h1>Realm stats</h1>
<div class="card-header">Statistics</div>
<div class="card-body table-responsive">
{{if $stats}}
<div id="chart" class="mb-3" style="height: 300px;">
<div id="realm_chart" class="mb-3" style="height: 300px;">
<span>Loading chart...</span>
</div>
<table class="table table-bordered table-striped">
Expand All @@ -45,6 +47,15 @@ <h1>Realm stats</h1>
{{end}}
</tbody>
</table>
{{end}}

{{if $userStats}}
<div id="per_user_chart" class="mb-3" style="height: 300px;">
<span>Loading chart...</span>
</div>
{{end}}

{{if or $stats $userStats}}
<div class="font-italic">
This data is refreshed every 5 minutes.
</div>
Expand All @@ -58,13 +69,18 @@ <h1>Realm stats</h1>

{{template "scripts" .}}

{{if $stats}}
<script src="https://www.gstatic.com/charts/loader.js"></script>
<script>
google.charts.load('current', {packages: ['line']});
google.charts.setOnLoadCallback(drawChart)

function drawChart() {
drawRealmChart();
drawUsersChart();
}

function drawRealmChart() {
{{if $stats}}
var data = google.visualization.arrayToDataTable([
['Date', 'Issued', "Claimed"],
{{range $stat := $stats}}
Expand All @@ -78,11 +94,40 @@ <h1>Realm stats</h1>
tooltip: {trigger: 'focus'},
};

var chart = new google.charts.Line(document.getElementById('chart'));
var chart = new google.charts.Line(document.getElementById('realm_chart'));
chart.draw(data, google.charts.Line.convertOptions(options));
{{end}}
}
</script>

function fixDate(arr) {
arr[0] = new Date(arr[0])
return arr
}

function drawUsersChart() {
{{if $userStats}}
var data = new google.visualization.DataTable();

data.addColumn('date', 'Day');
{{range $name := $names}}
data.addColumn('number', '{{$name}}');
{{end}}
data.addRows([
{{range $stat := $userStats}}
fixDate({{$stat}}),
{{end}}
]);

var options = {
legend: {position: 'bottom'},
tooltip: {trigger: 'focus'},
};

var chart = new google.charts.Line(document.getElementById('per_user_chart'));
chart.draw(data, google.charts.Line.convertOptions(options));
{{end}}
}
</script>
</body>
</html>
{{end}}
64 changes: 58 additions & 6 deletions pkg/controller/realmadmin/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,77 @@ func (c *Controller) HandleShow() http.Handler {
return
}

// Get and cache the stats for this user.
now := time.Now().UTC()
past := now.Add(-30 * 24 * time.Hour)
timeout := 5 * time.Minute

// Get and cache the stats for this realm.
var stats []*database.RealmStats
cacheKey := fmt.Sprintf("stats:realm:%d", realm.ID)
if err := c.cacher.Fetch(ctx, cacheKey, &stats, 5*time.Minute, func() (interface{}, error) {
now := time.Now().UTC()
past := now.Add(-30 * 24 * time.Hour)
if err := c.cacher.Fetch(ctx, cacheKey, &stats, timeout, func() (interface{}, error) {
return realm.Stats(c.db, past, now)
}); err != nil {
controller.InternalError(w, r, c.h, err)
return
}

c.renderShow(ctx, w, realm, stats)
// Also get the per-user stats.
var userStats []*database.RealmUserStats
cacheKey = fmt.Sprintf("userstats:realm:%d", realm.ID)
if err := c.cacher.Fetch(ctx, cacheKey, &userStats, timeout, func() (interface{}, error) {
return realm.CodesPerUser(c.db, past, now)
}); err != nil {
controller.InternalError(w, r, c.h, err)
return
}

c.renderShow(ctx, w, realm, stats, userStats)
})
}

func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, realm *database.Realm, stats []*database.RealmStats) {
// formatData formats a slice of RealmUserStats into a format more conducive
// to charting in Javascript.
func formatData(userStats []*database.RealmUserStats) ([]string, [][]interface{}) {
// We need to format the per-user-per-day data properly for the charts.
// Create some LUTs to make this easier.
nameLUT := make(map[string]int)
datesLUT := make(map[time.Time]int)
for _, stat := range userStats {
if _, ok := nameLUT[stat.Name]; !ok {
nameLUT[stat.Name] = len(nameLUT)
}
if _, ok := datesLUT[stat.Date]; !ok {
datesLUT[stat.Date] = len(datesLUT)
}
}

// Figure out the names.
names := make([]string, len(nameLUT))
for name, i := range nameLUT {
names[i] = name
}

// And combine up the data we want to send as well.
data := make([][]interface{}, len(datesLUT))
for date, i := range datesLUT {
data[i] = make([]interface{}, len(names)+1)
data[i][0] = date.Format("Jan 2")
}
for _, stat := range userStats {
i := datesLUT[stat.Date]
data[i][nameLUT[stat.Name]+1] = stat.CodesIssued
}

// Now, we need to format the data properly.
return names, data
}

func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, realm *database.Realm, stats []*database.RealmStats, userStats []*database.RealmUserStats) {
names, format := formatData(userStats)
m := controller.TemplateMapFromContext(ctx)
m["user"] = realm
m["stats"] = stats
m["names"] = names
m["userStats"] = format
c.h.RenderHTML(w, "realmadmin/show", m)
}
55 changes: 55 additions & 0 deletions pkg/controller/realmadmin/show_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package realmadmin

import (
"reflect"
"sort"
"testing"
"time"

"github.com/google/exposure-notifications-verification-server/pkg/database"
)

func TestFormatStats(t *testing.T) {
now := time.Now().Truncate(24 * time.Hour)
yesterday := now.Add(-24 * time.Hour).Truncate(24 * time.Hour)
tests := []struct {
data []*database.RealmUserStats
names []string
numDays int
}{
{[]*database.RealmUserStats{}, []string{}, 0},
{
[]*database.RealmUserStats{
{1, "Rocky", 10, now},
{1, "Bullwinkle", 1, now},
},
[]string{"Rocky", "Bullwinkle"},
1,
},
{
[]*database.RealmUserStats{
{1, "Rocky", 10, yesterday},
{1, "Rocky", 10, now},
},
[]string{"Rocky"},
2,
},
}

for i, test := range tests {
names, format := formatData(test.data)
sort.Strings(test.names)
sort.Strings(names)
if !reflect.DeepEqual(test.names, names) {
t.Errorf("[%d] %v != %v", i, names, test.names)
}
if len(format) != test.numDays {
t.Errorf("[%d] len(format) = %d, expected %d", i, len(format), test.numDays)
}
for _, f := range format {
if len(f) != len(test.names)+1 {
t.Errorf("[%d] len(codesIssued) = %d, expected %d", i, len(f), len(test.names)+1)
}
}
}
}
37 changes: 37 additions & 0 deletions pkg/database/realm.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func (t TestType) Display() string {

var (
ErrNoSigningKeyManagement = errors.New("no signing key management")
ErrBadDateRange = errors.New("bad date range")
)

const (
Expand Down Expand Up @@ -1159,3 +1160,39 @@ func ToCIDRList(s string) ([]string, error) {
sort.Strings(cidrs)
return cidrs, nil
}

// RealmUserStats carries the per-user-per-day-per-realm Codes issued.
// This is a structure joined from multiple tables in the DB.
type RealmUserStats struct {
UserID uint
Name string
CodesIssued uint
Date time.Time
}

// CodesPerUser returns a set of UserStats for a given date range.
func (r *Realm) CodesPerUser(db *Database, start, stop time.Time) ([]*RealmUserStats, error) {
start = start.Truncate(time.Hour * 24)
stop = stop.Truncate(time.Hour * 24)
if start.After(stop) {
return nil, ErrBadDateRange
}

var stats []*RealmUserStats
if err := db.db.
Model(&UserStats{}).
Select("users.id, users.name, codes_issued, date").
Where("realm_id = ?", r.ID).
Where("date >= ? AND date <= ?", start, stop).
Joins("INNNER JOIN users ON users.id = user_id").
Order("date ASC").
Scan(&stats).
Error; err != nil {
if IsNotFound(err) {
fmt.Println("NOT FOUND")
return stats, nil
}
return nil, err
}
return stats, nil
}
59 changes: 59 additions & 0 deletions pkg/database/realm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package database

import (
"testing"
"time"
)

func TestSMS(t *testing.T) {
Expand All @@ -36,3 +37,61 @@ func TestSMS(t *testing.T) {
t.Errorf("SMS text wrong, want: %q got %q", want, got)
}
}

func TestPerUserRealmStats(t *testing.T) {
t.Parallel()

db := NewTestDatabase(t)

numDays := 7
endDate := time.Now().Truncate(24 * time.Hour)
startDate := endDate.Add(time.Duration(numDays) * -24 * time.Hour).Truncate(24 * time.Hour)

// Create a new realm
realm := NewRealmWithDefaults("test")
if err := db.SaveRealm(realm, System); err != nil {
t.Fatalf("error saving realm: %v", err)
}

// Create the users.
users := []*User{}
for userIdx, name := range []string{"Rocky", "Bullwinkle", "Boris", "Natasha"} {
user := &User{
Realms: []*Realm{realm},
Name: name,
Email: name + "@gmail.com",
Admin: false,
}

if err := db.SaveUser(user, System); err != nil {
t.Fatalf("[%v] error creating user: %v", name, err)
}
users = append(users, user)

// Add some stats per user.
for i := 0; i < numDays; i++ {
stat := &UserStats{
RealmID: realm.ID,
UserID: user.ID,
Date: startDate.Add(time.Duration(i) * 24 * time.Hour),
CodesIssued: uint(10 + i + userIdx),
}
if err := stat.Save(db); err != nil {
t.Fatalf("error saving user stats %v", err)
}
}
}

if len(users) == 0 { // sanity check
t.Error("len(users) = 0, expected ≠ 0")
}

stats, err := realm.CodesPerUser(db, startDate, endDate)
if err != nil {
t.Fatalf("error getting stats: %v", err)
}

if len(stats) != numDays*len(users) {
t.Errorf("len(stats) = %d, expected %d", len(stats), numDays*len(users))
}
}
13 changes: 13 additions & 0 deletions pkg/database/user_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ package database

import (
"time"

"github.com/jinzhu/gorm"
)

// UserStats represents statistics related to a user in the database.
Expand All @@ -30,3 +32,14 @@ type UserStats struct {
func (UserStats) TableName() string {
return "user_stats"
}

// Save saves some UserStats to the database.
// This function is provided for testing only.
func (u *UserStats) Save(db *Database) error {
return db.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(u).Error; err != nil {
return err
}
return nil
})
}

0 comments on commit 435cbc3

Please sign in to comment.