-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
251 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
namespace ReplayBrowser.Data; | ||
|
||
/// <summary> | ||
/// Contains data for the analytics page. | ||
/// </summary> | ||
public class AnalyticsData | ||
{ | ||
public required List<Analytics> Analytics { get; set; } | ||
} | ||
|
||
/// <summary> | ||
/// Contains the chart.js data for the analytics page. | ||
/// </summary> | ||
public class Analytics | ||
{ | ||
public required string Name { get; set; } | ||
public required string Description { get; set; } | ||
|
||
/// <summary> | ||
/// The type of chart to display. | ||
/// </summary> | ||
public required string Type { get; set; } | ||
|
||
/// <summary> | ||
/// The data for the chart. | ||
/// </summary> | ||
public required List<ChartData> Data { get; set; } | ||
} | ||
|
||
/// <summary> | ||
/// Contains the data for a chart.js chart. | ||
/// </summary> | ||
public class ChartData | ||
{ | ||
public required string Label { get; set; } | ||
public required double Data { get; set; } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
@page "/stats" | ||
@using System.Globalization | ||
@using ReplayBrowser.Data | ||
@using ReplayBrowser.Services | ||
@inject AnalyticsService AnalyticsService | ||
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | ||
|
||
<h3>Stats</h3> | ||
@if (_errorMessage != null) | ||
{ | ||
<p>@_errorMessage</p> | ||
} else if (_isNotGenerated) | ||
{ | ||
<p>Analytics data is not generated yet. Please try again later.</p> | ||
} | ||
else if (_analyticsData != null) | ||
{ | ||
foreach (var data in _analyticsData.Analytics) | ||
{ | ||
<div> | ||
<h4>@data.Name</h4> | ||
<p>@data.Description</p> | ||
<canvas id="@data.Name" width="400" height="400"></canvas> | ||
@((MarkupString)GetChartScript(data)) | ||
</div> | ||
} | ||
} | ||
else | ||
{ | ||
<p>Loading...</p> | ||
} | ||
|
||
@code { | ||
private AnalyticsData? _analyticsData; | ||
private string? _errorMessage; | ||
private bool _isNotGenerated = false; | ||
|
||
protected override void OnInitialized() | ||
{ | ||
try | ||
{ | ||
_analyticsData = AnalyticsService.GetAnalytics(); | ||
} | ||
catch (InvalidOperationException ex) | ||
{ | ||
_isNotGenerated = true; | ||
} | ||
catch (Exception ex) | ||
{ | ||
_errorMessage = ex.Message; | ||
} | ||
} | ||
|
||
private string GetChartScript(Analytics data) | ||
{ | ||
var labels = string.Join(",", data.Data.Select(x => $"'{x.Label}'")); | ||
var dataset = string.Join(",", data.Data.Select(x => x.Data.ToString(CultureInfo.InvariantCulture))); | ||
|
||
return $@" | ||
<script> | ||
new Chart(document.getElementById('{data.Name}').getContext('2d'), {{ | ||
type: '{data.Type}', | ||
data: {{ | ||
labels: [{labels}], | ||
datasets: [{{ | ||
label: 'Count', | ||
data: [{dataset}], | ||
backgroundColor: 'rgba(255, 99, 132, 0.2)', | ||
borderColor: 'rgba(255, 99, 132, 1)', | ||
borderWidth: 1 | ||
}}] | ||
}}, | ||
options: {{ | ||
scales: {{ | ||
y: {{ | ||
beginAtZero: true | ||
}} | ||
}} | ||
}} | ||
}}); | ||
</script>"; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
using System.ComponentModel.DataAnnotations.Schema; | ||
using System.Diagnostics; | ||
using Microsoft.EntityFrameworkCore; | ||
using Microsoft.Extensions.Caching.Memory; | ||
using ReplayBrowser.Data; | ||
using ReplayBrowser.Helpers; | ||
using Serilog; | ||
|
||
namespace ReplayBrowser.Services; | ||
|
||
/// <summary> | ||
/// Provides the analytics data for the analytics page. | ||
/// </summary> | ||
public class AnalyticsService : IHostedService, IDisposable | ||
{ | ||
private const string CacheKey = "analytics"; | ||
|
||
private Timer? _timer = null; | ||
private readonly IMemoryCache _cache; | ||
private readonly IServiceScopeFactory _scopeFactory; | ||
private readonly IConfiguration _config; | ||
|
||
public AnalyticsService(IMemoryCache cache, IServiceScopeFactory scopeFactory, IConfiguration config) | ||
{ | ||
_cache = cache; | ||
_scopeFactory = scopeFactory; | ||
_config = config; | ||
} | ||
|
||
public Task StartAsync(CancellationToken cancellationToken) | ||
{ | ||
_timer = new Timer(GenerateAnalytics, null, TimeSpan.Zero, TimeSpan.FromHours(12)); | ||
return Task.CompletedTask; | ||
} | ||
|
||
public Task StopAsync(CancellationToken cancellationToken) | ||
{ | ||
_timer?.Change(Timeout.Infinite, 0); | ||
return Task.CompletedTask; | ||
} | ||
|
||
private void GenerateAnalytics(object? state) | ||
{ | ||
var sw = new Stopwatch(); | ||
sw.Start(); | ||
Log.Information("Generating analytics data..."); | ||
|
||
using var scope = _scopeFactory.CreateScope(); | ||
var dbContext = scope.ServiceProvider.GetRequiredService<ReplayDbContext>(); | ||
var replayUrls = _config.GetSection("ReplayUrls").Get<StorageUrl[]>()!; | ||
var analyticsData = new AnalyticsData | ||
{ | ||
Analytics = new List<Analytics>() | ||
}; | ||
|
||
var result = dbContext.Database.SqlQueryRaw<DurationResponse>( | ||
$""" | ||
SELECT | ||
"ServerName", | ||
DATE("Replays"."Date") AS date_of_replay, | ||
AVG(EXTRACT(EPOCH FROM "Replays"."Date"::time)) / 60 AS average_duration_minutes | ||
FROM | ||
"Replays" | ||
WHERE | ||
EXTRACT(EPOCH FROM "Replays"."Date"::time) / 60 <= 180 | ||
AND EXTRACT(EPOCH FROM "Replays"."Date"::time) / 60 >= 10 | ||
GROUP BY | ||
"ServerName", | ||
date_of_replay | ||
ORDER BY | ||
"ServerName", | ||
date_of_replay | ||
"""); // why not use EF Core for this? Performance. | ||
|
||
// For each in the result, add a new analytics object. | ||
foreach (var storageUrl in replayUrls) | ||
{ | ||
var resultsForUrl = result.Where(r => r.ServerName == storageUrl.FallBackServerName).ToList(); | ||
var analytics = new Analytics | ||
{ | ||
Name = storageUrl.FallBackServerName, | ||
Description = $"Average round duration for {storageUrl.FallBackServerName} in minutes.", | ||
Type = "line", | ||
Data = resultsForUrl.Select(r => new ChartData | ||
{ | ||
Label = r.DateOfReplay.ToString("yyyy-MM-dd"), | ||
Data = r.AverageDurationMinutes | ||
}).ToList() | ||
}; | ||
|
||
analyticsData.Analytics.Add(analytics); | ||
} | ||
|
||
_cache.Set(CacheKey, analyticsData, new MemoryCacheEntryOptions | ||
{ | ||
AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1) | ||
}); | ||
|
||
sw.Stop(); | ||
Log.Information("Generated analytics data in {ElapsedMilliseconds}ms.", sw.ElapsedMilliseconds); | ||
} | ||
|
||
public AnalyticsData GetAnalytics() | ||
{ | ||
if (!_cache.TryGetValue(CacheKey, out AnalyticsData data)) | ||
{ | ||
throw new InvalidOperationException("The analytics data has not been generated yet."); | ||
} | ||
|
||
return data; | ||
} | ||
|
||
private class DurationResponse | ||
{ | ||
public required string ServerName { get; set; } | ||
[Column("date_of_replay")] | ||
public required DateTime DateOfReplay { get; set; } | ||
[Column("average_duration_minutes")] | ||
public required double AverageDurationMinutes { get; set; } | ||
} | ||
|
||
public void Dispose() | ||
{ | ||
_timer?.Dispose(); | ||
_cache.Dispose(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters