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) +{ +
+
+

@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 000000000..964a60f34 --- /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 000000000..2daef6119 --- /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 000000000..0040ea0a1 --- /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 000000000..823e54caf --- /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 000000000..b0220e25f --- /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 000000000..92ffb1521 --- /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
+
@(string.IsNullOrEmpty(this._version) ? RuntimeInformation.FrameworkDescription : $"{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 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 -

@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 000000000..4b1f6a234 --- /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 000000000..f1d763ae9 --- /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 4e65234a8..1ca4c2617 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 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; + } +}