initial commit
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Pulsar.Server.Statistics
|
||||
{
|
||||
public sealed class ClientGeoSnapshot
|
||||
{
|
||||
public ClientGeoSnapshot(
|
||||
int totalClients,
|
||||
int mappedClients,
|
||||
int unknownClients,
|
||||
IReadOnlyList<GeoCountryCount> countries,
|
||||
DateTime generatedAtUtc,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
TotalClients = totalClients;
|
||||
MappedClients = mappedClients;
|
||||
UnknownClients = unknownClients;
|
||||
Countries = countries ?? Array.Empty<GeoCountryCount>();
|
||||
GeneratedAtUtc = generatedAtUtc;
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? null : errorMessage;
|
||||
}
|
||||
|
||||
public int TotalClients { get; }
|
||||
|
||||
public int MappedClients { get; }
|
||||
|
||||
public int UnknownClients { get; }
|
||||
|
||||
public IReadOnlyList<GeoCountryCount> Countries { get; }
|
||||
|
||||
public DateTime GeneratedAtUtc { get; }
|
||||
|
||||
public string? ErrorMessage { get; }
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
|
||||
|
||||
public int UniqueCountryCount => Countries?.Count ?? 0;
|
||||
|
||||
public static ClientGeoSnapshot CreateError(string message)
|
||||
{
|
||||
return new ClientGeoSnapshot(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Array.Empty<GeoCountryCount>(),
|
||||
DateTime.UtcNow,
|
||||
message);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GeoCountryCount
|
||||
{
|
||||
public GeoCountryCount(string countryCode2, string countryCode3, string name, int count, double share)
|
||||
{
|
||||
CountryCode2 = string.IsNullOrWhiteSpace(countryCode2) ? "" : countryCode2;
|
||||
CountryCode3 = string.IsNullOrWhiteSpace(countryCode3) ? "" : countryCode3;
|
||||
Name = string.IsNullOrWhiteSpace(name) ? "Unknown" : name;
|
||||
Count = count;
|
||||
Share = share;
|
||||
}
|
||||
|
||||
public string CountryCode2 { get; }
|
||||
|
||||
public string CountryCode3 { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
public double Share { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Pulsar.Server.Persistence;
|
||||
|
||||
namespace Pulsar.Server.Statistics
|
||||
{
|
||||
public static class ClientGeoStatisticsService
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, CountryInfo> CountriesByAlpha2;
|
||||
private static readonly IReadOnlyDictionary<string, CountryInfo> CountriesByAlpha3;
|
||||
private static readonly IReadOnlyDictionary<string, CountryInfo> CountriesByName;
|
||||
private static readonly IReadOnlyDictionary<string, CountryInfo> ManualOverrides;
|
||||
|
||||
static ClientGeoStatisticsService()
|
||||
{
|
||||
CountriesByAlpha2 = BuildAlpha2Map();
|
||||
CountriesByAlpha3 = BuildAlpha3Map(CountriesByAlpha2);
|
||||
CountriesByName = BuildNameMap(CountriesByAlpha2);
|
||||
ManualOverrides = BuildManualOverrides();
|
||||
}
|
||||
|
||||
public static ClientGeoSnapshot CreateSnapshot(IEnumerable<OfflineClientRecord>? records, DateTime? generatedAtUtc = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = records?.Where(r => r != null).ToList() ?? new List<OfflineClientRecord>();
|
||||
if (list.Count == 0)
|
||||
{
|
||||
return new ClientGeoSnapshot(0, 0, 0, Array.Empty<GeoCountryCount>(), generatedAtUtc ?? DateTime.UtcNow);
|
||||
}
|
||||
|
||||
var total = list.Count;
|
||||
var mappedCount = 0;
|
||||
var grouped = new Dictionary<string, CountryAggregate>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var record in list)
|
||||
{
|
||||
var info = ResolveCountry(record.CountryCode, record.Country);
|
||||
if (info == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
mappedCount++;
|
||||
var key = info.Value.Alpha3;
|
||||
|
||||
if (!grouped.TryGetValue(key, out var aggregate))
|
||||
{
|
||||
grouped[key] = new CountryAggregate(info.Value)
|
||||
{
|
||||
DisplayName = !string.IsNullOrWhiteSpace(record.Country) ? record.Country.Trim() : info.Value.EnglishName,
|
||||
Count = 1
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
aggregate.Count++;
|
||||
}
|
||||
}
|
||||
|
||||
var unknown = total - mappedCount;
|
||||
|
||||
var countries = grouped.Values
|
||||
.Select(aggregate => new GeoCountryCount(
|
||||
aggregate.Info.Alpha2,
|
||||
aggregate.Info.Alpha3,
|
||||
aggregate.DisplayName,
|
||||
aggregate.Count,
|
||||
total > 0 ? (double)aggregate.Count / total : 0))
|
||||
.OrderByDescending(c => c.Count)
|
||||
.ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new ClientGeoSnapshot(total, mappedCount, unknown, countries, generatedAtUtc ?? DateTime.UtcNow);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ClientGeoSnapshot.CreateError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static CountryInfo? ResolveCountry(string? countryCode, string? countryName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(countryCode))
|
||||
{
|
||||
var normalized = countryCode.Trim();
|
||||
if (TryResolveByCode(normalized, out var byCode))
|
||||
{
|
||||
return byCode;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(countryName))
|
||||
{
|
||||
var normalizedName = NormalizeName(countryName);
|
||||
if (CountriesByName.TryGetValue(normalizedName, out var byName))
|
||||
{
|
||||
return byName;
|
||||
}
|
||||
|
||||
if (ManualOverrides.TryGetValue(normalizedName, out var manual))
|
||||
{
|
||||
return manual;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryResolveByCode(string code, out CountryInfo info)
|
||||
{
|
||||
if (code.Length == 2)
|
||||
{
|
||||
if (CountriesByAlpha2.TryGetValue(code.ToUpperInvariant(), out info))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ManualOverrides.TryGetValue($"__ALPHA2__{code.ToUpperInvariant()}", out info))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (code.Length == 3)
|
||||
{
|
||||
if (CountriesByAlpha3.TryGetValue(code.ToUpperInvariant(), out info))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ManualOverrides.TryGetValue($"__ALPHA3__{code.ToUpperInvariant()}", out info))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
info = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, CountryInfo> BuildAlpha2Map()
|
||||
{
|
||||
var map = new Dictionary<string, CountryInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var culture in CultureInfo.GetCultures(CultureTypes.SpecificCultures))
|
||||
{
|
||||
try
|
||||
{
|
||||
var region = new RegionInfo(culture.Name);
|
||||
if (!map.ContainsKey(region.TwoLetterISORegionName))
|
||||
{
|
||||
map[region.TwoLetterISORegionName] = new CountryInfo(region.TwoLetterISORegionName, region.ThreeLetterISORegionName, region.EnglishName);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cultures without associated region information.
|
||||
}
|
||||
}
|
||||
|
||||
return new ReadOnlyDictionary<string, CountryInfo>(map);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, CountryInfo> BuildAlpha3Map(IReadOnlyDictionary<string, CountryInfo> alpha2Map)
|
||||
{
|
||||
var map = new Dictionary<string, CountryInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in alpha2Map.Values)
|
||||
{
|
||||
if (!map.ContainsKey(entry.Alpha3))
|
||||
{
|
||||
map[entry.Alpha3] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return new ReadOnlyDictionary<string, CountryInfo>(map);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, CountryInfo> BuildNameMap(IReadOnlyDictionary<string, CountryInfo> alpha2Map)
|
||||
{
|
||||
var map = new Dictionary<string, CountryInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in alpha2Map.Values)
|
||||
{
|
||||
var englishName = NormalizeName(entry.EnglishName);
|
||||
if (!map.ContainsKey(englishName))
|
||||
{
|
||||
map[englishName] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return new ReadOnlyDictionary<string, CountryInfo>(map);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, CountryInfo> BuildManualOverrides()
|
||||
{
|
||||
var map = new Dictionary<string, CountryInfo>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ NormalizeName("United States"), new CountryInfo("US", "USA", "United States") },
|
||||
{ NormalizeName("United States of America"), new CountryInfo("US", "USA", "United States") },
|
||||
{ NormalizeName("United Kingdom"), new CountryInfo("GB", "GBR", "United Kingdom") },
|
||||
{ NormalizeName("Great Britain"), new CountryInfo("GB", "GBR", "United Kingdom") },
|
||||
{ NormalizeName("Russia"), new CountryInfo("RU", "RUS", "Russia") },
|
||||
{ NormalizeName("South Korea"), new CountryInfo("KR", "KOR", "South Korea") },
|
||||
{ NormalizeName("North Korea"), new CountryInfo("KP", "PRK", "North Korea") },
|
||||
{ NormalizeName("Viet Nam"), new CountryInfo("VN", "VNM", "Vietnam") },
|
||||
{ NormalizeName("Czech Republic"), new CountryInfo("CZ", "CZE", "Czech Republic") },
|
||||
{ NormalizeName("Ivory Coast"), new CountryInfo("CI", "CIV", "Côte d'Ivoire") },
|
||||
{ NormalizeName("Bolivia"), new CountryInfo("BO", "BOL", "Bolivia") },
|
||||
{ NormalizeName("Tanzania"), new CountryInfo("TZ", "TZA", "Tanzania") },
|
||||
{ NormalizeName("Syria"), new CountryInfo("SY", "SYR", "Syria") },
|
||||
{ NormalizeName("Moldova"), new CountryInfo("MD", "MDA", "Moldova") },
|
||||
{ NormalizeName("Macau"), new CountryInfo("MO", "MAC", "Macau") },
|
||||
{ NormalizeName("Hong Kong"), new CountryInfo("HK", "HKG", "Hong Kong") },
|
||||
{ NormalizeName("Taiwan"), new CountryInfo("TW", "TWN", "Taiwan") },
|
||||
{ NormalizeName("Cape Verde"), new CountryInfo("CV", "CPV", "Cabo Verde") },
|
||||
{ NormalizeName("Laos"), new CountryInfo("LA", "LAO", "Laos") },
|
||||
{ NormalizeName("Kosovo"), new CountryInfo("XK", "XKX", "Kosovo") },
|
||||
{ NormalizeName("Palestine"), new CountryInfo("PS", "PSE", "Palestine") },
|
||||
{ NormalizeName("Vatican"), new CountryInfo("VA", "VAT", "Vatican City") },
|
||||
{ "__ALPHA2__XK", new CountryInfo("XK", "XKX", "Kosovo") },
|
||||
{ "__ALPHA3__XKX", new CountryInfo("XK", "XKX", "Kosovo") }
|
||||
};
|
||||
|
||||
return new ReadOnlyDictionary<string, CountryInfo>(map);
|
||||
}
|
||||
|
||||
private static string NormalizeName(string value)
|
||||
{
|
||||
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
|
||||
}
|
||||
|
||||
private readonly struct CountryInfo
|
||||
{
|
||||
public CountryInfo(string alpha2, string alpha3, string englishName)
|
||||
{
|
||||
Alpha2 = alpha2;
|
||||
Alpha3 = alpha3;
|
||||
EnglishName = englishName;
|
||||
}
|
||||
|
||||
public string Alpha2 { get; }
|
||||
|
||||
public string Alpha3 { get; }
|
||||
|
||||
public string EnglishName { get; }
|
||||
}
|
||||
|
||||
private sealed class CountryAggregate
|
||||
{
|
||||
public CountryAggregate(CountryInfo info)
|
||||
{
|
||||
Info = info;
|
||||
DisplayName = info.EnglishName;
|
||||
}
|
||||
|
||||
public CountryInfo Info { get; }
|
||||
|
||||
public string DisplayName { get; set; }
|
||||
|
||||
public int Count { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Pulsar.Server.Persistence;
|
||||
|
||||
namespace Pulsar.Server.Statistics
|
||||
{
|
||||
public static class ClientStatisticsService
|
||||
{
|
||||
private const int DefaultHistoryDays = 14;
|
||||
private const int MaxPieSegments = 7;
|
||||
private const int MaxTagRows = 10;
|
||||
|
||||
public static ClientStatisticsSnapshot CreateSnapshot(IEnumerable<OfflineClientRecord>? records)
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = records?.ToList() ?? new List<OfflineClientRecord>();
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var total = list.Count;
|
||||
var online = list.Count(r => r.IsOnline);
|
||||
var offline = total - online;
|
||||
|
||||
var dayRange = BuildDayRange(nowUtc.Date, DefaultHistoryDays);
|
||||
var firstSeenGroups = list
|
||||
.Where(r => r.FirstSeenUtc.HasValue)
|
||||
.GroupBy(r => r.FirstSeenUtc!.Value.Date)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var daily = dayRange
|
||||
.Select(date => new DailyCount(date, firstSeenGroups.TryGetValue(date, out var value) ? value : 0))
|
||||
.ToList();
|
||||
|
||||
var sevenDayThreshold = nowUtc.Date.AddDays(-6);
|
||||
var newSevenDayCount = daily.Where(d => d.Date >= sevenDayThreshold).Sum(d => d.Count);
|
||||
|
||||
var countryStats = BuildCategoryList(list, r => Normalize(r.Country), total, MaxPieSegments);
|
||||
var osStats = BuildCategoryList(list, r => Normalize(r.OperatingSystem), total, MaxPieSegments);
|
||||
var tagStats = BuildCategoryList(list, r => string.IsNullOrWhiteSpace(r.Tag) ? "(none)" : r.Tag, total, MaxTagRows);
|
||||
|
||||
return new ClientStatisticsSnapshot(
|
||||
total,
|
||||
online,
|
||||
offline,
|
||||
newSevenDayCount,
|
||||
daily,
|
||||
countryStats,
|
||||
osStats,
|
||||
tagStats,
|
||||
nowUtc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ClientStatisticsSnapshot.CreateError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CategoryCount> BuildCategoryList(IEnumerable<OfflineClientRecord> source, Func<OfflineClientRecord, string> selector, int total, int maxItems)
|
||||
{
|
||||
var grouped = source
|
||||
.GroupBy(selector)
|
||||
.Select(g => new { Label = string.IsNullOrWhiteSpace(g.Key) ? "Unknown" : g.Key.Trim(), Count = g.Count() })
|
||||
.OrderByDescending(g => g.Count)
|
||||
.ThenBy(g => g.Label)
|
||||
.ToList();
|
||||
|
||||
if (grouped.Count == 0)
|
||||
{
|
||||
return Array.Empty<CategoryCount>();
|
||||
}
|
||||
|
||||
if (maxItems <= 0 || grouped.Count <= maxItems)
|
||||
{
|
||||
return grouped
|
||||
.Select(g => new CategoryCount(g.Label, g.Count, total > 0 ? (double)g.Count / total : 0))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var top = grouped.Take(maxItems - 1).ToList();
|
||||
var otherCount = grouped.Skip(maxItems - 1).Sum(g => g.Count);
|
||||
|
||||
var result = top
|
||||
.Select(g => new CategoryCount(g.Label, g.Count, total > 0 ? (double)g.Count / total : 0))
|
||||
.ToList();
|
||||
|
||||
if (otherCount > 0)
|
||||
{
|
||||
result.Add(new CategoryCount("Other", otherCount, total > 0 ? (double)otherCount / total : 0));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DateTime> BuildDayRange(DateTime endDateInclusive, int days)
|
||||
{
|
||||
if (days <= 0)
|
||||
{
|
||||
return new[] { endDateInclusive };
|
||||
}
|
||||
|
||||
var start = endDateInclusive.AddDays(-(days - 1));
|
||||
var result = new List<DateTime>(days);
|
||||
for (var d = start; d <= endDateInclusive; d = d.AddDays(1))
|
||||
{
|
||||
result.Add(d);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Pulsar.Server.Statistics
|
||||
{
|
||||
public sealed class ClientStatisticsSnapshot
|
||||
{
|
||||
public ClientStatisticsSnapshot(
|
||||
int totalClients,
|
||||
int onlineClients,
|
||||
int offlineClients,
|
||||
int newClientsLast7Days,
|
||||
IReadOnlyList<DailyCount> newClientsByDay,
|
||||
IReadOnlyList<CategoryCount> clientsByCountry,
|
||||
IReadOnlyList<CategoryCount> clientsByOperatingSystem,
|
||||
IReadOnlyList<CategoryCount> topTags,
|
||||
DateTime generatedAtUtc,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
TotalClients = totalClients;
|
||||
OnlineClients = onlineClients;
|
||||
OfflineClients = offlineClients;
|
||||
NewClientsLast7Days = newClientsLast7Days;
|
||||
NewClientsByDay = newClientsByDay ?? Array.Empty<DailyCount>();
|
||||
ClientsByCountry = clientsByCountry ?? Array.Empty<CategoryCount>();
|
||||
ClientsByOperatingSystem = clientsByOperatingSystem ?? Array.Empty<CategoryCount>();
|
||||
TopTags = topTags ?? Array.Empty<CategoryCount>();
|
||||
GeneratedAtUtc = generatedAtUtc;
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? null : errorMessage;
|
||||
}
|
||||
|
||||
public int TotalClients { get; }
|
||||
|
||||
public int OnlineClients { get; }
|
||||
|
||||
public int OfflineClients { get; }
|
||||
|
||||
public int NewClientsLast7Days { get; }
|
||||
|
||||
public IReadOnlyList<DailyCount> NewClientsByDay { get; }
|
||||
|
||||
public IReadOnlyList<CategoryCount> ClientsByCountry { get; }
|
||||
|
||||
public IReadOnlyList<CategoryCount> ClientsByOperatingSystem { get; }
|
||||
|
||||
public IReadOnlyList<CategoryCount> TopTags { get; }
|
||||
|
||||
public DateTime GeneratedAtUtc { get; }
|
||||
|
||||
public string? ErrorMessage { get; }
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
|
||||
|
||||
public static ClientStatisticsSnapshot CreateError(string message)
|
||||
{
|
||||
return new ClientStatisticsSnapshot(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Array.Empty<DailyCount>(),
|
||||
Array.Empty<CategoryCount>(),
|
||||
Array.Empty<CategoryCount>(),
|
||||
Array.Empty<CategoryCount>(),
|
||||
DateTime.UtcNow,
|
||||
message);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DailyCount
|
||||
{
|
||||
public DailyCount(DateTime date, int count)
|
||||
{
|
||||
Date = date;
|
||||
Count = count;
|
||||
}
|
||||
|
||||
public DateTime Date { get; }
|
||||
|
||||
public int Count { get; }
|
||||
}
|
||||
|
||||
public sealed class CategoryCount
|
||||
{
|
||||
public CategoryCount(string label, int count, double share)
|
||||
{
|
||||
Label = string.IsNullOrWhiteSpace(label) ? "Unknown" : label;
|
||||
Count = count;
|
||||
Share = share;
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
public double Share { get; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Label} ({Count})";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user