From fbe917a6a4981db8f78861f65d2171a65920efb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:51:56 +0000 Subject: [PATCH 1/3] feat(admin-panel): add a dashboard with server statistics to the start page The start page now shows card-styled statistics instead of only a welcome text: players online and capacity, running servers, accounts (new and banned), guilds and alliances, a chart of the players online in the last 24 hours, the server list with their load, and quick actions (global message and links). Administrators additionally see the castle siege state, system information and the available configuration updates. The online history is sampled every 5 minutes from the IServerProvider, because the PlayerCount metric is only observable in-process, while the admin panel can run in its own process in the distributed deployment. The global message form of the servers page was extracted into a component, so that the dashboard can reuse it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XowA25b3pgLghrNZSZRwTi --- docs-website/docs/admin-panel/overview.md | 28 ++ src/Persistence/AccountStatistics.cs | 17 + .../EntityFramework/GuildServerContext.cs | 14 + .../EntityFramework/PlayerContext.cs | 17 + src/Persistence/GuildStatistics.cs | 12 + src/Persistence/IGuildServerContext.cs | 7 + src/Persistence/IPlayerContext.cs | 8 + .../InMemory/GuildServerInMemoryContext.cs | 12 + .../InMemory/PlayerInMemoryContext.cs | 12 + .../Dashboard/CastleSiegeCard.razor | 59 ++++ .../Dashboard/ConfigurationUpdatesCard.razor | 62 ++++ .../Dashboard/OnlinePlayersChart.razor | 67 ++++ .../Dashboard/OnlinePlayersChart.razor.css | 22 ++ .../Components/Dashboard/StatisticCard.razor | 39 ++ .../Dashboard/StatisticCard.razor.css | 5 + .../Components/Dashboard/SystemInfoCard.razor | 88 +++++ .../Components/GlobalMessageForm.razor | 136 +++++++ src/Web/AdminPanel/Pages/Index.razor | 118 ++++++- src/Web/AdminPanel/Pages/Index.razor.cs | 185 ++++++++++ src/Web/AdminPanel/Pages/Index.razor.css | 26 ++ src/Web/AdminPanel/Pages/Servers.razor | 95 +---- .../Properties/Resources.Designer.cs | 333 ++++++++++++++++++ .../AdminPanel/Properties/Resources.de.resx | 111 ++++++ src/Web/AdminPanel/Properties/Resources.resx | 111 ++++++ .../Properties/Resources.zh-CN.resx | 111 ++++++ .../AdminPanel/Services/OnlinePlayerChart.cs | 80 +++++ .../Services/OnlinePlayerHistory.cs | 122 +++++++ .../AdminPanel/Services/OnlinePlayerSample.cs | 12 + .../AdminPanel/Services/ServerStatistics.cs | 49 +++ .../AdminPanel/WebApplicationExtensions.cs | 2 + .../Dashboard/DashboardStatisticsTests.cs | 89 +++++ .../Dashboard/IndexPageTests.cs | 120 +++++++ .../Dashboard/ManualTimeProvider.cs | 19 + .../Dashboard/OnlinePlayerChartTests.cs | 74 ++++ .../Dashboard/OnlinePlayerHistoryTests.cs | 69 ++++ .../Dashboard/ServerStatisticsTests.cs | 79 +++++ .../Dashboard/TestServers.cs | 45 +++ 37 files changed, 2359 insertions(+), 96 deletions(-) create mode 100644 src/Persistence/AccountStatistics.cs create mode 100644 src/Persistence/GuildStatistics.cs create mode 100644 src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor create mode 100644 src/Web/AdminPanel/Components/Dashboard/ConfigurationUpdatesCard.razor create mode 100644 src/Web/AdminPanel/Components/Dashboard/OnlinePlayersChart.razor create mode 100644 src/Web/AdminPanel/Components/Dashboard/OnlinePlayersChart.razor.css create mode 100644 src/Web/AdminPanel/Components/Dashboard/StatisticCard.razor create mode 100644 src/Web/AdminPanel/Components/Dashboard/StatisticCard.razor.css create mode 100644 src/Web/AdminPanel/Components/Dashboard/SystemInfoCard.razor create mode 100644 src/Web/AdminPanel/Components/GlobalMessageForm.razor create mode 100644 src/Web/AdminPanel/Pages/Index.razor.cs create mode 100644 src/Web/AdminPanel/Pages/Index.razor.css create mode 100644 src/Web/AdminPanel/Services/OnlinePlayerChart.cs create mode 100644 src/Web/AdminPanel/Services/OnlinePlayerHistory.cs create mode 100644 src/Web/AdminPanel/Services/OnlinePlayerSample.cs create mode 100644 src/Web/AdminPanel/Services/ServerStatistics.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/Dashboard/DashboardStatisticsTests.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/Dashboard/IndexPageTests.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/Dashboard/ManualTimeProvider.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/Dashboard/OnlinePlayerChartTests.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/Dashboard/OnlinePlayerHistoryTests.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/Dashboard/ServerStatisticsTests.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/Dashboard/TestServers.cs diff --git a/docs-website/docs/admin-panel/overview.md b/docs-website/docs/admin-panel/overview.md index 24fffdaf9f..5c20b82433 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 0000000000..7efc4e80fc --- /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/EntityFramework/GuildServerContext.cs b/src/Persistence/EntityFramework/GuildServerContext.cs index 2e2aae3590..d7c14e8db0 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 4c94fd3f94..6b35c09c38 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 0000000000..d02975e347 --- /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 f0b9f48c54..1004c8016c 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 6218425622..aac55879db 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 d3a17b5fec..b1a2459be2 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/PlayerInMemoryContext.cs b/src/Persistence/InMemory/PlayerInMemoryContext.cs index 173dee7aab..c0f060e20d 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 0000000000..0ecc4f9507 --- /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) +{ +
+
+

@Resources.CastleSiege

+ @Resources.DashboardOpen +
+
+
+
@Resources.CastleSiegeState
+
@snapshot.State
+
@Resources.CastleSiegeEndsUtc
+
@snapshot.StateEndTimeUtc.ToString("u")
+
@Resources.CastleSiegeOwner
+
@(snapshot.IsOccupied ? snapshot.OwnerGuildName ?? snapshot.OwnerGuildId?.ToString() : Resources.CastleSiegeUnoccupied)
+
@Resources.CastleSiegeRegistrations
+
@snapshot.Registrations.Count
+
+
+
+} + +@code { + private CastleSiegeAdministrationSnapshot? _snapshot; + + /// + /// Gets or sets the castle siege management service. + /// + [Inject] + public CastleSiegeManagementService CastleSiegeService { get; set; } = null!; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(true); + + // The castle siege runs on one game server, so we show the first one which provides it. + foreach (var server in this.CastleSiegeService.AvailableGameServers) + { + try + { + var result = await this.CastleSiegeService.GetSnapshotAsync(server.Id).ConfigureAwait(true); + if (result.Snapshot is { } snapshot) + { + this._snapshot = snapshot; + return; + } + } + catch + { + // The server may not be started; try the next one. + } + } + } +} diff --git a/src/Web/AdminPanel/Components/Dashboard/ConfigurationUpdatesCard.razor b/src/Web/AdminPanel/Components/Dashboard/ConfigurationUpdatesCard.razor new file mode 100644 index 0000000000..ac39d47418 --- /dev/null +++ b/src/Web/AdminPanel/Components/Dashboard/ConfigurationUpdatesCard.razor @@ -0,0 +1,62 @@ +@using MUnique.OpenMU.Persistence.Initialization.Updates +@using MUnique.OpenMU.Web.AdminPanel.Properties + +
+
+

@Resources.DashboardConfigurationUpdates

+ @Resources.DashboardAdministratorsOnly +
+
+ @switch (this._availableUpdates) + { + case null when this._isLoading: + @Resources.Loading + break; + case null: + @Resources.DashboardNotAvailable + break; + case 0: + @Resources.DashboardNoUpdates + break; + default: +
+ @this._availableUpdates + @Resources.DashboardUpdatesAvailable +
+ @Resources.DashboardReviewUpdates + break; + } +
+
+ +@code { + private int? _availableUpdates; + + private bool _isLoading = true; + + /// + /// Gets or sets the update service. + /// + [Inject] + public DataUpdateService UpdateService { get; set; } = null!; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(true); + try + { + var updates = await this.UpdateService.DetermineAvailableUpdatesAsync().ConfigureAwait(true); + this._availableUpdates = updates.Count; + } + catch + { + // E.g. when the database is not initialized yet. + this._availableUpdates = null; + } + finally + { + this._isLoading = false; + } + } +} diff --git a/src/Web/AdminPanel/Components/Dashboard/OnlinePlayersChart.razor b/src/Web/AdminPanel/Components/Dashboard/OnlinePlayersChart.razor new file mode 100644 index 0000000000..2daef6119b --- /dev/null +++ b/src/Web/AdminPanel/Components/Dashboard/OnlinePlayersChart.razor @@ -0,0 +1,67 @@ +@using MUnique.OpenMU.Web.AdminPanel.Properties +@using MUnique.OpenMU.Web.AdminPanel.Services +@implements IDisposable + +
+
+

@Resources.DashboardOnlineHistory

+ @string.Format(Resources.DashboardSampleInterval, (int)OnlinePlayerHistory.SampleInterval.TotalMinutes) +
+
+ @if (OnlinePlayerChart.Create(this.History.GetSamples(), DateTime.UtcNow) is { } chart) + { + + @for (var i = 1; i <= 3; i++) + { + var y = OnlinePlayerChart.Height * i / 4; + + } + + + + +
+ @Resources.DashboardCurrent @chart.Current + @Resources.DashboardPeak @chart.Peak + @Resources.DashboardLow @chart.Low +
+ } + else + { +

@Resources.DashboardCollectingData

+ } +
+
+ +@code { + /// + /// Gets or sets the history of the online players. + /// + [Inject] + public OnlinePlayerHistory History { get; set; } = null!; + + /// + public void Dispose() + { + this.History.SampleAdded -= this.OnSampleAdded; + } + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + this.History.SampleAdded += this.OnSampleAdded; + } + + private void OnSampleAdded(object? sender, EventArgs e) + { + _ = this.InvokeAsync(this.StateHasChanged); + } +} diff --git a/src/Web/AdminPanel/Components/Dashboard/OnlinePlayersChart.razor.css b/src/Web/AdminPanel/Components/Dashboard/OnlinePlayersChart.razor.css new file mode 100644 index 0000000000..0040ea0a12 --- /dev/null +++ b/src/Web/AdminPanel/Components/Dashboard/OnlinePlayersChart.razor.css @@ -0,0 +1,22 @@ +.online-chart { + display: block; + width: 100%; + height: 12.5rem; +} + +.online-chart-grid { + stroke: var(--bs-border-color); + stroke-width: 1; +} + +.online-chart-area { + fill: var(--bs-primary); + fill-opacity: 0.12; +} + +.online-chart-line { + fill: none; + stroke: var(--bs-primary); + stroke-width: 2; + stroke-linejoin: round; +} diff --git a/src/Web/AdminPanel/Components/Dashboard/StatisticCard.razor b/src/Web/AdminPanel/Components/Dashboard/StatisticCard.razor new file mode 100644 index 0000000000..823e54cafa --- /dev/null +++ b/src/Web/AdminPanel/Components/Dashboard/StatisticCard.razor @@ -0,0 +1,39 @@ +
+
+
+

@this.Title

+ +
+
@this.Value
+ @if (this.ChildContent is not null) + { +
@this.ChildContent
+ } +
+
+ +@code { + /// + /// Gets or sets the title. + /// + [Parameter] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the CSS class of the open iconic icon, e.g. oi-people. + /// + [Parameter] + public string Icon { get; set; } = string.Empty; + + /// + /// Gets or sets the value. + /// + [Parameter] + public RenderFragment? Value { get; set; } + + /// + /// Gets or sets the details, which are shown below the value. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/Web/AdminPanel/Components/Dashboard/StatisticCard.razor.css b/src/Web/AdminPanel/Components/Dashboard/StatisticCard.razor.css new file mode 100644 index 0000000000..b0220e25fd --- /dev/null +++ b/src/Web/AdminPanel/Components/Dashboard/StatisticCard.razor.css @@ -0,0 +1,5 @@ +.statistic-value { + font-size: 2rem; + font-weight: 600; + line-height: 1.2; +} diff --git a/src/Web/AdminPanel/Components/Dashboard/SystemInfoCard.razor b/src/Web/AdminPanel/Components/Dashboard/SystemInfoCard.razor new file mode 100644 index 0000000000..4c49141c33 --- /dev/null +++ b/src/Web/AdminPanel/Components/Dashboard/SystemInfoCard.razor @@ -0,0 +1,88 @@ +@using System.Diagnostics +@using System.Reflection +@using System.Runtime.InteropServices +@using System.Threading +@using MUnique.OpenMU.Persistence +@using MUnique.OpenMU.Web.AdminPanel.Properties + +
+
+

@Resources.System

+ @Resources.DashboardAdministratorsOnly +
+
+
+
@Resources.DashboardUptime
+
@string.Format(Resources.DashboardUptimeValue, this._uptime.Days, this._uptime.Hours, this._uptime.Minutes)
+
@Resources.DashboardMemory
+
@string.Format(Resources.DashboardMemoryValue, this._workingSetMegabytes)
+
@Resources.DashboardVersion
+
@this._version · @RuntimeInformation.FrameworkDescription
+
@Resources.DashboardDatabase
+
+ @switch (this._isDatabaseReachable) + { + case null: + @Resources.Loading + break; + case true: + @Resources.DashboardDatabaseConnected + break; + default: + @Resources.DashboardDatabaseNotReachable + break; + } +
+
+
+
+ +@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 0000000000..6149af98d8 --- /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 db3adcd49d..57b804a8d9 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 -

@Resources.OpenMUAdminPanel

@Resources.OpenMUAdminPanel +

@Resources.Dashboard

+

@Resources.WelcomeMessage

-@Resources.WelcomeMessage +
+
+ + @ServerStatistics.GetOnlinePlayerCount(this._servers).ToString("N0") + + @if (ServerStatistics.GetPlayerCapacity(this._servers) is { } capacity) + { + @string.Format(Resources.DashboardPlayerCapacity, capacity.ToString("N0")) + } + + + + + @this._servers.Count(s => s.ServerState == ServerState.Started) + / @this._servers.Count + + + @if (this._servers.Count(s => s.ServerState != ServerState.Started) is var notRunning and > 0) + { + @string.Format(Resources.DashboardServersNotRunning, notRunning) + } + else + { + @Resources.DashboardAllServersRunning + } + + + + @this.GetStatisticText(this._accountStatistics?.AccountCount) + + @if (this._accountStatistics is { } accounts) + { + @string.Format(Resources.DashboardNewAccounts, accounts.NewAccountCount.ToString("N0")) + · @string.Format(Resources.DashboardBannedAccounts, accounts.BannedAccountCount.ToString("N0")) + } + + + + @this.GetStatisticText(this._guildStatistics?.GuildCount) + @this.GetGuildDetails() + +
+ +
+ + +
+
+

@Resources.Servers

+ @Resources.DashboardManageServers +
+
    + @foreach (var server in this._servers.OrderBy(s => s.Type).ThenBy(s => s.Description)) + { +
  • +
    +
    +
    @server.Description
    +
    @server.Type.GetEnumCaption()
    +
    +
    + @GetLoadText(server) + @server.ServerState.GetEnumCaption() +
    +
    + @if (GetLoadPercentage(server) is { } percentage) + { +
    +
    +
    + } +
  • + } +
+
+
+ +
+
+
+

@Resources.DashboardQuickActions

+
+
+ @if (this._isGlobalMessageAvailable) + { + + } +
+ @Resources.Accounts + @Resources.OnlineAccounts + @Resources.Guilds + @if (AdminPanelEnvironment.IsHostingEmbedded) + { + @Resources.LogFiles + } + @if (AdminPanelEnvironment.IsGrafanaAvailable) + { + @Resources.Metrics + } +
+
+
+ + + + + + +
+
diff --git a/src/Web/AdminPanel/Pages/Index.razor.cs b/src/Web/AdminPanel/Pages/Index.razor.cs new file mode 100644 index 0000000000..4b1f6a234c --- /dev/null +++ b/src/Web/AdminPanel/Pages/Index.razor.cs @@ -0,0 +1,185 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using System.ComponentModel; +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Web.AdminPanel.Properties; + +/// +/// The start page of the admin panel, which shows a dashboard with statistics about the server. +/// +public partial class Index : IDisposable +{ + /// + /// The period in which registered accounts are counted as new. + /// + private static readonly TimeSpan NewAccountPeriod = TimeSpan.FromDays(7); + + private IList _servers = []; + + private AccountStatistics? _accountStatistics; + + private GuildStatistics? _guildStatistics; + + private bool _isLoadingStatistics = true; + + private bool _isGlobalMessageAvailable; + + [Inject] + private IServerProvider ServerProvider { get; set; } = null!; + + [Inject] + private IDataSource AccountDataSource { get; set; } = null!; + + [Inject] + private IPersistenceContextProvider PersistenceContextProvider { get; set; } = null!; + + [Inject] + private IServiceProvider ServiceProvider { get; set; } = null!; + + [Inject] + private ILogger Logger { get; set; } = null!; + + /// + public void Dispose() + { + this.ServerProvider.PropertyChanged -= this.OnServerProviderPropertyChanged; + this.UnsubscribeFromServers(); + } + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(true); + this.ServerProvider.PropertyChanged += this.OnServerProviderPropertyChanged; + this.SubscribeToServers(); + + // The game servers are only available in the all-in-one deployment. + this._isGlobalMessageAvailable = this.ServiceProvider.GetService>() is not null; + + await this.LoadStatisticsAsync().ConfigureAwait(true); + } + + private static string GetLoadText(IManageableServer server) + { + return server.Type == ServerType.GameServer && server.MaximumConnections < int.MaxValue + ? $"{server.CurrentConnections:N0} / {server.MaximumConnections:N0}" + : string.Format(Resources.DashboardConnections, server.CurrentConnections.ToString("N0")); + } + + private static string? GetLoadPercentage(IManageableServer server) + { + if (server.MaximumConnections <= 0 || server.MaximumConnections == int.MaxValue) + { + return null; + } + + var percentage = Math.Clamp(100.0 * server.CurrentConnections / server.MaximumConnections, 0, 100); + return percentage.ToString("0.#", CultureInfo.InvariantCulture); + } + + private static string GetStateBadgeClass(ServerState state) + { + return state switch + { + ServerState.Started => "text-bg-success", + ServerState.Stopped => "text-bg-danger", + ServerState.Timeout => "text-bg-danger", + _ => "text-bg-warning", + }; + } + + private string GetStatisticText(int? value) + { + if (value is { } notNull) + { + return notNull.ToString("N0"); + } + + return this._isLoadingStatistics ? "…" : Resources.DashboardNotAvailable; + } + + private string GetGuildDetails() + { + var details = new List(2); + if (this._guildStatistics is { } guilds) + { + details.Add(string.Format(Resources.DashboardAlliances, guilds.AllianceCount.ToString("N0"))); + } + + if (this._accountStatistics is { } accounts) + { + details.Add(string.Format(Resources.DashboardCharacters, accounts.CharacterCount.ToString("N0"))); + } + + return string.Join(" · ", details); + } + + private async Task LoadStatisticsAsync() + { + try + { + var playerContext = (IPlayerContext)await this.AccountDataSource.GetContextAsync().ConfigureAwait(true); + this._accountStatistics = await playerContext.GetAccountStatisticsAsync(DateTime.UtcNow - NewAccountPeriod).ConfigureAwait(true); + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "Couldn't load the account statistics."); + } + + try + { + using var guildContext = this.PersistenceContextProvider.CreateNewGuildContext(); + this._guildStatistics = await guildContext.GetGuildStatisticsAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "Couldn't load the guild statistics."); + } + + this._isLoadingStatistics = false; + } + + private void OnServerProviderPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(IServerProvider.Servers)) + { + return; + } + + this.UnsubscribeFromServers(); + this.SubscribeToServers(); + _ = this.InvokeAsync(this.StateHasChanged); + } + + private void OnServerPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + _ = this.InvokeAsync(this.StateHasChanged); + } + + private void SubscribeToServers() + { + this._servers = this.ServerProvider.Servers; + foreach (var server in this._servers) + { + server.PropertyChanged += this.OnServerPropertyChanged; + } + } + + private void UnsubscribeFromServers() + { + foreach (var server in this._servers) + { + server.PropertyChanged -= this.OnServerPropertyChanged; + } + } +} diff --git a/src/Web/AdminPanel/Pages/Index.razor.css b/src/Web/AdminPanel/Pages/Index.razor.css new file mode 100644 index 0000000000..f1d763ae9c --- /dev/null +++ b/src/Web/AdminPanel/Pages/Index.razor.css @@ -0,0 +1,26 @@ +.dashboard { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.dashboard-grid { + display: grid; + gap: 1rem; +} + +.dashboard-statistics { + grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); +} + +.dashboard-main { + grid-template-columns: repeat(auto-fit, minmax(min(26rem, 100%), 1fr)); +} + +.dashboard-details { + grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); +} + +.server-load { + height: 0.375rem; +} diff --git a/src/Web/AdminPanel/Pages/Servers.razor b/src/Web/AdminPanel/Pages/Servers.razor index 4e65234a86..1ca4c26178 100644 --- a/src/Web/AdminPanel/Pages/Servers.razor +++ b/src/Web/AdminPanel/Pages/Servers.razor @@ -21,35 +21,7 @@ else {
-
-
- - -
-
- - -
-
- - -
-
- -
-
+
} @@ -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 3cadc21cab..d20c869f10 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 5c0e12bce7..c53ed66c29 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 11e14e2409..76cbe8c82e 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 3f6074559f..f95fc6d5a3 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 0000000000..c719ea2ce0 --- /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 0000000000..5f083587e7 --- /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 0000000000..951b2dd817 --- /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 0000000000..2d934ccb78 --- /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 692ebd4a0c..9a6b2644ec 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.Web.Tests/Dashboard/DashboardStatisticsTests.cs b/tests/MUnique.OpenMU.Web.Tests/Dashboard/DashboardStatisticsTests.cs new file mode 100644 index 0000000000..6f8be92271 --- /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 0000000000..5830a65b6b --- /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 0000000000..151d6916b1 --- /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 0000000000..74f9f46354 --- /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 0000000000..65c1c8e849 --- /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 0000000000..dad49809a9 --- /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 0000000000..df5891a3d6 --- /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; + } +} From b93e7ba6d9224c8acd286df1e48e885d9ed89754 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:56:19 +0000 Subject: [PATCH 2/3] fix(admin-panel): wrap dashboard card headers and server rows on narrow screens Also don't show a separator before the runtime when the assembly has no informational version (local builds). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XowA25b3pgLghrNZSZRwTi --- src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor | 2 +- .../Components/Dashboard/ConfigurationUpdatesCard.razor | 2 +- src/Web/AdminPanel/Components/Dashboard/SystemInfoCard.razor | 4 ++-- src/Web/AdminPanel/Pages/Index.razor | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor b/src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor index 0ecc4f9507..4c86c76fee 100644 --- a/src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor +++ b/src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor @@ -5,7 +5,7 @@ @if (this._snapshot is { } snapshot) {
-
+

@Resources.CastleSiege

@Resources.DashboardOpen
diff --git a/src/Web/AdminPanel/Components/Dashboard/ConfigurationUpdatesCard.razor b/src/Web/AdminPanel/Components/Dashboard/ConfigurationUpdatesCard.razor index ac39d47418..964a60f34c 100644 --- a/src/Web/AdminPanel/Components/Dashboard/ConfigurationUpdatesCard.razor +++ b/src/Web/AdminPanel/Components/Dashboard/ConfigurationUpdatesCard.razor @@ -2,7 +2,7 @@ @using MUnique.OpenMU.Web.AdminPanel.Properties
-
+

@Resources.DashboardConfigurationUpdates

@Resources.DashboardAdministratorsOnly
diff --git a/src/Web/AdminPanel/Components/Dashboard/SystemInfoCard.razor b/src/Web/AdminPanel/Components/Dashboard/SystemInfoCard.razor index 4c49141c33..92ffb15210 100644 --- a/src/Web/AdminPanel/Components/Dashboard/SystemInfoCard.razor +++ b/src/Web/AdminPanel/Components/Dashboard/SystemInfoCard.razor @@ -6,7 +6,7 @@ @using MUnique.OpenMU.Web.AdminPanel.Properties
-
+

@Resources.System

@Resources.DashboardAdministratorsOnly
@@ -17,7 +17,7 @@
@Resources.DashboardMemory
@string.Format(Resources.DashboardMemoryValue, this._workingSetMegabytes)
@Resources.DashboardVersion
-
@this._version · @RuntimeInformation.FrameworkDescription
+
@(string.IsNullOrEmpty(this._version) ? RuntimeInformation.FrameworkDescription : $"{this._version} · {RuntimeInformation.FrameworkDescription}")
@Resources.DashboardDatabase
@switch (this._isDatabaseReachable) diff --git a/src/Web/AdminPanel/Pages/Index.razor b/src/Web/AdminPanel/Pages/Index.razor index 57b804a8d9..dc6cb53e06 100644 --- a/src/Web/AdminPanel/Pages/Index.razor +++ b/src/Web/AdminPanel/Pages/Index.razor @@ -58,7 +58,7 @@
-
+ @@ -66,7 +66,7 @@ @foreach (var server in this._servers.OrderBy(s => s.Type).ThenBy(s => s.Description)) {
  • -
    +
    @server.Description
    @server.Type.GetEnumCaption()
    From b7a864ef49f3c04513ccfd5658b5af4e8af533b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:12:14 +0000 Subject: [PATCH 3/3] fix(persistence): create in-memory repositories thread-safely The in-memory repository provider creates repositories on first access, without synchronization. In the demo mode, the castle siege plugins of the game servers did that concurrently at startup, which failed with "An item with the same key has already been added" or corrupted the dictionary of the repositories, so that later lookups failed, e.g. the guild statistics of the dashboard. The repositories are now created under a lock, and the dictionary is a concurrent one, so that lookups are safe while a repository is added. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XowA25b3pgLghrNZSZRwTi --- src/Persistence/BaseRepositoryProvider.cs | 6 ++- .../InMemory/InMemoryRepositoryProvider.cs | 22 +++++++++- .../InMemoryRepositoryProviderTest.cs | 43 +++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/MUnique.OpenMU.Tests/InMemoryRepositoryProviderTest.cs diff --git a/src/Persistence/BaseRepositoryProvider.cs b/src/Persistence/BaseRepositoryProvider.cs index cea0df561c..4a30bd3c3b 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/InMemory/InMemoryRepositoryProvider.cs b/src/Persistence/InMemory/InMemoryRepositoryProvider.cs index ae5362c6ab..eead334db4 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/tests/MUnique.OpenMU.Tests/InMemoryRepositoryProviderTest.cs b/tests/MUnique.OpenMU.Tests/InMemoryRepositoryProviderTest.cs new file mode 100644 index 0000000000..5a1a82d4d7 --- /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)); + } + } +}