diff --git a/docs-website/docs/admin-panel/overview.md b/docs-website/docs/admin-panel/overview.md
index 24fffdaf9..5c20b8243 100644
--- a/docs-website/docs/admin-panel/overview.md
+++ b/docs-website/docs/admin-panel/overview.md
@@ -47,6 +47,7 @@ with `-adminpanel:disabled`.
| Page | Purpose |
|---|---|
+| [Dashboard](#dashboard) | The start page: statistics about the servers, players, accounts and guilds |
| [Setup](setup.md) | Create, reinstall and update the database; choose the game version |
| [Configuration updates](configuration-updates.md) | Apply configuration data updates which come with new OpenMU versions |
| [Servers](servers.md) | Start/stop listeners, player counts, add servers, global messages |
@@ -77,6 +78,32 @@ example the [server-side AI bots](../server-features/bots.md).
included; further languages can be added as satellite resource files and appear
in this selector automatically.
+## Dashboard
+
+The start page shows the state of the server at a glance, as cards:
+
+* **Players online** — the players on the running game servers, and how many
+ can be online on them.
+* **Servers running** — how many of the servers are started. Servers which
+ aren't are highlighted.
+* **Accounts** — the number of accounts, how many were registered in the last
+ 7 days and how many are banned. Bot and template accounts are not counted.
+* **Guilds** — the number of guilds and alliances, and the number of characters.
+* **Players online in the last 24 hours** — a chart which is updated every
+ 5 minutes. The history is kept in memory, so it starts over when the admin
+ panel is restarted.
+* **Servers** — every server with its player count and state. The page
+ updates itself when a server starts or stops or players log in or out.
+* **Quick actions** — send a [global message](servers.md#global-messages) and
+ links to frequently used pages.
+
+Administrators additionally see the state of the castle siege,
+the uptime and memory usage of the process, the version, whether the database is
+reachable, and the number of available [configuration updates](configuration-updates.md).
+
+The numbers of accounts, guilds and characters are counted when the page is
+opened; reload the page to update them.
+
## A note about the edit pages
To be able to edit most of the data without writing SQL, there are **generic edit
@@ -99,6 +126,7 @@ Some parts of the panel depend on how the server is hosted:
|---|---|---|
| Logs | [Log files page](logs-and-monitoring.md#log-files-all-in-one) inside the panel | Links to Grafana for logs, metrics and traces |
| Live map | Rendered by the panel itself | Reverse-proxied from the game server container |
+| Dashboard | Shows the castle siege and the global message | Without the castle siege and the global message; uptime and memory are the ones of the admin panel container |
| Auto start / auto schema update | Applies | Ignored — listeners always start, schema updates are started manually |
| After an installation | Ready immediately | The connect server and game server containers have to be restarted |
diff --git a/src/Persistence/AccountStatistics.cs b/src/Persistence/AccountStatistics.cs
new file mode 100644
index 000000000..7efc4e80f
--- /dev/null
+++ b/src/Persistence/AccountStatistics.cs
@@ -0,0 +1,17 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence;
+
+///
+/// Statistics about the player accounts, e.g. for the dashboard of the admin panel.
+///
+///
+/// Template and bot accounts are not included, since they are not created by players.
+///
+/// The number of accounts.
+/// The number of accounts which were registered since the requested point in time.
+/// The number of accounts which are banned, permanently or temporarily.
+/// The number of characters of the accounts.
+public sealed record AccountStatistics(int AccountCount, int NewAccountCount, int BannedAccountCount, int CharacterCount);
diff --git a/src/Persistence/BaseRepositoryProvider.cs b/src/Persistence/BaseRepositoryProvider.cs
index cea0df561..4a30bd3c3 100644
--- a/src/Persistence/BaseRepositoryProvider.cs
+++ b/src/Persistence/BaseRepositoryProvider.cs
@@ -4,6 +4,7 @@
namespace MUnique.OpenMU.Persistence;
+using System.Collections.Concurrent;
using System.Threading;
///
@@ -18,7 +19,10 @@ public class BaseRepositoryProvider : IRepositoryProvider
///
/// Gets the repositories for each entity type.
///
- protected IDictionary Repositories { get; } = new Dictionary();
+ ///
+ /// Derived providers may register repositories on demand, while other contexts look them up concurrently.
+ ///
+ protected IDictionary Repositories { get; } = new ConcurrentDictionary();
///
/// Gets the repository of the specified generic type.
diff --git a/src/Persistence/EntityFramework/GuildServerContext.cs b/src/Persistence/EntityFramework/GuildServerContext.cs
index 2e2aae359..d7c14e8db 100644
--- a/src/Persistence/EntityFramework/GuildServerContext.cs
+++ b/src/Persistence/EntityFramework/GuildServerContext.cs
@@ -116,4 +116,18 @@ public async ValueTask> GetAllianceMasterIdsAsync(IRea
.Distinct()
.ToListAsync().ConfigureAwait(false);
}
+
+ ///
+ public async ValueTask GetGuildStatisticsAsync(CancellationToken cancellationToken = default)
+ {
+ var guilds = this.Context.Set().AsNoTracking();
+ var guildCount = await guilds.CountAsync(cancellationToken).ConfigureAwait(false);
+ var allianceCount = await guilds
+ .Where(g => g.AllianceGuildId != null)
+ .Select(g => g.AllianceGuildId)
+ .Distinct()
+ .CountAsync(cancellationToken)
+ .ConfigureAwait(false);
+ return new GuildStatistics(guildCount, allianceCount);
+ }
}
diff --git a/src/Persistence/EntityFramework/PlayerContext.cs b/src/Persistence/EntityFramework/PlayerContext.cs
index 4c94fd3f9..6b35c09c3 100644
--- a/src/Persistence/EntityFramework/PlayerContext.cs
+++ b/src/Persistence/EntityFramework/PlayerContext.cs
@@ -138,6 +138,23 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe
}
}
+ ///
+ public async ValueTask GetAccountStatisticsAsync(DateTime registeredSince, CancellationToken cancellationToken = default)
+ {
+ using var l = await this.LockAsync(cancellationToken).ConfigureAwait(false);
+ using (this.RepositoryProvider.ContextStack.UseContext(this))
+ {
+ var accounts = this.Context.Set().AsNoTracking().Where(a => !a.IsTemplate && !a.IsBot);
+ var accountCount = await accounts.CountAsync(cancellationToken).ConfigureAwait(false);
+ var newAccountCount = await accounts.CountAsync(a => a.RegistrationDate >= registeredSince, cancellationToken).ConfigureAwait(false);
+ var bannedAccountCount = await accounts
+ .CountAsync(a => a.State == DataModel.Entities.AccountState.Banned || a.State == DataModel.Entities.AccountState.TemporarilyBanned, cancellationToken)
+ .ConfigureAwait(false);
+ var characterCount = await accounts.SelectMany(a => a.RawCharacters).CountAsync(cancellationToken).ConfigureAwait(false);
+ return new AccountStatistics(accountCount, newAccountCount, bannedAccountCount, characterCount);
+ }
+ }
+
///
public async ValueTask GetAccountByCharacterNameAsync(string characterName, CancellationToken cancellationToken = default)
{
diff --git a/src/Persistence/GuildStatistics.cs b/src/Persistence/GuildStatistics.cs
new file mode 100644
index 000000000..d02975e34
--- /dev/null
+++ b/src/Persistence/GuildStatistics.cs
@@ -0,0 +1,12 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence;
+
+///
+/// Statistics about the guilds, e.g. for the dashboard of the admin panel.
+///
+/// The number of guilds.
+/// The number of alliances, i.e. the guilds which are the master of an alliance.
+public sealed record GuildStatistics(int GuildCount, int AllianceCount);
diff --git a/src/Persistence/IGuildServerContext.cs b/src/Persistence/IGuildServerContext.cs
index f0b9f48c5..1004c8016 100644
--- a/src/Persistence/IGuildServerContext.cs
+++ b/src/Persistence/IGuildServerContext.cs
@@ -74,4 +74,11 @@ public interface IGuildServerContext : IContext
/// The guild identifiers to check. Kept small (e.g. one page) to avoid a full table scan.
/// The subset of which are alliance masters.
ValueTask> GetAllianceMasterIdsAsync(IReadOnlyCollection guildIds);
+
+ ///
+ /// Gets statistics about the guilds.
+ ///
+ /// The cancellation token.
+ /// The statistics about the guilds.
+ ValueTask GetGuildStatisticsAsync(CancellationToken cancellationToken = default);
}
diff --git a/src/Persistence/IPlayerContext.cs b/src/Persistence/IPlayerContext.cs
index 621842562..aac55879d 100644
--- a/src/Persistence/IPlayerContext.cs
+++ b/src/Persistence/IPlayerContext.cs
@@ -91,6 +91,14 @@ public interface IPlayerContext : IContext
///
ValueTask> SearchAccountsAsync(string searchTerm, int skip, int count, CancellationToken cancellationToken = default);
+ ///
+ /// Gets statistics about the player accounts.
+ ///
+ /// The point in time (UTC) since which registered accounts are counted as new.
+ /// The cancellation token.
+ /// The statistics about the player accounts.
+ ValueTask GetAccountStatisticsAsync(DateTime registeredSince, CancellationToken cancellationToken = default);
+
///
/// Gets the account by character name.
///
diff --git a/src/Persistence/InMemory/GuildServerInMemoryContext.cs b/src/Persistence/InMemory/GuildServerInMemoryContext.cs
index d3a17b5fe..b1a2459be 100644
--- a/src/Persistence/InMemory/GuildServerInMemoryContext.cs
+++ b/src/Persistence/InMemory/GuildServerInMemoryContext.cs
@@ -101,4 +101,16 @@ public async ValueTask> GetAllianceMasterIdsAsync(IRea
.Distinct()
.ToList();
}
+
+ ///
+ public async ValueTask GetGuildStatisticsAsync(CancellationToken cancellationToken = default)
+ {
+ var allGuilds = (await this.Provider.GetRepository().GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList();
+ var allianceCount = allGuilds
+ .Where(g => g.AllianceGuild is not null)
+ .Select(g => g.AllianceGuild!.GetId())
+ .Distinct()
+ .Count();
+ return new GuildStatistics(allGuilds.Count, allianceCount);
+ }
}
diff --git a/src/Persistence/InMemory/InMemoryRepositoryProvider.cs b/src/Persistence/InMemory/InMemoryRepositoryProvider.cs
index ae5362c6a..eead334db 100644
--- a/src/Persistence/InMemory/InMemoryRepositoryProvider.cs
+++ b/src/Persistence/InMemory/InMemoryRepositoryProvider.cs
@@ -4,6 +4,7 @@
namespace MUnique.OpenMU.Persistence.InMemory;
+using System.Threading;
using MUnique.OpenMU.Persistence.BasicModel;
///
@@ -11,6 +12,8 @@ namespace MUnique.OpenMU.Persistence.InMemory;
///
public class InMemoryRepositoryProvider : BaseRepositoryProvider
{
+ private readonly Lock _creationLock = new();
+
///
/// Gets all which were added to this manager.
///
@@ -24,7 +27,7 @@ public class InMemoryRepositoryProvider : BaseRepositoryProvider
public new IRepository GetRepository()
where T : class
{
- var repository = this.InternalGetRepository(typeof(T)) ?? this.CreateAndRegisterMemoryRepository(typeof(T));
+ var repository = this.GetOrCreateMemoryRepository(typeof(T));
return new InMemoryRepositoryAdapter((IMemoryRepository)repository);
}
@@ -35,11 +38,26 @@ public class InMemoryRepositoryProvider : BaseRepositoryProvider
/// The repository of the specified type.
public new IRepository GetRepository(Type objectType)
{
- var repository = this.InternalGetRepository(objectType) ?? this.CreateAndRegisterMemoryRepository(objectType);
+ var repository = this.GetOrCreateMemoryRepository(objectType);
return repository;
}
+ private IRepository GetOrCreateMemoryRepository(Type type)
+ {
+ if (this.InternalGetRepository(type) is { } repository)
+ {
+ return repository;
+ }
+
+ // The repositories are created on demand, possibly by concurrent contexts. Without the lock,
+ // both would create one, and the changes made in the one which isn't registered get lost.
+ lock (this._creationLock)
+ {
+ return this.InternalGetRepository(type) ?? this.CreateAndRegisterMemoryRepository(type);
+ }
+ }
+
private IRepository CreateAndRegisterMemoryRepository(Type type)
{
var baseModelAssembly = typeof(GameConfiguration).Assembly;
diff --git a/src/Persistence/InMemory/PlayerInMemoryContext.cs b/src/Persistence/InMemory/PlayerInMemoryContext.cs
index 173dee7aa..c0f060e20 100644
--- a/src/Persistence/InMemory/PlayerInMemoryContext.cs
+++ b/src/Persistence/InMemory/PlayerInMemoryContext.cs
@@ -57,6 +57,18 @@ public PlayerInMemoryContext(InMemoryRepositoryProvider provider)
return allAccounts.OrderBy(a => a.LoginName).Skip(skip).Take(count);
}
+ ///
+ public async ValueTask GetAccountStatisticsAsync(DateTime registeredSince, CancellationToken cancellationToken = default)
+ {
+ var allAccounts = await this.Provider.GetRepository().GetAllAsync(cancellationToken).ConfigureAwait(false);
+ var accounts = allAccounts.Where(a => !a.IsTemplate && !a.IsBot).ToList();
+ return new AccountStatistics(
+ accounts.Count,
+ accounts.Count(a => a.RegistrationDate >= registeredSince),
+ accounts.Count(a => a.State is MUnique.OpenMU.DataModel.Entities.AccountState.Banned or MUnique.OpenMU.DataModel.Entities.AccountState.TemporarilyBanned),
+ accounts.Sum(a => a.Characters.Count));
+ }
+
///
public async ValueTask> SearchAccountsAsync(string searchTerm, int skip, int count, CancellationToken cancellationToken = default)
{
diff --git a/src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor b/src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor
new file mode 100644
index 000000000..4c86c76fe
--- /dev/null
+++ b/src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor
@@ -0,0 +1,59 @@
+@using MUnique.OpenMU.GameLogic.CastleSiege
+@using MUnique.OpenMU.Web.AdminPanel.Properties
+@using MUnique.OpenMU.Web.AdminPanel.Services
+
+@if (this._snapshot is { } snapshot)
+{
+
+
+
+
+@code {
+ private TimeSpan _uptime;
+
+ private long _workingSetMegabytes;
+
+ private string _version = string.Empty;
+
+ private bool? _isDatabaseReachable;
+
+ ///
+ /// Gets or sets the persistence context provider.
+ ///
+ [Inject]
+ public IMigratableDatabaseContextProvider PersistenceContextProvider { get; set; } = null!;
+
+ ///
+ protected override async Task OnInitializedAsync()
+ {
+ using (var process = Process.GetCurrentProcess())
+ {
+ this._uptime = DateTime.Now - process.StartTime;
+ this._workingSetMegabytes = process.WorkingSet64 / 1024 / 1024;
+ }
+
+ this._version = GetVersion();
+
+ await base.OnInitializedAsync().ConfigureAwait(true);
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ this._isDatabaseReachable = await this.PersistenceContextProvider.CanConnectToDatabaseAsync(cts.Token).ConfigureAwait(true);
+ }
+ catch
+ {
+ this._isDatabaseReachable = false;
+ }
+ }
+
+ private static string GetVersion()
+ {
+ var version = typeof(SystemInfoCard).Assembly.GetCustomAttribute()?.InformationalVersion ?? string.Empty;
+
+ // The informational version may contain the full commit hash (e.g. "1.0.0+0123456789abcdef..."), which we shorten.
+ var metadataIndex = version.IndexOf('+');
+ return metadataIndex >= 0 && version.Length > metadataIndex + 8
+ ? version[..(metadataIndex + 8)]
+ : version;
+ }
+}
diff --git a/src/Web/AdminPanel/Components/GlobalMessageForm.razor b/src/Web/AdminPanel/Components/GlobalMessageForm.razor
new file mode 100644
index 000000000..6149af98d
--- /dev/null
+++ b/src/Web/AdminPanel/Components/GlobalMessageForm.razor
@@ -0,0 +1,136 @@
+@using Microsoft.Extensions.DependencyInjection
+@using MUnique.OpenMU.Interfaces
+@using MUnique.OpenMU.Web.AdminPanel.Properties
+
+@if (this._gameServers is not null)
+{
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
+
+@code {
+ private string _message = string.Empty;
+
+ private int _selectedServerId = -1;
+
+ private MessageType _messageType = MessageType.GoldenCenter;
+
+ ///
+ /// The game servers, resolved optionally: the dictionary is only registered in the
+ /// single-process host, so it is null on the distributed (Dapr) admin panel host. When
+ /// null, the form is not rendered (graceful degradation).
+ ///
+ private IDictionary? _gameServers;
+
+ ///
+ /// Gets or sets the servers, which are offered as target.
+ ///
+ [Parameter]
+ public IList Servers { get; set; } = null!;
+
+ ///
+ /// Gets or sets a value indicating whether the form is shown in a compact layout,
+ /// e.g. when it's placed in a narrow card.
+ ///
+ [Parameter]
+ public bool Compact { get; set; }
+
+ ///
+ /// Gets or sets the service provider, used to optionally resolve the game server dictionary.
+ ///
+ [Inject]
+ public IServiceProvider ServiceProvider { get; set; } = null!;
+
+ ///
+ /// Gets or sets the toast service.
+ ///
+ [Inject]
+ public IToastService ToastService { get; set; } = null!;
+
+ ///
+ protected override void OnInitialized()
+ {
+ base.OnInitialized();
+ this._gameServers = this.ServiceProvider.GetService>();
+ }
+
+ private async Task SendMessageAsync()
+ {
+ var message = _message;
+ if (string.IsNullOrWhiteSpace(message) || this._gameServers is not { } gameServers)
+ {
+ return;
+ }
+
+ // Clear the input immediately (on the sync context) so the Send button gets disabled,
+ // preventing double-submission and preserving any text the user types while sending.
+ _message = string.Empty;
+
+ var targets = _selectedServerId == -1
+ ? gameServers.Values.Where(s => s.ServerState == ServerState.Started).ToList()
+ : gameServers.TryGetValue(_selectedServerId, out var selected) && selected.ServerState == ServerState.Started
+ ? new List { selected }
+ : new List();
+
+ if (targets.Count == 0)
+ {
+ // No running target (e.g. the selected server stopped meanwhile). Restore the text so
+ // the admin can pick another target and retry without retyping.
+ _message = message;
+ this.ToastService.ShowInfo(Resources.GlobalMessageNoTarget);
+ return;
+ }
+
+ var sent = 0;
+ foreach (var server in targets)
+ {
+ try
+ {
+ await server.SendGlobalMessageAsync(message, _messageType);
+ sent++;
+ }
+ catch (Exception ex)
+ {
+ // Per-server catch so one failing server does not abort the broadcast to the rest.
+ this.ToastService.ShowError(string.Format(Resources.GlobalMessageSendFailed, server.Description, ex.Message));
+ }
+ }
+
+ if (sent == targets.Count)
+ {
+ this.ToastService.ShowSuccess(Resources.GlobalMessageSent);
+ }
+ else if (sent == 0)
+ {
+ // Nothing got through: restore the text so the whole broadcast can be retried.
+ _message = message;
+ }
+ }
+}
diff --git a/src/Web/AdminPanel/Pages/Index.razor b/src/Web/AdminPanel/Pages/Index.razor
index db3adcd49..dc6cb53e0 100644
--- a/src/Web/AdminPanel/Pages/Index.razor
+++ b/src/Web/AdminPanel/Pages/Index.razor
@@ -1,9 +1,123 @@
@page "/"
+@using MUnique.OpenMU.DataModel
+@using MUnique.OpenMU.Interfaces
+@using MUnique.OpenMU.Web.AdminPanel.Components.Dashboard
@using MUnique.OpenMU.Web.AdminPanel.Properties
+@using MUnique.OpenMU.Web.AdminPanel.Services
-
}
@@ -116,12 +88,6 @@ else
private bool _isRestarting;
- private string _message = string.Empty;
-
- private int _selectedServerId = -1;
-
- private MessageType _messageType = MessageType.GoldenCenter;
-
///
/// Gets or sets the .
///
@@ -140,12 +106,6 @@ else
[Inject]
public IServiceProvider ServiceProvider { get; set; } = null!;
- ///
- /// Gets or sets the toast service.
- ///
- [Inject]
- public IToastService ToastService { get; set; } = null!;
-
///
/// The game servers, resolved optionally: the dictionary is only registered in the
/// single-process host, so it is null on the distributed (Dapr) admin panel host. When
@@ -189,57 +149,4 @@ else
this._isRestarting = false;
}
}
-
- private async Task SendMessageAsync()
- {
- var message = _message;
- if (string.IsNullOrWhiteSpace(message) || this._gameServers is not { } gameServers)
- {
- return;
- }
-
- // Clear the input immediately (on the sync context) so the Send button gets disabled,
- // preventing double-submission and preserving any text the user types while sending.
- _message = string.Empty;
-
- var targets = _selectedServerId == -1
- ? gameServers.Values.Where(s => s.ServerState == ServerState.Started).ToList()
- : gameServers.TryGetValue(_selectedServerId, out var selected) && selected.ServerState == ServerState.Started
- ? new List { selected }
- : new List();
-
- if (targets.Count == 0)
- {
- // No running target (e.g. the selected server stopped meanwhile). Restore the text so
- // the admin can pick another target and retry without retyping.
- _message = message;
- this.ToastService.ShowInfo(Resources.GlobalMessageNoTarget);
- return;
- }
-
- var sent = 0;
- foreach (var server in targets)
- {
- try
- {
- await server.SendGlobalMessageAsync(message, _messageType);
- sent++;
- }
- catch (Exception ex)
- {
- // Per-server catch so one failing server does not abort the broadcast to the rest.
- this.ToastService.ShowError(string.Format(Resources.GlobalMessageSendFailed, server.Description, ex.Message));
- }
- }
-
- if (sent == targets.Count)
- {
- this.ToastService.ShowSuccess(Resources.GlobalMessageSent);
- }
- else if (sent == 0)
- {
- // Nothing got through: restore the text so the whole broadcast can be retried.
- _message = message;
- }
- }
}
diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs
index 3cadc21ca..d20c869f1 100644
--- a/src/Web/AdminPanel/Properties/Resources.Designer.cs
+++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs
@@ -1293,6 +1293,339 @@ public static string CurrentState {
}
}
+ ///
+ /// Looks up a localized string similar to Dashboard.
+ ///
+ public static string Dashboard {
+ get {
+ return ResourceManager.GetString("Dashboard", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Administrators only.
+ ///
+ public static string DashboardAdministratorsOnly {
+ get {
+ return ResourceManager.GetString("DashboardAdministratorsOnly", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} alliances.
+ ///
+ public static string DashboardAlliances {
+ get {
+ return ResourceManager.GetString("DashboardAlliances", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to All servers are running.
+ ///
+ public static string DashboardAllServersRunning {
+ get {
+ return ResourceManager.GetString("DashboardAllServersRunning", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} banned.
+ ///
+ public static string DashboardBannedAccounts {
+ get {
+ return ResourceManager.GetString("DashboardBannedAccounts", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} characters.
+ ///
+ public static string DashboardCharacters {
+ get {
+ return ResourceManager.GetString("DashboardCharacters", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Collecting data. The history starts when the admin panel is started..
+ ///
+ public static string DashboardCollectingData {
+ get {
+ return ResourceManager.GetString("DashboardCollectingData", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Configuration updates.
+ ///
+ public static string DashboardConfigurationUpdates {
+ get {
+ return ResourceManager.GetString("DashboardConfigurationUpdates", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} connections.
+ ///
+ public static string DashboardConnections {
+ get {
+ return ResourceManager.GetString("DashboardConnections", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Now.
+ ///
+ public static string DashboardCurrent {
+ get {
+ return ResourceManager.GetString("DashboardCurrent", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Database.
+ ///
+ public static string DashboardDatabase {
+ get {
+ return ResourceManager.GetString("DashboardDatabase", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Connected.
+ ///
+ public static string DashboardDatabaseConnected {
+ get {
+ return ResourceManager.GetString("DashboardDatabaseConnected", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Not reachable.
+ ///
+ public static string DashboardDatabaseNotReachable {
+ get {
+ return ResourceManager.GetString("DashboardDatabaseNotReachable", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} h ago.
+ ///
+ public static string DashboardHoursAgo {
+ get {
+ return ResourceManager.GetString("DashboardHoursAgo", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Low.
+ ///
+ public static string DashboardLow {
+ get {
+ return ResourceManager.GetString("DashboardLow", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Manage servers.
+ ///
+ public static string DashboardManageServers {
+ get {
+ return ResourceManager.GetString("DashboardManageServers", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Memory.
+ ///
+ public static string DashboardMemory {
+ get {
+ return ResourceManager.GetString("DashboardMemory", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0:N0} MB working set.
+ ///
+ public static string DashboardMemoryValue {
+ get {
+ return ResourceManager.GetString("DashboardMemoryValue", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to +{0} registered in the last 7 days.
+ ///
+ public static string DashboardNewAccounts {
+ get {
+ return ResourceManager.GetString("DashboardNewAccounts", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Not available.
+ ///
+ public static string DashboardNotAvailable {
+ get {
+ return ResourceManager.GetString("DashboardNotAvailable", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Your game configuration is up to date..
+ ///
+ public static string DashboardNoUpdates {
+ get {
+ return ResourceManager.GetString("DashboardNoUpdates", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to now.
+ ///
+ public static string DashboardNow {
+ get {
+ return ResourceManager.GetString("DashboardNow", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Players online in the last 24 hours.
+ ///
+ public static string DashboardOnlineHistory {
+ get {
+ return ResourceManager.GetString("DashboardOnlineHistory", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Chart of the players online in the last 24 hours. Currently {0}, peak {1}, low {2}..
+ ///
+ public static string DashboardOnlineHistoryDescription {
+ get {
+ return ResourceManager.GetString("DashboardOnlineHistoryDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Open.
+ ///
+ public static string DashboardOpen {
+ get {
+ return ResourceManager.GetString("DashboardOpen", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Peak.
+ ///
+ public static string DashboardPeak {
+ get {
+ return ResourceManager.GetString("DashboardPeak", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to of {0} slots on the running game servers.
+ ///
+ public static string DashboardPlayerCapacity {
+ get {
+ return ResourceManager.GetString("DashboardPlayerCapacity", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Players online.
+ ///
+ public static string DashboardPlayersOnline {
+ get {
+ return ResourceManager.GetString("DashboardPlayersOnline", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Quick actions.
+ ///
+ public static string DashboardQuickActions {
+ get {
+ return ResourceManager.GetString("DashboardQuickActions", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Review updates.
+ ///
+ public static string DashboardReviewUpdates {
+ get {
+ return ResourceManager.GetString("DashboardReviewUpdates", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to sampled every {0} minutes.
+ ///
+ public static string DashboardSampleInterval {
+ get {
+ return ResourceManager.GetString("DashboardSampleInterval", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} not running.
+ ///
+ public static string DashboardServersNotRunning {
+ get {
+ return ResourceManager.GetString("DashboardServersNotRunning", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Servers running.
+ ///
+ public static string DashboardServersRunning {
+ get {
+ return ResourceManager.GetString("DashboardServersRunning", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to updates are available for your game configuration..
+ ///
+ public static string DashboardUpdatesAvailable {
+ get {
+ return ResourceManager.GetString("DashboardUpdatesAvailable", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Uptime.
+ ///
+ public static string DashboardUptime {
+ get {
+ return ResourceManager.GetString("DashboardUptime", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} d {1} h {2} min.
+ ///
+ public static string DashboardUptimeValue {
+ get {
+ return ResourceManager.GetString("DashboardUptimeValue", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Version.
+ ///
+ public static string DashboardVersion {
+ get {
+ return ResourceManager.GetString("DashboardVersion", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Database Snapshot.
///
diff --git a/src/Web/AdminPanel/Properties/Resources.de.resx b/src/Web/AdminPanel/Properties/Resources.de.resx
index 5c0e12bce..c53ed66c2 100644
--- a/src/Web/AdminPanel/Properties/Resources.de.resx
+++ b/src/Web/AdminPanel/Properties/Resources.de.resx
@@ -1926,4 +1926,115 @@
Jede Quelle benötigt einen Namen, und jeder Name darf nur einmal verwendet werden.
+
+ Dashboard
+
+
+ Spieler online
+
+
+ von {0} Plätzen auf den laufenden Spielservern
+
+
+ Laufende Server
+
+
+ {0} nicht gestartet
+
+
+ Alle Server laufen
+
+
+ +{0} in den letzten 7 Tagen registriert
+
+
+ {0} gesperrt
+
+
+ {0} Allianzen
+
+
+ {0} Charaktere
+
+
+ Nicht verfügbar
+
+
+ Spieler online in den letzten 24 Stunden
+
+
+ Diagramm der Spieler online in den letzten 24 Stunden. Aktuell {0}, Höchstwert {1}, Tiefstwert {2}.
+
+
+ alle {0} Minuten erfasst
+
+
+ Daten werden gesammelt. Der Verlauf beginnt mit dem Start des Admin-Panels.
+
+
+ vor {0} Std.
+
+
+ jetzt
+
+
+ Aktuell
+
+
+ Höchstwert
+
+
+ Tiefstwert
+
+
+ Server verwalten
+
+
+ {0} Verbindungen
+
+
+ Nur für Administratoren
+
+
+ Laufzeit
+
+
+ {0} T. {1} Std. {2} Min.
+
+
+ Arbeitsspeicher
+
+
+ {0:N0} MB Arbeitssatz
+
+
+ Version
+
+
+ Datenbank
+
+
+ Verbunden
+
+
+ Nicht erreichbar
+
+
+ Konfigurationsaktualisierungen
+
+
+ Aktualisierungen sind für Ihre Spielkonfiguration verfügbar.
+
+
+ Ihre Spielkonfiguration ist aktuell.
+
+
+ Aktualisierungen prüfen
+
+
+ Schnellzugriff
+
+
+ Öffnen
+
diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx
index 11e14e240..76cbe8c82 100644
--- a/src/Web/AdminPanel/Properties/Resources.resx
+++ b/src/Web/AdminPanel/Properties/Resources.resx
@@ -2017,4 +2017,115 @@
Every source needs a name, and each name may only be used once.
+
+ Dashboard
+
+
+ Players online
+
+
+ of {0} slots on the running game servers
+
+
+ Servers running
+
+
+ {0} not running
+
+
+ All servers are running
+
+
+ +{0} registered in the last 7 days
+
+
+ {0} banned
+
+
+ {0} alliances
+
+
+ {0} characters
+
+
+ Not available
+
+
+ Players online in the last 24 hours
+
+
+ Chart of the players online in the last 24 hours. Currently {0}, peak {1}, low {2}.
+
+
+ sampled every {0} minutes
+
+
+ Collecting data. The history starts when the admin panel is started.
+
+
+ {0} h ago
+
+
+ now
+
+
+ Now
+
+
+ Peak
+
+
+ Low
+
+
+ Manage servers
+
+
+ {0} connections
+
+
+ Administrators only
+
+
+ Uptime
+
+
+ {0} d {1} h {2} min
+
+
+ Memory
+
+
+ {0:N0} MB working set
+
+
+ Version
+
+
+ Database
+
+
+ Connected
+
+
+ Not reachable
+
+
+ Configuration updates
+
+
+ updates are available for your game configuration.
+
+
+ Your game configuration is up to date.
+
+
+ Review updates
+
+
+ Quick actions
+
+
+ Open
+
diff --git a/src/Web/AdminPanel/Properties/Resources.zh-CN.resx b/src/Web/AdminPanel/Properties/Resources.zh-CN.resx
index 3f6074559..f95fc6d5a 100644
--- a/src/Web/AdminPanel/Properties/Resources.zh-CN.resx
+++ b/src/Web/AdminPanel/Properties/Resources.zh-CN.resx
@@ -1789,4 +1789,115 @@
地形编辑器
+
+ 仪表板
+
+
+ 在线玩家
+
+
+ 运行中的游戏服务器共 {0} 个名额
+
+
+ 运行中的服务器
+
+
+ {0} 个未运行
+
+
+ 所有服务器均在运行
+
+
+ 最近 7 天新注册 +{0}
+
+
+ {0} 个已封禁
+
+
+ {0} 个联盟
+
+
+ {0} 个角色
+
+
+ 不可用
+
+
+ 最近 24 小时在线玩家
+
+
+ 最近 24 小时在线玩家图表。当前 {0},峰值 {1},最低值 {2}。
+
+
+ 每 {0} 分钟采样一次
+
+
+ 正在收集数据。历史记录从管理面板启动时开始。
+
+
+ {0} 小时前
+
+
+ 现在
+
+
+ 当前
+
+
+ 峰值
+
+
+ 最低值
+
+
+ 管理服务器
+
+
+ {0} 个连接
+
+
+ 仅限管理员
+
+
+ 运行时间
+
+
+ {0} 天 {1} 小时 {2} 分钟
+
+
+ 内存
+
+
+ 工作集 {0:N0} MB
+
+
+ 版本
+
+
+ 数据库
+
+
+ 已连接
+
+
+ 无法连接
+
+
+ 配置更新
+
+
+ 个更新可用于您的游戏配置。
+
+
+ 您的游戏配置已是最新。
+
+
+ 查看更新
+
+
+ 快捷操作
+
+
+ 打开
+
diff --git a/src/Web/AdminPanel/Services/OnlinePlayerChart.cs b/src/Web/AdminPanel/Services/OnlinePlayerChart.cs
new file mode 100644
index 000000000..c719ea2ce
--- /dev/null
+++ b/src/Web/AdminPanel/Services/OnlinePlayerChart.cs
@@ -0,0 +1,80 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.AdminPanel.Services;
+
+using System.Globalization;
+using System.Text;
+
+///
+/// The data of the chart of the online players, which is drawn as SVG.
+///
+/// The points of the line, in the format of the SVG points attribute.
+/// The points of the area below the line, in the format of the SVG points attribute.
+/// The current number of online players, i.e. of the latest sample.
+/// The highest number of online players.
+/// The lowest number of online players.
+public sealed record OnlinePlayerChart(string LinePoints, string AreaPoints, int Current, int Peak, int Low)
+{
+ ///
+ /// The width of the coordinate system of the chart.
+ ///
+ public const int Width = 600;
+
+ ///
+ /// The height of the coordinate system of the chart.
+ ///
+ public const int Height = 160;
+
+ ///
+ /// The minimum value of the y-axis, so that small numbers are not drawn as huge spikes.
+ ///
+ private const int MinimumScale = 10;
+
+ ///
+ /// Creates the chart of the samples of the which ends at the specified time.
+ ///
+ /// The samples, ordered by their timestamp.
+ /// The end of the time window (UTC).
+ /// The chart; or null, if there are not enough samples to draw a line.
+ public static OnlinePlayerChart? Create(IReadOnlyList samples, DateTime now)
+ {
+ var windowStart = now - OnlinePlayerHistory.Window;
+ var visibleSamples = samples.Where(s => s.Timestamp >= windowStart).ToList();
+ if (visibleSamples.Count < 2)
+ {
+ return null;
+ }
+
+ var peak = visibleSamples.Max(s => s.PlayerCount);
+ var low = visibleSamples.Min(s => s.PlayerCount);
+
+ // Leave some space above the peak, so the line doesn't touch the top border.
+ var scale = Math.Max(peak, MinimumScale) * 1.1;
+ var line = new StringBuilder();
+ foreach (var sample in visibleSamples)
+ {
+ var x = (sample.Timestamp - windowStart) / OnlinePlayerHistory.Window * Width;
+ var y = Height - (sample.PlayerCount / scale * Height);
+ AppendPoint(line, x, y);
+ }
+
+ var firstX = (visibleSamples[0].Timestamp - windowStart) / OnlinePlayerHistory.Window * Width;
+ var lastX = (visibleSamples[^1].Timestamp - windowStart) / OnlinePlayerHistory.Window * Width;
+ var area = new StringBuilder();
+ AppendPoint(area, firstX, Height);
+ area.Append(line);
+ AppendPoint(area, lastX, Height);
+
+ return new OnlinePlayerChart(line.ToString().TrimEnd(), area.ToString().TrimEnd(), visibleSamples[^1].PlayerCount, peak, low);
+ }
+
+ private static void AppendPoint(StringBuilder builder, double x, double y)
+ {
+ builder.Append(x.ToString("0.#", CultureInfo.InvariantCulture))
+ .Append(',')
+ .Append(y.ToString("0.#", CultureInfo.InvariantCulture))
+ .Append(' ');
+ }
+}
diff --git a/src/Web/AdminPanel/Services/OnlinePlayerHistory.cs b/src/Web/AdminPanel/Services/OnlinePlayerHistory.cs
new file mode 100644
index 000000000..5f083587e
--- /dev/null
+++ b/src/Web/AdminPanel/Services/OnlinePlayerHistory.cs
@@ -0,0 +1,122 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.AdminPanel.Services;
+
+using System.Threading;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using MUnique.OpenMU.Interfaces;
+
+///
+/// Keeps the history of the number of online players of the last ,
+/// by sampling the game servers of the every .
+///
+///
+/// The player count is also exported as a metric of the game context (see GameContext.PlayerCount),
+/// but metrics are only observable in the process which records them. The admin panel can run in
+/// its own process (distributed deployment), so we sample the ,
+/// which provides the player counts in both deployments.
+/// The history is kept in memory, so it starts over when the admin panel is restarted.
+///
+public sealed class OnlinePlayerHistory : BackgroundService
+{
+ ///
+ /// The interval in which the number of online players is sampled.
+ ///
+ public static readonly TimeSpan SampleInterval = TimeSpan.FromMinutes(5);
+
+ ///
+ /// The time window which is kept in the history.
+ ///
+ public static readonly TimeSpan Window = TimeSpan.FromHours(24);
+
+ private readonly IServerProvider _serverProvider;
+
+ private readonly TimeProvider _timeProvider;
+
+ private readonly ILogger _logger;
+
+ private readonly Queue _samples = new();
+
+ private readonly object _syncRoot = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The server provider.
+ /// The logger.
+ public OnlinePlayerHistory(IServerProvider serverProvider, ILogger logger)
+ : this(serverProvider, TimeProvider.System, logger)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The server provider.
+ /// The time provider.
+ /// The logger.
+ internal OnlinePlayerHistory(IServerProvider serverProvider, TimeProvider timeProvider, ILogger logger)
+ {
+ this._serverProvider = serverProvider;
+ this._timeProvider = timeProvider;
+ this._logger = logger;
+ }
+
+ ///
+ /// Occurs when a sample was added.
+ ///
+ public event EventHandler? SampleAdded;
+
+ ///
+ /// Gets the samples of the last , ordered by their timestamp.
+ ///
+ /// The samples of the last .
+ public IReadOnlyList GetSamples()
+ {
+ lock (this._syncRoot)
+ {
+ return this._samples.ToList();
+ }
+ }
+
+ ///
+ /// Takes a sample of the current number of online players.
+ ///
+ internal void TakeSample()
+ {
+ var now = this._timeProvider.GetUtcNow().UtcDateTime;
+ var sample = new OnlinePlayerSample(now, ServerStatistics.GetOnlinePlayerCount(this._serverProvider.Servers));
+ lock (this._syncRoot)
+ {
+ this._samples.Enqueue(sample);
+ while (this._samples.Peek().Timestamp < now - Window)
+ {
+ this._samples.Dequeue();
+ }
+ }
+
+ this.SampleAdded?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ using var timer = new PeriodicTimer(SampleInterval, this._timeProvider);
+ do
+ {
+ try
+ {
+ this.TakeSample();
+ }
+ catch (Exception ex)
+ {
+ // An exception must not end the sampling, or even stop the host.
+ this._logger.LogError(ex, "Couldn't take a sample of the online players.");
+ }
+ }
+ while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
+ }
+}
diff --git a/src/Web/AdminPanel/Services/OnlinePlayerSample.cs b/src/Web/AdminPanel/Services/OnlinePlayerSample.cs
new file mode 100644
index 000000000..951b2dd81
--- /dev/null
+++ b/src/Web/AdminPanel/Services/OnlinePlayerSample.cs
@@ -0,0 +1,12 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.AdminPanel.Services;
+
+///
+/// A sample of the number of online players at a point in time.
+///
+/// The point in time (UTC) when the sample was taken.
+/// The number of online players.
+public sealed record OnlinePlayerSample(DateTime Timestamp, int PlayerCount);
diff --git a/src/Web/AdminPanel/Services/ServerStatistics.cs b/src/Web/AdminPanel/Services/ServerStatistics.cs
new file mode 100644
index 000000000..2d934ccb7
--- /dev/null
+++ b/src/Web/AdminPanel/Services/ServerStatistics.cs
@@ -0,0 +1,49 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.AdminPanel.Services;
+
+using MUnique.OpenMU.Interfaces;
+
+///
+/// Calculates statistics about the s.
+///
+public static class ServerStatistics
+{
+ ///
+ /// Gets the number of players which are online on the game servers.
+ ///
+ /// The servers.
+ /// The number of players which are online on the game servers.
+ public static int GetOnlinePlayerCount(IEnumerable servers)
+ {
+ return servers
+ .Where(s => s.Type == ServerType.GameServer && s.ServerState != ServerState.Timeout)
+ .Sum(s => s.CurrentConnections);
+ }
+
+ ///
+ /// Gets the number of players which can be online on the started game servers.
+ ///
+ /// The servers.
+ ///
+ /// The number of players which can be online on the started game servers;
+ /// or null, if one of them doesn't limit the number of players.
+ ///
+ public static int? GetPlayerCapacity(IEnumerable servers)
+ {
+ long capacity = 0;
+ foreach (var server in servers.Where(s => s.Type == ServerType.GameServer && s.ServerState == ServerState.Started))
+ {
+ if (server.MaximumConnections == int.MaxValue)
+ {
+ return null;
+ }
+
+ capacity += server.MaximumConnections;
+ }
+
+ return (int)Math.Min(capacity, int.MaxValue);
+ }
+}
diff --git a/src/Web/AdminPanel/WebApplicationExtensions.cs b/src/Web/AdminPanel/WebApplicationExtensions.cs
index 692ebd4a0..9a6b2644e 100644
--- a/src/Web/AdminPanel/WebApplicationExtensions.cs
+++ b/src/Web/AdminPanel/WebApplicationExtensions.cs
@@ -101,6 +101,8 @@ public static WebApplicationBuilder AddAdminPanel(this WebApplicationBuilder bui
services.AddScoped>(serviceProvider => serviceProvider.GetService()!);
services.AddScoped();
services.AddScoped();
+ services.AddSingleton();
+ services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService());
services.AddScoped();
// The analyzers are only created when the network analyzer page is actually used,
diff --git a/tests/MUnique.OpenMU.Tests/InMemoryRepositoryProviderTest.cs b/tests/MUnique.OpenMU.Tests/InMemoryRepositoryProviderTest.cs
new file mode 100644
index 000000000..5a1a82d4d
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/InMemoryRepositoryProviderTest.cs
@@ -0,0 +1,43 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using System.Threading;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.Persistence.InMemory;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class InMemoryRepositoryProviderTest
+{
+ ///
+ /// Tests that concurrent contexts which access a repository the first time get the same repository.
+ /// The game servers of the demo mode did that at startup, which corrupted the dictionary of the
+ /// repositories, or failed because a repository was registered twice.
+ ///
+ [Test]
+ public async Task ConcurrentFirstAccessCreatesOneRepositoryAsync()
+ {
+ for (var i = 0; i < 20; i++)
+ {
+ var provider = new InMemoryRepositoryProvider();
+ using var start = new ManualResetEventSlim();
+ var tasks = Enumerable.Range(0, 8)
+ .Select(_ => Task.Run(() =>
+ {
+ start.Wait();
+ return provider.GetRepository(typeof(Guild));
+ }))
+ .ToList();
+ start.Set();
+
+ var repositories = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ Assert.That(repositories.Distinct().Count(), Is.EqualTo(1));
+ }
+ }
+}
diff --git a/tests/MUnique.OpenMU.Web.Tests/Dashboard/DashboardStatisticsTests.cs b/tests/MUnique.OpenMU.Web.Tests/Dashboard/DashboardStatisticsTests.cs
new file mode 100644
index 000000000..6f8be9227
--- /dev/null
+++ b/tests/MUnique.OpenMU.Web.Tests/Dashboard/DashboardStatisticsTests.cs
@@ -0,0 +1,89 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.Tests.Dashboard;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.Persistence;
+using MUnique.OpenMU.Persistence.InMemory;
+
+///
+/// Tests for the statistics queries of the persistence contexts, which are shown on the dashboard.
+///
+[TestFixture]
+public class DashboardStatisticsTests
+{
+ private InMemoryPersistenceContextProvider _persistenceContextProvider = null!;
+
+ ///
+ /// Setups the test objects.
+ ///
+ [SetUp]
+ public void Setup()
+ {
+ this._persistenceContextProvider = new InMemoryPersistenceContextProvider();
+ }
+
+ ///
+ /// The account statistics count the accounts of players, their characters, new and banned ones.
+ ///
+ [Test]
+ public async Task AccountStatisticsAsync()
+ {
+ var now = DateTime.UtcNow;
+ using (var context = this._persistenceContextProvider.CreateNewPlayerContext(new GameConfiguration()))
+ {
+ CreateAccount(context, now.AddDays(-30), AccountState.Normal, characters: 2);
+ CreateAccount(context, now.AddDays(-1), AccountState.Normal, characters: 1);
+ CreateAccount(context, now.AddDays(-30), AccountState.Banned, characters: 0);
+ CreateAccount(context, now.AddDays(-30), AccountState.TemporarilyBanned, characters: 0);
+ CreateAccount(context, now.AddDays(-1), AccountState.Normal, characters: 1).IsBot = true;
+ CreateAccount(context, now.AddDays(-1), AccountState.Normal, characters: 1).IsTemplate = true;
+ await context.SaveChangesAsync().ConfigureAwait(false);
+ }
+
+ using var playerContext = this._persistenceContextProvider.CreateNewPlayerContext(new GameConfiguration());
+ var statistics = await playerContext.GetAccountStatisticsAsync(now.AddDays(-7)).ConfigureAwait(false);
+
+ Assert.That(statistics, Is.EqualTo(new AccountStatistics(AccountCount: 4, NewAccountCount: 1, BannedAccountCount: 2, CharacterCount: 3)));
+ }
+
+ ///
+ /// The guild statistics count the guilds and the alliances.
+ ///
+ [Test]
+ public async Task GuildStatisticsAsync()
+ {
+ using (var context = this._persistenceContextProvider.CreateNewGuildContext())
+ {
+ var firstMaster = context.CreateNew();
+ var secondMaster = context.CreateNew();
+ context.CreateNew().AllianceGuild = firstMaster;
+ context.CreateNew().AllianceGuild = firstMaster;
+ context.CreateNew().AllianceGuild = secondMaster;
+ context.CreateNew();
+ await context.SaveChangesAsync().ConfigureAwait(false);
+ }
+
+ using var guildContext = this._persistenceContextProvider.CreateNewGuildContext();
+ var statistics = await guildContext.GetGuildStatisticsAsync().ConfigureAwait(false);
+
+ Assert.That(statistics, Is.EqualTo(new GuildStatistics(GuildCount: 6, AllianceCount: 2)));
+ }
+
+ private static Account CreateAccount(IContext context, DateTime registrationDate, AccountState state, int characters)
+ {
+ var account = context.CreateNew();
+ account.LoginName = Guid.NewGuid().ToString("N")[..10];
+ account.RegistrationDate = registrationDate;
+ account.State = state;
+ for (var i = 0; i < characters; i++)
+ {
+ account.Characters.Add(context.CreateNew());
+ }
+
+ return account;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Web.Tests/Dashboard/IndexPageTests.cs b/tests/MUnique.OpenMU.Web.Tests/Dashboard/IndexPageTests.cs
new file mode 100644
index 000000000..5830a65b6
--- /dev/null
+++ b/tests/MUnique.OpenMU.Web.Tests/Dashboard/IndexPageTests.cs
@@ -0,0 +1,120 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.Tests.Dashboard;
+
+using System.Threading;
+using Bunit;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Persistence;
+using MUnique.OpenMU.Persistence.Initialization.Updates;
+using MUnique.OpenMU.PlugIns;
+using MUnique.OpenMU.Web.AdminPanel.Auth;
+using MUnique.OpenMU.Web.AdminPanel.Pages;
+using MUnique.OpenMU.Web.AdminPanel.Properties;
+using MUnique.OpenMU.Web.AdminPanel.Services;
+using MUnique.OpenMU.Web.Shared.Services;
+
+///
+/// Tests for the dashboard on the start page ().
+///
+[TestFixture]
+public class IndexPageTests
+{
+ ///
+ /// The statistics of the servers and of the database are shown.
+ ///
+ [Test]
+ public void ShowsStatistics()
+ {
+ using var context = CreateContext(isAdministrator: false);
+
+ var page = context.Render();
+
+ page.WaitForAssertion(() =>
+ {
+ var values = page.FindAll(".statistic-value").Select(v => v.TextContent.Trim()).ToList();
+ Assert.That(values[0], Is.EqualTo(15.ToString("N0")));
+ Assert.That(values[1], Does.StartWith("2").And.EndWith("/ 3"));
+ Assert.That(values[2], Is.EqualTo(1234.ToString("N0")));
+ Assert.That(values[3], Is.EqualTo(56.ToString("N0")));
+ });
+ }
+
+ ///
+ /// The cards with system information and actions for administrators are not shown to other users.
+ ///
+ [Test]
+ public void HidesAdministratorCardsForViewers()
+ {
+ using var context = CreateContext(isAdministrator: false);
+
+ var page = context.Render();
+
+ Assert.That(page.Markup, Does.Not.Contain(Resources.DashboardAdministratorsOnly));
+ }
+
+ ///
+ /// The cards with system information and actions are shown to administrators.
+ ///
+ [Test]
+ public void ShowsAdministratorCardsForAdministrators()
+ {
+ using var context = CreateContext(isAdministrator: true);
+
+ var page = context.Render();
+
+ page.WaitForAssertion(() =>
+ {
+ Assert.That(page.Markup, Does.Contain(Resources.DashboardUptime));
+ Assert.That(page.Markup, Does.Contain(Resources.DashboardConfigurationUpdates));
+ });
+ }
+
+ private static BunitContext CreateContext(bool isAdministrator)
+ {
+ var context = new BunitContext();
+ context.JSInterop.Mode = JSRuntimeMode.Loose;
+ var authorization = context.AddAuthorization();
+ authorization.SetAuthorized("user");
+ if (isAdministrator)
+ {
+ authorization.SetPolicies(AdminPolicies.Administrator);
+ }
+
+ var serverProvider = TestServers.CreateProvider(
+ TestServers.Create(ServerType.GameServer, ServerState.Started, 10),
+ TestServers.Create(ServerType.GameServer, ServerState.Stopped, 5),
+ TestServers.Create(ServerType.ConnectServer, ServerState.Started, 30));
+
+ var playerContext = new Mock();
+ playerContext.Setup(c => c.GetAccountStatisticsAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new AccountStatistics(1234, 5, 2, 2000));
+ var accountSource = new Mock>();
+ accountSource.Setup(s => s.GetContextAsync(It.IsAny())).ReturnsAsync(playerContext.Object);
+
+ var guildContext = new Mock();
+ guildContext.Setup(c => c.GetGuildStatisticsAsync(It.IsAny())).ReturnsAsync(new GuildStatistics(56, 3));
+ var contextProvider = new Mock();
+ contextProvider.Setup(p => p.CreateNewGuildContext()).Returns(guildContext.Object);
+ contextProvider.Setup(p => p.CanConnectToDatabaseAsync(It.IsAny())).ReturnsAsync(true);
+ var plugInManager = new PlugInManager(null, NullLoggerFactory.Instance, null, null);
+
+ context.Services.AddSingleton(serverProvider);
+ context.Services.AddSingleton(new OnlinePlayerHistory(serverProvider, NullLogger.Instance));
+ context.Services.AddSingleton(accountSource.Object);
+ context.Services.AddSingleton(contextProvider.Object);
+ context.Services.AddSingleton(contextProvider.Object);
+ context.Services.AddSingleton(new DataUpdateService(contextProvider.Object, plugInManager));
+ context.Services.AddSingleton(new CastleSiegeManagementService(serverProvider));
+ context.Services.AddSingleton();
+ context.Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
+ return context;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Web.Tests/Dashboard/ManualTimeProvider.cs b/tests/MUnique.OpenMU.Web.Tests/Dashboard/ManualTimeProvider.cs
new file mode 100644
index 000000000..151d6916b
--- /dev/null
+++ b/tests/MUnique.OpenMU.Web.Tests/Dashboard/ManualTimeProvider.cs
@@ -0,0 +1,19 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.Tests.Dashboard;
+
+///
+/// A whose time is set by the test.
+///
+internal sealed class ManualTimeProvider : TimeProvider
+{
+ ///
+ /// Gets or sets the current time.
+ ///
+ public DateTimeOffset UtcNow { get; set; } = new(2026, 10, 18, 12, 0, 0, TimeSpan.Zero);
+
+ ///
+ public override DateTimeOffset GetUtcNow() => this.UtcNow;
+}
diff --git a/tests/MUnique.OpenMU.Web.Tests/Dashboard/OnlinePlayerChartTests.cs b/tests/MUnique.OpenMU.Web.Tests/Dashboard/OnlinePlayerChartTests.cs
new file mode 100644
index 000000000..74f9f4635
--- /dev/null
+++ b/tests/MUnique.OpenMU.Web.Tests/Dashboard/OnlinePlayerChartTests.cs
@@ -0,0 +1,74 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.Tests.Dashboard;
+
+using System.Globalization;
+using MUnique.OpenMU.Web.AdminPanel.Services;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class OnlinePlayerChartTests
+{
+ private static readonly DateTime Now = new(2026, 10, 18, 12, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// A line needs at least two samples.
+ ///
+ [Test]
+ public void NoChartWithSingleSample()
+ {
+ Assert.That(OnlinePlayerChart.Create([new OnlinePlayerSample(Now, 5)], Now), Is.Null);
+ }
+
+ ///
+ /// The current, peak and low values are taken from the samples.
+ ///
+ [Test]
+ public void ValuesAreTakenFromSamples()
+ {
+ var chart = OnlinePlayerChart.Create(
+ [
+ new OnlinePlayerSample(Now.AddHours(-3), 20),
+ new OnlinePlayerSample(Now.AddHours(-2), 80),
+ new OnlinePlayerSample(Now.AddHours(-1), 5),
+ new OnlinePlayerSample(Now, 30),
+ ],
+ Now);
+
+ Assert.That(chart, Is.Not.Null);
+ Assert.That((chart!.Current, chart.Peak, chart.Low), Is.EqualTo((30, 80, 5)));
+ }
+
+ ///
+ /// The points are placed by their time within the window, and are inside the coordinate system.
+ /// Samples which are older than the window are not drawn.
+ ///
+ [Test]
+ public void PointsAreInsideOfCoordinateSystem()
+ {
+ var chart = OnlinePlayerChart.Create(
+ [
+ new OnlinePlayerSample(Now - OnlinePlayerHistory.Window - TimeSpan.FromHours(1), 1000),
+ new OnlinePlayerSample(Now - (OnlinePlayerHistory.Window / 2), 0),
+ new OnlinePlayerSample(Now, 50),
+ ],
+ Now);
+
+ var points = chart!.LinePoints.Split(' ')
+ .Select(p => p.Split(','))
+ .Select(p => (X: double.Parse(p[0], CultureInfo.InvariantCulture), Y: double.Parse(p[1], CultureInfo.InvariantCulture)))
+ .ToList();
+ Assert.Multiple(() =>
+ {
+ Assert.That(chart.Peak, Is.EqualTo(50));
+ Assert.That(points, Has.Count.EqualTo(2));
+ Assert.That(points[0], Is.EqualTo((OnlinePlayerChart.Width / 2.0, (double)OnlinePlayerChart.Height)));
+ Assert.That(points[1].X, Is.EqualTo(OnlinePlayerChart.Width));
+ Assert.That(points[1].Y, Is.InRange(0, OnlinePlayerChart.Height));
+ });
+ }
+}
diff --git a/tests/MUnique.OpenMU.Web.Tests/Dashboard/OnlinePlayerHistoryTests.cs b/tests/MUnique.OpenMU.Web.Tests/Dashboard/OnlinePlayerHistoryTests.cs
new file mode 100644
index 000000000..65c1c8e84
--- /dev/null
+++ b/tests/MUnique.OpenMU.Web.Tests/Dashboard/OnlinePlayerHistoryTests.cs
@@ -0,0 +1,69 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.Tests.Dashboard;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Web.AdminPanel.Services;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class OnlinePlayerHistoryTests
+{
+ ///
+ /// A sample contains the number of players on the game servers at the time it was taken.
+ ///
+ [Test]
+ public void SampleContainsOnlinePlayers()
+ {
+ var timeProvider = new ManualTimeProvider();
+ var serverProvider = TestServers.CreateProvider(
+ TestServers.Create(ServerType.GameServer, ServerState.Started, 42),
+ TestServers.Create(ServerType.ConnectServer, ServerState.Started, 100));
+ using var history = new OnlinePlayerHistory(serverProvider, timeProvider, NullLogger.Instance);
+
+ history.TakeSample();
+
+ Assert.That(history.GetSamples(), Is.EqualTo(new[] { new OnlinePlayerSample(timeProvider.UtcNow.UtcDateTime, 42) }));
+ }
+
+ ///
+ /// Samples which are older than the window are removed.
+ ///
+ [Test]
+ public void SamplesOutsideOfWindowAreRemoved()
+ {
+ var timeProvider = new ManualTimeProvider();
+ var start = timeProvider.UtcNow;
+ using var history = new OnlinePlayerHistory(TestServers.CreateProvider(), timeProvider, NullLogger.Instance);
+
+ history.TakeSample();
+ timeProvider.UtcNow = start + OnlinePlayerHistory.Window;
+ history.TakeSample();
+ timeProvider.UtcNow = start + OnlinePlayerHistory.Window + OnlinePlayerHistory.SampleInterval;
+ history.TakeSample();
+
+ Assert.That(
+ history.GetSamples().Select(s => s.Timestamp),
+ Is.EqualTo(new[] { (start + OnlinePlayerHistory.Window).UtcDateTime, timeProvider.UtcNow.UtcDateTime }));
+ }
+
+ ///
+ /// Adding a sample raises the event, so that the dashboard can update its chart.
+ ///
+ [Test]
+ public void SampleAddedIsRaised()
+ {
+ using var history = new OnlinePlayerHistory(TestServers.CreateProvider(), new ManualTimeProvider(), NullLogger.Instance);
+ var raised = 0;
+ history.SampleAdded += (_, _) => raised++;
+
+ history.TakeSample();
+
+ Assert.That(raised, Is.EqualTo(1));
+ }
+}
diff --git a/tests/MUnique.OpenMU.Web.Tests/Dashboard/ServerStatisticsTests.cs b/tests/MUnique.OpenMU.Web.Tests/Dashboard/ServerStatisticsTests.cs
new file mode 100644
index 000000000..dad49809a
--- /dev/null
+++ b/tests/MUnique.OpenMU.Web.Tests/Dashboard/ServerStatisticsTests.cs
@@ -0,0 +1,79 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.Tests.Dashboard;
+
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Web.AdminPanel.Services;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class ServerStatisticsTests
+{
+ ///
+ /// Only the players of the game servers are counted, not the connections of the other servers.
+ ///
+ [Test]
+ public void OnlinePlayerCountIncludesOnlyGameServers()
+ {
+ var servers = new[]
+ {
+ TestServers.Create(ServerType.GameServer, ServerState.Started, 10),
+ TestServers.Create(ServerType.GameServer, ServerState.Started, 5),
+ TestServers.Create(ServerType.ConnectServer, ServerState.Started, 100),
+ TestServers.Create(ServerType.ChatServer, ServerState.Started, 7),
+ };
+
+ Assert.That(ServerStatistics.GetOnlinePlayerCount(servers), Is.EqualTo(15));
+ }
+
+ ///
+ /// The players of a game server which timed out are not counted, since its count is outdated.
+ ///
+ [Test]
+ public void OnlinePlayerCountExcludesTimedOutServers()
+ {
+ var servers = new[]
+ {
+ TestServers.Create(ServerType.GameServer, ServerState.Started, 10),
+ TestServers.Create(ServerType.GameServer, ServerState.Timeout, 5),
+ };
+
+ Assert.That(ServerStatistics.GetOnlinePlayerCount(servers), Is.EqualTo(10));
+ }
+
+ ///
+ /// The capacity is the sum of the maximum connections of the started game servers.
+ ///
+ [Test]
+ public void PlayerCapacityIncludesOnlyStartedGameServers()
+ {
+ var servers = new[]
+ {
+ TestServers.Create(ServerType.GameServer, ServerState.Started, 0, 1000),
+ TestServers.Create(ServerType.GameServer, ServerState.Started, 0, 500),
+ TestServers.Create(ServerType.GameServer, ServerState.Stopped, 0, 1000),
+ TestServers.Create(ServerType.ConnectServer, ServerState.Started, 0, 5000),
+ };
+
+ Assert.That(ServerStatistics.GetPlayerCapacity(servers), Is.EqualTo(1500));
+ }
+
+ ///
+ /// The capacity is unknown, when a game server doesn't limit its connections.
+ ///
+ [Test]
+ public void PlayerCapacityIsNullForUnlimitedGameServer()
+ {
+ var servers = new[]
+ {
+ TestServers.Create(ServerType.GameServer, ServerState.Started, 0, 1000),
+ TestServers.Create(ServerType.GameServer, ServerState.Started, 0, int.MaxValue),
+ };
+
+ Assert.That(ServerStatistics.GetPlayerCapacity(servers), Is.Null);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Web.Tests/Dashboard/TestServers.cs b/tests/MUnique.OpenMU.Web.Tests/Dashboard/TestServers.cs
new file mode 100644
index 000000000..df5891a3d
--- /dev/null
+++ b/tests/MUnique.OpenMU.Web.Tests/Dashboard/TestServers.cs
@@ -0,0 +1,45 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Web.Tests.Dashboard;
+
+using Moq;
+using MUnique.OpenMU.Interfaces;
+
+///
+/// Creates s for the dashboard tests.
+///
+internal static class TestServers
+{
+ ///
+ /// Creates a server.
+ ///
+ /// The server type.
+ /// The server state.
+ /// The current connections.
+ /// The maximum connections.
+ /// The created server.
+ public static IManageableServer Create(ServerType type, ServerState state, int currentConnections, int maximumConnections = 1000)
+ {
+ var server = new Mock();
+ server.Setup(s => s.Type).Returns(type);
+ server.Setup(s => s.ServerState).Returns(state);
+ server.Setup(s => s.CurrentConnections).Returns(currentConnections);
+ server.Setup(s => s.MaximumConnections).Returns(maximumConnections);
+ server.Setup(s => s.Description).Returns($"{type} {currentConnections}");
+ return server.Object;
+ }
+
+ ///
+ /// Creates a server provider.
+ ///
+ /// The servers.
+ /// The created server provider.
+ public static IServerProvider CreateProvider(params IManageableServer[] servers)
+ {
+ var provider = new Mock();
+ provider.Setup(p => p.Servers).Returns(servers.ToList());
+ return provider.Object;
+ }
+}