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

Commit

Permalink
Update API key stats too
Browse files Browse the repository at this point in the history
  • Loading branch information
sethvargo committed Dec 7, 2020
1 parent f1f0016 commit 2b79f9b
Show file tree
Hide file tree
Showing 14 changed files with 430 additions and 118 deletions.
160 changes: 100 additions & 60 deletions cmd/server/assets/apikeys/show.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@
{{template "flash" .}}

<h1>{{$authApp.Name}} API key</h1>
<p class="float-right">
<a href="/realm/apikeys/{{$authApp.ID}}/edit">Edit</a>
</p>
<p>
Here is information about the API key.
</p>
Expand All @@ -40,7 +37,13 @@ <h1>{{$authApp.Name}} API key</h1>
{{end}}

<div class="card mb-3 shadow-sm">
<div class="card-header">Details</div>
<div class="card-header">
<span class="oi oi-key mr-2 ml-n1" aria-hidden="true"></span>
Details about {{$authApp.Name}}
<a href="/realm/apikeys/{{$authApp.ID}}/edit" class="float-right mr-n1 text-body" id="edit" data-toggle="tooltip" title="Edit this API key">
<span class="oi oi-pencil" aria-hidden="true"></span>
</a>
</div>
<div class="card-body">
<strong>App name</strong>
<div class="mb-3">
Expand All @@ -61,73 +64,110 @@ <h1>{{$authApp.Name}} API key</h1>
</div>

<div class="card mb-3 shadow-sm">
<div class="card-header">Statistics</div>
<div class="card-body table-responsive">
{{if $stats}}
<div id="chart" class="mb-3" style="height: 300px;">
<span>Loading chart...</span>
</div>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th scope="col" width="125px">Date</th>
<th scope="col">Keys issued</th>
</tr>
</thead>
<tbody>
{{range $stat := $stats}}
<tr>
<td>{{$stat.Date.Format "2006-01-02"}}</td>
<td>{{$stat.CodesIssued}}</td>
</tr>
{{end}}
</tbody>
</table>
<div class="font-italic">
This data is refreshed every 5 minutes.
<div class="card-header">
<span class="oi oi-bar-chart mr-2 ml-n1"></span>
Statistics
</div>
<div class="card-body">
<div id="apikey_stats_chart" class="container d-flex h-100 w-100" style="min-height:300px;">
<p class="justify-content-center align-self-center text-center font-italic w-100">Loading chart...</p>
</div>
{{else}}
<p>This app has not recently issued any codes.</p>
{{end}}
</div>
<small class="card-footer d-flex justify-content-between text-muted">
<span>
This data is refreshed every 30 minutes.
<a href="#" data-toggle="modal" data-target="#apikey-stats-modal">Learn more</a>
</span>
<span>
<span class="mr-1">Export as:</span>
<a href="/realm/apikeys/{{$authApp.ID}}/stats.csv" class="mr-1">CSV</a>
<a href="/realm/apikeys/{{$authApp.ID}}/stats.json">JSON</a>
</span>
</small>
</div>

<div>
<p>
<a href="/realm/apikeys">&larr; Back to all API keys</a>
</p>
<div class="modal fade" id="apikey-stats-modal" data-backdrop="static" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Codes issued by {{$authApp.Name}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body mb-n3">
<p>
This chart reflects the number of codes issued by
{{$authApp.Name}}.
</p>
<p>
This chart does <u>not</u> include codes that were issued by users
via the UI or codes that were issued using a different API key.
</p>
</div>
</div>
</div>
</div>
</main>

{{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() {
let arr = [
{{range $stat := $stats}}
['{{$stat.Date.Format "Jan 2"}}', {{$stat.CodesIssued}}],
{{end}}
];

// Reverse the array, so the dates are in ascending order.
arr = arr.reverse();
arr.unshift(['Date', 'Codes issued']);
let data = google.visualization.arrayToDataTable(arr);

let options = {
colors: ['#007bff'],
legend: {position: 'none'},
tooltip: {trigger: 'focus'},
};

let chart = new google.charts.Line(document.getElementById('chart'));
chart.draw(data, google.charts.Line.convertOptions(options));
let userStatsChartDiv = document.getElementById('apikey_stats_chart');
let dateFormatter;

google.charts.load('current', {
packages: ['corechart'],
callback: function() {
dateFormatter = new google.visualization.DateFormat({
pattern: 'MMM dd',
});

drawStats();
},
});

function drawStats() {
$.ajax({
url: '/realm/apikeys/{{$authApp.ID}}/stats.json',
dataType: 'json',
})
.done(function(data, status, xhr) {
if (!data.statistics) {
$(userStatsChartDiv).find('p').text('This API key has not yet issued any codes.');
return;
}

var dataTable = new google.visualization.DataTable();
dataTable.addColumn('date', 'Date');
dataTable.addColumn('number', 'Issued');

data.statistics.reverse().forEach(function(row) {
dataTable.addRow([utcDate(row.date), row.data.codes_issued]);
});

dateFormatter.format(dataTable, 0);

let options = {
colors: ['#007bff'],
chartArea: {
left: 30, // leave room for y-axis labels
top: 20,
bottom: 20,
width: '100%',
height: '100%',
},
hAxis: { format: 'M/d' },
legend: 'none',
};

let chart = new google.visualization.LineChart(userStatsChartDiv);
chart.draw(dataTable, options);
})
.fail(function(xhr, status, err) {
flash.error('Failed to render API key stats: ' + err);
});
}
</script>
{{end}}
</body>

</html>
Expand Down
2 changes: 2 additions & 0 deletions internal/routes/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ func apikeyRoutes(r *mux.Router, c *apikey.Controller) {
r.Handle("/{id:[0-9]+}", c.HandleUpdate()).Methods("PATCH")
r.Handle("/{id:[0-9]+}/disable", c.HandleDisable()).Methods("PATCH")
r.Handle("/{id:[0-9]+}/enable", c.HandleEnable()).Methods("PATCH")
r.Handle("/{id:[0-9]+}/stats.json", c.HandleStats()).Methods("GET")
r.Handle("/{id:[0-9]+}/stats.csv", c.HandleStats()).Methods("GET")
}

// userRoutes are the user routes.
Expand Down
6 changes: 6 additions & 0 deletions internal/routes/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ func TestRoutes_apikeyRoutes(t *testing.T) {
{
req: httptest.NewRequest("PATCH", "/12345/enable", nil),
},
{
req: httptest.NewRequest("GET", "/12345/stats.json", nil),
},
{
req: httptest.NewRequest("GET", "/12345/stats.csv", nil),
},
}

for _, tc := range cases {
Expand Down
7 changes: 7 additions & 0 deletions pkg/controller/apikey/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,10 @@ func New(ctx context.Context, config *config.ServerConfig, cacher cache.Cacher,
h: h,
}
}

func (c *Controller) findAuthorizedApp(currentUser *database.User, realm *database.Realm, id interface{}) (*database.AuthorizedApp, error) {
if currentUser.SystemAdmin {
return c.db.FindAuthorizedApp(id)
}
return realm.FindAuthorizedApp(c.db, id)
}
2 changes: 1 addition & 1 deletion pkg/controller/apikey/disable.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (c *Controller) HandleDisable() http.Handler {
return
}

authApp, err := realm.FindAuthorizedApp(c.db, vars["id"])
authApp, err := c.findAuthorizedApp(currentUser, realm, vars["id"])
if err != nil {
if database.IsNotFound(err) {
controller.Unauthorized(w, r, c.h)
Expand Down
2 changes: 1 addition & 1 deletion pkg/controller/apikey/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func (c *Controller) HandleEnable() http.Handler {
return
}

authApp, err := realm.FindAuthorizedApp(c.db, vars["id"])
authApp, err := c.findAuthorizedApp(currentUser, realm, vars["id"])
if err != nil {
if database.IsNotFound(err) {
controller.Unauthorized(w, r, c.h)
Expand Down
31 changes: 9 additions & 22 deletions pkg/controller/apikey/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,8 @@ package apikey
import (
"context"
"net/http"
"strconv"
"time"

"github.com/google/exposure-notifications-server/pkg/logging"
"github.com/google/exposure-notifications-verification-server/pkg/cache"
"github.com/google/exposure-notifications-verification-server/pkg/controller"
"github.com/google/exposure-notifications-verification-server/pkg/database"

Expand All @@ -48,6 +45,12 @@ func (c *Controller) HandleShow() http.Handler {
return
}

currentUser := controller.UserFromContext(ctx)
if currentUser == nil {
controller.MissingUser(w, r, c.h)
return
}

// If the API key is present, add it to the variables map and then delete it
// from the session.
apiKey, ok := session.Values["apiKey"]
Expand All @@ -58,7 +61,7 @@ func (c *Controller) HandleShow() http.Handler {
}

// Pull the authorized app from the id.
authApp, err := realm.FindAuthorizedApp(c.db, vars["id"])
authApp, err := c.findAuthorizedApp(currentUser, realm, vars["id"])
if err != nil {
if database.IsNotFound(err) {
logger.Debugw("auth app does not exist", "id", vars["id"])
Expand All @@ -70,30 +73,14 @@ func (c *Controller) HandleShow() http.Handler {
return
}

// Get and cache the stats for this user.
var stats []*database.AuthorizedAppStats
cacheKey := &cache.Key{
Namespace: "stats:app",
Key: strconv.FormatUint(uint64(authApp.ID), 10),
}
if err := c.cacher.Fetch(ctx, cacheKey, &stats, 5*time.Minute, func() (interface{}, error) {
now := time.Now().UTC()
past := now.Add(-14 * 24 * time.Hour)
return authApp.Stats(c.db, past, now)
}); err != nil {
controller.InternalError(w, r, c.h, err)
return
}

c.renderShow(ctx, w, authApp, stats)
c.renderShow(ctx, w, authApp)
})
}

// renderShow renders the edit page.
func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, authApp *database.AuthorizedApp, stats []*database.AuthorizedAppStats) {
func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, authApp *database.AuthorizedApp) {
m := controller.TemplateMapFromContext(ctx)
m.Title("API key: %s", authApp.Name)
m["authApp"] = authApp
m["stats"] = stats
c.h.RenderHTML(w, "apikeys/show", m)
}
Loading

0 comments on commit 2b79f9b

Please sign in to comment.