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

show system wide code issue/claim chart #2372

Merged
merged 5 commits into from
Aug 26, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions assets/server/admin/realms/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<html dir="{{$.textDirection}}" lang="{{$.textLanguage}}">
<head>
{{template "head" .}}
<script defer src="https://www.gstatic.com/charts/loader.js"></script>
</head>

<body id="admin-realms-index" class="tab-content">
Expand All @@ -16,6 +17,22 @@
<main role="main" class="container">
{{template "flash" .}}

<div class="card shadow-sm mb-3">
<div class="card-header">
<i class="bi bi-graph-up me-2"></i>
System wide verification code statistics
</div>
<div id="dashboard_div">
<div id="system_chart_div" class="h-100 w-100" style="min-height:325px;">
<p class="text-center font-italic w-100 mt-5">Loading chart...</p>
</div>
<div id="filter_div" class="text-end" style="height: 75px;"></div>
</div>
<small class="card-footer d-flex justify-content-between text-muted">
<p>An individual day requires data from at least <em>{{.minRealms}}</em> realms to be shown.</p>
</small>
</div>

<div class="card mb-3 shadow-sm">
<div class="card-header">
<i class="bi bi-house-door me-2"></i>
Expand Down
159 changes: 159 additions & 0 deletions assets/server/static/js/admin-stats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
(() => {
window.addEventListener('load', async (event) => {
const containerChart = document.querySelector('div#system_chart_div');
if (!containerChart) {
return;
}

google.charts.load('current', {
packages: ['corechart', 'controls'],
callback: drawCharts,
});

function drawCharts() {
const request = new XMLHttpRequest();
request.open('GET', '/admin/stats/system.json');
request.overrideMimeType('application/json');

request.onload = (event) => {
const data = JSON.parse(request.response);
drawSystemCodeChart(data);
};

request.onerror = (event) => {
console.error('error from response: ' + request.response);
flash.error('Failed to load realm stats: ' + err);
};

request.send();
}

function drawSystemCodeChart(data) {
const charts = [
{
chartType: 'LineChart',
chartDiv: '#system_chart_div',
dashboardDiv: '#dashboard_div',
filterDiv: '#filter_div',
headerFunc: (dataTable, hasKeyServerStats) => {
dataTable.addColumn('date', 'Date');
dataTable.addColumn('number', 'Codes Issued');
dataTable.addColumn('number', 'Codes Claimed');
},
rowFunc: (dataTable, row, hasKeyServerStats) => {
if (hasKeyServerStats) {
dataTable.addRow([
utcDate(row.date),
row.data.codes_issued,
row.data.codes_claimed,
]);
} else {
dataTable.addRow([
utcDate(row.date),
row.data.codes_issued,
row.data.codes_claimed,
]);
}
},
},
];

const dateFormatter = new google.visualization.DateFormat({
pattern: 'MMM dd',
});

for (let i = 0; i < charts.length; i++) {
const chart = charts[i];
const chartContainer = document.querySelector(chart.chartDiv);
const dashboardContainer = document.querySelector(chart.dashboardDiv);
const filterContainer = document.querySelector(chart.filterDiv);

if (!chartContainer || !dashboardContainer || !filterContainer) {
continue;
}

if (!data || !data.statistics) {
const pContainer = chartContainer.querySelector('p');
pContainer.innerText = 'No data yet.';
continue;
}

const hasKeyServerStats = data.has_key_server_stats;
const win = Math.min(30, data.statistics.length - 1);
const startChart = new Date(data.statistics[win].date);

const dataTable = new google.visualization.DataTable();
chart.headerFunc(dataTable, hasKeyServerStats);
for (let j = 0; j < data.statistics.length; j++) {
const stat = data.statistics[j];
chart.rowFunc(dataTable, stat, hasKeyServerStats);
}
dateFormatter.format(dataTable, 0);

const dashboard = new google.visualization.Dashboard(dashboardContainer);
const filter = new google.visualization.ControlWrapper({
controlType: 'ChartRangeFilter',
containerId: filterContainer,
state: {
range: {
start: startChart,
},
},
options: {
filterColumnIndex: 0,
series: {
0: {
opacity: 0,
},
},
ui: {
chartType: 'LineChart',
chartOptions: {
colors: ['#dddddd'],
chartArea: {
width: '100%',
height: '100%',
top: 0,
right: 40,
bottom: 20,
left: 60,
},
isStacked: true,
hAxis: { format: 'M/d' },
},
chartView: {
columns: [0, 1],
},
minRangeSize: 86400000, // ms for 1 day
},
},
});

const realmChart = new google.visualization.ChartWrapper({
chartType: chart.chartType,
containerId: chartContainer,
options: {
colors: ['#007bff', '#28a745', '#dc3545', '#6c757d', '#ee8c00'],
chartArea: {
left: 60,
right: 40,
bottom: 5,
top: 40,
width: '100%',
height: '300',
},
isStacked: true,
hAxis: { textPosition: 'none' },
legend: { position: 'top' },
width: '100%',
},
});

dashboard.bind(filter, realmChart);
dashboard.draw(dataTable);
debounce('resize', async () => dashboard.draw(dataTable));
}
}
});
})();

2 changes: 2 additions & 0 deletions internal/routes/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,8 @@ func systemAdminRoutes(r *mux.Router, c *admin.Controller) {
r.Handle("", http.RedirectHandler("/admin/realms", http.StatusSeeOther)).Methods(http.MethodGet)
r.Handle("/", http.RedirectHandler("/admin/realms", http.StatusSeeOther)).Methods(http.MethodGet)

r.Handle("/stats/system.json", c.HandleCodeStats()).Methods(http.MethodGet)

r.Handle("/realms", c.HandleRealmsIndex()).Methods(http.MethodGet)
r.Handle("/realms", c.HandleRealmsCreate()).Methods(http.MethodPost)
r.Handle("/realms/new", c.HandleRealmsCreate()).Methods(http.MethodGet)
Expand Down
4 changes: 4 additions & 0 deletions pkg/config/server_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ type ServerConfig struct {
// If MaintenanceMode is true, the server is temporarily read-only and will not issue codes.
MaintenanceMode bool `env:"MAINTENANCE_MODE"`

// MinRealmsForSystemStatistics gives a minimum threshold for displaying system
// admin level statistics
MinRealmsForSystemStatistics uint `env:"MIN_REALMS_FOR_SYSTEM_STATS, default=2"`

// Rate limiting configuration
RateLimit ratelimit.Config
}
Expand Down
23 changes: 23 additions & 0 deletions pkg/controller/admin/code_stats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package admin

import (
"net/http"

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

// HandleCodeStats returns the code issue / claimed
// rates across all realms.
func (c *Controller) HandleCodeStats() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

stats, err := c.db.AllRealmCodeStatsCached(ctx, c.cacher, int(c.config.MinRealmsForSystemStatistics))
if err != nil {
controller.InternalError(w, r, c.h, err)
return
}

c.h.RenderJSON(w, http.StatusOK, stats)
})
}
1 change: 1 addition & 0 deletions pkg/controller/admin/realms.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func (c *Controller) HandleRealmsIndex() http.Handler {
m["memberships"] = membershipsMap
m["query"] = q
m["paginator"] = paginator
m["minRealms"] = c.config.MinRealmsForSystemStatistics
c.h.RenderHTML(w, "admin/realms/index", m)
})
}
Expand Down
73 changes: 73 additions & 0 deletions pkg/database/realm.go
Original file line number Diff line number Diff line change
Expand Up @@ -1922,6 +1922,79 @@ func (r *Realm) Stats(db *Database) (RealmStats, error) {
return stats, nil
}

// AllRealmCodeStatsCached returns combined code issue / claimed stats for all realms
// in the system, but cached.
func (db *Database) AllRealmCodeStatsCached(ctx context.Context, cacher cache.Cacher, requiredRealms int) (RealmStats, error) {
if cacher == nil {
return nil, fmt.Errorf("cacher cannot be nil")
}

var stats RealmStats
cacheKey := &cache.Key{
Namespace: "stats:system",
Key: fmt.Sprintf("all:min-%d", requiredRealms),
}
if err := cacher.Fetch(ctx, cacheKey, &stats, 30*time.Minute, func() (interface{}, error) {
return db.AllRealmCodeStats(ctx, requiredRealms)
}); err != nil {
return nil, err
}

return stats, nil
}

// AllRealmCodeStats returns the usage statistics for all realms.
// This reuses the RealmStats data structure and the realm_id is the COUNT
// of all realms that contributed to statistics on that day, so that filtering
// can be done if there are not enough data points.
func (db *Database) AllRealmCodeStats(ctx context.Context, requiredRealms int) (RealmStats, error) {
stop := timeutils.UTCMidnight(time.Now())
start := stop.Add(project.StatsDisplayDays * -24 * time.Hour)
if start.After(stop) {
return nil, ErrBadDateRange
}

sql := `
SELECT
d.date AS date,
COUNT(DISTINCT(realm_id)) as realm_id,
SUM(s.codes_issued) AS codes_issued,
SUM(s.codes_claimed) AS codes_claimed,
0 AS codes_invalid,
0 AS user_reports_issued,
0 AS user_reports_claimed,
0 AS tokens_claimed,
0 AS tokens_invalid,
0 AS user_report_tokens_claimed,
array[]::integer[] AS code_claim_age_distribution,
0 AS code_claim_mean_age,
array[0,0,0]::bigint[] AS codes_invalid_by_os,
0 AS user_reports_invalid_nonce
FROM (
SELECT date::date FROM generate_series($1, $2, '1 day'::interval) date
) d
LEFT JOIN realm_stats s ON s.date = d.date
GROUP BY d.date
ORDER BY date DESC`

var stats []*RealmStat
if err := db.db.Raw(sql, start, stop).Scan(&stats).Error; err != nil {
if IsNotFound(err) {
return stats, nil
}
return nil, err
}

for _, s := range stats {
if s.RealmID < uint(requiredRealms) {
s.CodesIssued = 0
s.CodesClaimed = 0
}
}

return stats, nil
}

// StatsCached is stats, but cached.
func (r *Realm) StatsCached(ctx context.Context, db *Database, cacher cache.Cacher) (RealmStats, error) {
if cacher == nil {
Expand Down
Loading