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

Commit 92a785d

Browse files
authored
show system wide code issue/claim chart (#2372)
* show system wide code issue/claim chart * add missing return * enforce min number of realms contributing to a stats day * enforce min min * lint
1 parent 4867794 commit 92a785d

File tree

8 files changed

+356
-49
lines changed

8 files changed

+356
-49
lines changed

assets/server/admin/realms/index.html

+17
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<html dir="{{$.textDirection}}" lang="{{$.textLanguage}}">
99
<head>
1010
{{template "head" .}}
11+
<script defer src="https://www.gstatic.com/charts/loader.js"></script>
1112
</head>
1213

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

20+
<div class="card shadow-sm mb-3">
21+
<div class="card-header">
22+
<i class="bi bi-graph-up me-2"></i>
23+
System wide verification code statistics
24+
</div>
25+
<div id="dashboard_div">
26+
<div id="system_chart_div" class="h-100 w-100" style="min-height:325px;">
27+
<p class="text-center font-italic w-100 mt-5">Loading chart...</p>
28+
</div>
29+
<div id="filter_div" class="text-end" style="height: 75px;"></div>
30+
</div>
31+
<small class="card-footer d-flex justify-content-between text-muted">
32+
<p>An individual day requires data from at least <em>{{.minRealms}}</em> realms to be shown.</p>
33+
</small>
34+
</div>
35+
1936
<div class="card mb-3 shadow-sm">
2037
<div class="card-header">
2138
<i class="bi bi-house-door me-2"></i>
+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
(() => {
2+
window.addEventListener('load', async (event) => {
3+
const containerChart = document.querySelector('div#system_chart_div');
4+
if (!containerChart) {
5+
return;
6+
}
7+
8+
google.charts.load('current', {
9+
packages: ['corechart', 'controls'],
10+
callback: drawCharts,
11+
});
12+
13+
function drawCharts() {
14+
const request = new XMLHttpRequest();
15+
request.open('GET', '/admin/stats/system.json');
16+
request.overrideMimeType('application/json');
17+
18+
request.onload = (event) => {
19+
const data = JSON.parse(request.response);
20+
drawSystemCodeChart(data);
21+
};
22+
23+
request.onerror = (event) => {
24+
console.error('error from response: ' + request.response);
25+
flash.error('Failed to load realm stats: ' + err);
26+
};
27+
28+
request.send();
29+
}
30+
31+
function drawSystemCodeChart(data) {
32+
const charts = [
33+
{
34+
chartType: 'LineChart',
35+
chartDiv: '#system_chart_div',
36+
dashboardDiv: '#dashboard_div',
37+
filterDiv: '#filter_div',
38+
headerFunc: (dataTable, hasKeyServerStats) => {
39+
dataTable.addColumn('date', 'Date');
40+
dataTable.addColumn('number', 'Codes Issued');
41+
dataTable.addColumn('number', 'Codes Claimed');
42+
},
43+
rowFunc: (dataTable, row, hasKeyServerStats) => {
44+
if (hasKeyServerStats) {
45+
dataTable.addRow([
46+
utcDate(row.date),
47+
row.data.codes_issued,
48+
row.data.codes_claimed,
49+
]);
50+
} else {
51+
dataTable.addRow([
52+
utcDate(row.date),
53+
row.data.codes_issued,
54+
row.data.codes_claimed,
55+
]);
56+
}
57+
},
58+
},
59+
];
60+
61+
const dateFormatter = new google.visualization.DateFormat({
62+
pattern: 'MMM dd',
63+
});
64+
65+
for (let i = 0; i < charts.length; i++) {
66+
const chart = charts[i];
67+
const chartContainer = document.querySelector(chart.chartDiv);
68+
const dashboardContainer = document.querySelector(chart.dashboardDiv);
69+
const filterContainer = document.querySelector(chart.filterDiv);
70+
71+
if (!chartContainer || !dashboardContainer || !filterContainer) {
72+
continue;
73+
}
74+
75+
if (!data || !data.statistics) {
76+
const pContainer = chartContainer.querySelector('p');
77+
pContainer.innerText = 'No data yet.';
78+
continue;
79+
}
80+
81+
const hasKeyServerStats = data.has_key_server_stats;
82+
const win = Math.min(30, data.statistics.length - 1);
83+
const startChart = new Date(data.statistics[win].date);
84+
85+
const dataTable = new google.visualization.DataTable();
86+
chart.headerFunc(dataTable, hasKeyServerStats);
87+
for (let j = 0; j < data.statistics.length; j++) {
88+
const stat = data.statistics[j];
89+
chart.rowFunc(dataTable, stat, hasKeyServerStats);
90+
}
91+
dateFormatter.format(dataTable, 0);
92+
93+
const dashboard = new google.visualization.Dashboard(dashboardContainer);
94+
const filter = new google.visualization.ControlWrapper({
95+
controlType: 'ChartRangeFilter',
96+
containerId: filterContainer,
97+
state: {
98+
range: {
99+
start: startChart,
100+
},
101+
},
102+
options: {
103+
filterColumnIndex: 0,
104+
series: {
105+
0: {
106+
opacity: 0,
107+
},
108+
},
109+
ui: {
110+
chartType: 'LineChart',
111+
chartOptions: {
112+
colors: ['#dddddd'],
113+
chartArea: {
114+
width: '100%',
115+
height: '100%',
116+
top: 0,
117+
right: 40,
118+
bottom: 20,
119+
left: 60,
120+
},
121+
isStacked: true,
122+
hAxis: { format: 'M/d' },
123+
},
124+
chartView: {
125+
columns: [0, 1],
126+
},
127+
minRangeSize: 86400000, // ms for 1 day
128+
},
129+
},
130+
});
131+
132+
const realmChart = new google.visualization.ChartWrapper({
133+
chartType: chart.chartType,
134+
containerId: chartContainer,
135+
options: {
136+
colors: ['#007bff', '#28a745', '#dc3545', '#6c757d', '#ee8c00'],
137+
chartArea: {
138+
left: 60,
139+
right: 40,
140+
bottom: 5,
141+
top: 40,
142+
width: '100%',
143+
height: '300',
144+
},
145+
isStacked: true,
146+
hAxis: { textPosition: 'none' },
147+
legend: { position: 'top' },
148+
width: '100%',
149+
},
150+
});
151+
152+
dashboard.bind(filter, realmChart);
153+
dashboard.draw(dataTable);
154+
debounce('resize', async () => dashboard.draw(dataTable));
155+
}
156+
}
157+
});
158+
})();
159+

internal/routes/server.go

+2
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,8 @@ func systemAdminRoutes(r *mux.Router, c *admin.Controller) {
514514
r.Handle("", http.RedirectHandler("/admin/realms", http.StatusSeeOther)).Methods(http.MethodGet)
515515
r.Handle("/", http.RedirectHandler("/admin/realms", http.StatusSeeOther)).Methods(http.MethodGet)
516516

517+
r.Handle("/stats/system.json", c.HandleCodeStats()).Methods(http.MethodGet)
518+
517519
r.Handle("/realms", c.HandleRealmsIndex()).Methods(http.MethodGet)
518520
r.Handle("/realms", c.HandleRealmsCreate()).Methods(http.MethodPost)
519521
r.Handle("/realms/new", c.HandleRealmsCreate()).Methods(http.MethodGet)

pkg/config/server_config.go

+8
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ type ServerConfig struct {
100100
// If MaintenanceMode is true, the server is temporarily read-only and will not issue codes.
101101
MaintenanceMode bool `env:"MAINTENANCE_MODE"`
102102

103+
// MinRealmsForSystemStatistics gives a minimum threshold for displaying system
104+
// admin level statistics
105+
MinRealmsForSystemStatistics uint `env:"MIN_REALMS_FOR_SYSTEM_STATS, default=2"`
106+
103107
// Rate limiting configuration
104108
RateLimit ratelimit.Config
105109
}
@@ -165,6 +169,10 @@ func (c *ServerConfig) Validate() error {
165169
return fmt.Errorf("failed to validate issue API configuration: %w", err)
166170
}
167171

172+
if c.MinRealmsForSystemStatistics < 2 {
173+
return fmt.Errorf("MIN_REALMS_FOR_SYSTEM_STATS cannot be set lower than 2")
174+
}
175+
168176
return nil
169177
}
170178

pkg/controller/admin/code_stats.go

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2022 the Exposure Notifications Verification Server authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package admin
16+
17+
import (
18+
"net/http"
19+
20+
"github.com/google/exposure-notifications-verification-server/pkg/controller"
21+
)
22+
23+
// HandleCodeStats returns the code issue / claimed
24+
// rates across all realms.
25+
func (c *Controller) HandleCodeStats() http.Handler {
26+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
ctx := r.Context()
28+
29+
stats, err := c.db.AllRealmCodeStatsCached(ctx, c.cacher, int(c.config.MinRealmsForSystemStatistics))
30+
if err != nil {
31+
controller.InternalError(w, r, c.h, err)
32+
return
33+
}
34+
35+
c.h.RenderJSON(w, http.StatusOK, stats)
36+
})
37+
}

pkg/controller/admin/realms.go

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func (c *Controller) HandleRealmsIndex() http.Handler {
6969
m["memberships"] = membershipsMap
7070
m["query"] = q
7171
m["paginator"] = paginator
72+
m["minRealms"] = c.config.MinRealmsForSystemStatistics
7273
c.h.RenderHTML(w, "admin/realms/index", m)
7374
})
7475
}

pkg/database/realm.go

+73
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,79 @@ func (r *Realm) Stats(db *Database) (RealmStats, error) {
19221922
return stats, nil
19231923
}
19241924

1925+
// AllRealmCodeStatsCached returns combined code issue / claimed stats for all realms
1926+
// in the system, but cached.
1927+
func (db *Database) AllRealmCodeStatsCached(ctx context.Context, cacher cache.Cacher, requiredRealms int) (RealmStats, error) {
1928+
if cacher == nil {
1929+
return nil, fmt.Errorf("cacher cannot be nil")
1930+
}
1931+
1932+
var stats RealmStats
1933+
cacheKey := &cache.Key{
1934+
Namespace: "stats:system",
1935+
Key: fmt.Sprintf("all:min-%d", requiredRealms),
1936+
}
1937+
if err := cacher.Fetch(ctx, cacheKey, &stats, 30*time.Minute, func() (interface{}, error) {
1938+
return db.AllRealmCodeStats(ctx, requiredRealms)
1939+
}); err != nil {
1940+
return nil, err
1941+
}
1942+
1943+
return stats, nil
1944+
}
1945+
1946+
// AllRealmCodeStats returns the usage statistics for all realms.
1947+
// This reuses the RealmStats data structure and the realm_id is the COUNT
1948+
// of all realms that contributed to statistics on that day, so that filtering
1949+
// can be done if there are not enough data points.
1950+
func (db *Database) AllRealmCodeStats(ctx context.Context, requiredRealms int) (RealmStats, error) {
1951+
stop := timeutils.UTCMidnight(time.Now())
1952+
start := stop.Add(project.StatsDisplayDays * -24 * time.Hour)
1953+
if start.After(stop) {
1954+
return nil, ErrBadDateRange
1955+
}
1956+
1957+
sql := `
1958+
SELECT
1959+
d.date AS date,
1960+
COUNT(DISTINCT(realm_id)) as realm_id,
1961+
SUM(s.codes_issued) AS codes_issued,
1962+
SUM(s.codes_claimed) AS codes_claimed,
1963+
0 AS codes_invalid,
1964+
0 AS user_reports_issued,
1965+
0 AS user_reports_claimed,
1966+
0 AS tokens_claimed,
1967+
0 AS tokens_invalid,
1968+
0 AS user_report_tokens_claimed,
1969+
array[]::integer[] AS code_claim_age_distribution,
1970+
0 AS code_claim_mean_age,
1971+
array[0,0,0]::bigint[] AS codes_invalid_by_os,
1972+
0 AS user_reports_invalid_nonce
1973+
FROM (
1974+
SELECT date::date FROM generate_series($1, $2, '1 day'::interval) date
1975+
) d
1976+
LEFT JOIN realm_stats s ON s.date = d.date
1977+
GROUP BY d.date
1978+
ORDER BY date DESC`
1979+
1980+
var stats []*RealmStat
1981+
if err := db.db.Raw(sql, start, stop).Scan(&stats).Error; err != nil {
1982+
if IsNotFound(err) {
1983+
return stats, nil
1984+
}
1985+
return nil, err
1986+
}
1987+
1988+
for _, s := range stats {
1989+
if s.RealmID < uint(requiredRealms) {
1990+
s.CodesIssued = 0
1991+
s.CodesClaimed = 0
1992+
}
1993+
}
1994+
1995+
return stats, nil
1996+
}
1997+
19251998
// StatsCached is stats, but cached.
19261999
func (r *Realm) StatsCached(ctx context.Context, db *Database, cacher cache.Cacher) (RealmStats, error) {
19272000
if cacher == nil {

0 commit comments

Comments
 (0)