Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs-website/docs/admin-panel/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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 |

Expand Down
17 changes: 17 additions & 0 deletions src/Persistence/AccountStatistics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// <copyright file="AccountStatistics.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Persistence;

/// <summary>
/// Statistics about the player accounts, e.g. for the dashboard of the admin panel.
/// </summary>
/// <remarks>
/// Template and bot accounts are not included, since they are not created by players.
/// </remarks>
/// <param name="AccountCount">The number of accounts.</param>
/// <param name="NewAccountCount">The number of accounts which were registered since the requested point in time.</param>
/// <param name="BannedAccountCount">The number of accounts which are banned, permanently or temporarily.</param>
/// <param name="CharacterCount">The number of characters of the accounts.</param>
public sealed record AccountStatistics(int AccountCount, int NewAccountCount, int BannedAccountCount, int CharacterCount);
6 changes: 5 additions & 1 deletion src/Persistence/BaseRepositoryProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace MUnique.OpenMU.Persistence;

using System.Collections.Concurrent;
using System.Threading;

/// <summary>
Expand All @@ -18,7 +19,10 @@ public class BaseRepositoryProvider : IRepositoryProvider
/// <summary>
/// Gets the repositories for each entity type.
/// </summary>
protected IDictionary<Type, object> Repositories { get; } = new Dictionary<Type, object>();
/// <remarks>
/// Derived providers may register repositories on demand, while other contexts look them up concurrently.
/// </remarks>
protected IDictionary<Type, object> Repositories { get; } = new ConcurrentDictionary<Type, object>();

/// <summary>
/// Gets the repository of the specified generic type.
Expand Down
14 changes: 14 additions & 0 deletions src/Persistence/EntityFramework/GuildServerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,18 @@ public async ValueTask<IReadOnlyCollection<Guid>> GetAllianceMasterIdsAsync(IRea
.Distinct()
.ToListAsync().ConfigureAwait(false);
}

/// <inheritdoc/>
public async ValueTask<GuildStatistics> GetGuildStatisticsAsync(CancellationToken cancellationToken = default)
{
var guilds = this.Context.Set<Guild>().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);
}
}
17 changes: 17 additions & 0 deletions src/Persistence/EntityFramework/PlayerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,23 @@ public async ValueTask<bool> CanSaveLetterAsync(Interfaces.LetterHeader letterHe
}
}

/// <inheritdoc />
public async ValueTask<AccountStatistics> 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<Account>().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);
}
}

/// <inheritdoc />
public async ValueTask<DataModel.Entities.Account?> GetAccountByCharacterNameAsync(string characterName, CancellationToken cancellationToken = default)
{
Expand Down
12 changes: 12 additions & 0 deletions src/Persistence/GuildStatistics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// <copyright file="GuildStatistics.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Persistence;

/// <summary>
/// Statistics about the guilds, e.g. for the dashboard of the admin panel.
/// </summary>
/// <param name="GuildCount">The number of guilds.</param>
/// <param name="AllianceCount">The number of alliances, i.e. the guilds which are the master of an alliance.</param>
public sealed record GuildStatistics(int GuildCount, int AllianceCount);
7 changes: 7 additions & 0 deletions src/Persistence/IGuildServerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,11 @@ public interface IGuildServerContext : IContext
/// <param name="guildIds">The guild identifiers to check. Kept small (e.g. one page) to avoid a full table scan.</param>
/// <returns>The subset of <paramref name="guildIds"/> which are alliance masters.</returns>
ValueTask<IReadOnlyCollection<Guid>> GetAllianceMasterIdsAsync(IReadOnlyCollection<Guid> guildIds);

/// <summary>
/// Gets statistics about the guilds.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The statistics about the guilds.</returns>
ValueTask<GuildStatistics> GetGuildStatisticsAsync(CancellationToken cancellationToken = default);
}
8 changes: 8 additions & 0 deletions src/Persistence/IPlayerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ public interface IPlayerContext : IContext
/// </returns>
ValueTask<IEnumerable<Account>> SearchAccountsAsync(string searchTerm, int skip, int count, CancellationToken cancellationToken = default);

/// <summary>
/// Gets statistics about the player accounts.
/// </summary>
/// <param name="registeredSince">The point in time (UTC) since which registered accounts are counted as new.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The statistics about the player accounts.</returns>
ValueTask<AccountStatistics> GetAccountStatisticsAsync(DateTime registeredSince, CancellationToken cancellationToken = default);

/// <summary>
/// Gets the account by character name.
/// </summary>
Expand Down
12 changes: 12 additions & 0 deletions src/Persistence/InMemory/GuildServerInMemoryContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,16 @@ public async ValueTask<IReadOnlyCollection<Guid>> GetAllianceMasterIdsAsync(IRea
.Distinct()
.ToList();
}

/// <inheritdoc/>
public async ValueTask<GuildStatistics> GetGuildStatisticsAsync(CancellationToken cancellationToken = default)
{
var allGuilds = (await this.Provider.GetRepository<DataModel.Entities.Guild>().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);
}
}
22 changes: 20 additions & 2 deletions src/Persistence/InMemory/InMemoryRepositoryProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@

namespace MUnique.OpenMU.Persistence.InMemory;

using System.Threading;
using MUnique.OpenMU.Persistence.BasicModel;

/// <summary>
/// A repository provider which creates new in-memory repositories on-demand.
/// </summary>
public class InMemoryRepositoryProvider : BaseRepositoryProvider
{
private readonly Lock _creationLock = new();

/// <summary>
/// Gets all <see cref="IMemoryRepository"/> which were added to this manager.
/// </summary>
internal IEnumerable<IMemoryRepository> MemoryRepositories => base.Repositories.Values.OfType<IMemoryRepository>();

Check warning on line 20 in src/Persistence/InMemory/InMemoryRepositoryProvider.cs

View workflow job for this annotation

GitHub Actions / build

Do not prefix calls with base unless local implementation exists (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1100.md)

/// <summary>
/// Gets the memory repository, and creates it if it wasn't created yet.
Expand All @@ -24,7 +27,7 @@
public new IRepository<T> GetRepository<T>()
where T : class
{
var repository = this.InternalGetRepository(typeof(T)) ?? this.CreateAndRegisterMemoryRepository(typeof(T));
var repository = this.GetOrCreateMemoryRepository(typeof(T));
return new InMemoryRepositoryAdapter<T>((IMemoryRepository)repository);
}

Expand All @@ -35,11 +38,26 @@
/// <returns>The repository of the specified type.</returns>
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;
Expand Down
12 changes: 12 additions & 0 deletions src/Persistence/InMemory/PlayerInMemoryContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ public PlayerInMemoryContext(InMemoryRepositoryProvider provider)
return allAccounts.OrderBy(a => a.LoginName).Skip(skip).Take(count);
}

/// <inheritdoc/>
public async ValueTask<AccountStatistics> GetAccountStatisticsAsync(DateTime registeredSince, CancellationToken cancellationToken = default)
{
var allAccounts = await this.Provider.GetRepository<Account>().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));
}

/// <inheritdoc/>
public async ValueTask<IEnumerable<MUnique.OpenMU.DataModel.Entities.Account>> SearchAccountsAsync(string searchTerm, int skip, int count, CancellationToken cancellationToken = default)
{
Expand Down
59 changes: 59 additions & 0 deletions src/Web/AdminPanel/Components/Dashboard/CastleSiegeCard.razor
Original file line number Diff line number Diff line change
@@ -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)
{
<section class="card h-100">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<h2 class="h6 mb-0">@Resources.CastleSiege</h2>
<a href="castle-siege" class="small">@Resources.DashboardOpen</a>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 fw-normal text-body-secondary">@Resources.CastleSiegeState</dt>
<dd class="col-7 fw-semibold">@snapshot.State</dd>
<dt class="col-5 fw-normal text-body-secondary">@Resources.CastleSiegeEndsUtc</dt>
<dd class="col-7">@snapshot.StateEndTimeUtc.ToString("u")</dd>
<dt class="col-5 fw-normal text-body-secondary">@Resources.CastleSiegeOwner</dt>
<dd class="col-7">@(snapshot.IsOccupied ? snapshot.OwnerGuildName ?? snapshot.OwnerGuildId?.ToString() : Resources.CastleSiegeUnoccupied)</dd>
<dt class="col-5 fw-normal text-body-secondary">@Resources.CastleSiegeRegistrations</dt>
<dd class="col-7 mb-0">@snapshot.Registrations.Count</dd>
</dl>
</div>
</section>
}

@code {
private CastleSiegeAdministrationSnapshot? _snapshot;

/// <summary>
/// Gets or sets the castle siege management service.
/// </summary>
[Inject]
public CastleSiegeManagementService CastleSiegeService { get; set; } = null!;

/// <inheritdoc />
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.
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
@using MUnique.OpenMU.Persistence.Initialization.Updates
@using MUnique.OpenMU.Web.AdminPanel.Properties

<section class="card h-100">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<h2 class="h6 mb-0">@Resources.DashboardConfigurationUpdates</h2>
<span class="badge rounded-pill text-bg-light border">@Resources.DashboardAdministratorsOnly</span>
</div>
<div class="card-body d-flex flex-column gap-3">
@switch (this._availableUpdates)
{
case null when this._isLoading:
<span class="text-body-secondary">@Resources.Loading</span>
break;
case null:
<span class="text-body-secondary">@Resources.DashboardNotAvailable</span>
break;
case 0:
<span>@Resources.DashboardNoUpdates</span>
break;
default:
<div class="d-flex align-items-baseline gap-2">
<span class="fs-3 fw-semibold">@this._availableUpdates</span>
<span class="text-body-secondary">@Resources.DashboardUpdatesAvailable</span>
</div>
<a class="btn btn-primary align-self-start" href="config-updates">@Resources.DashboardReviewUpdates</a>
break;
}
</div>
</section>

@code {
private int? _availableUpdates;

private bool _isLoading = true;

/// <summary>
/// Gets or sets the update service.
/// </summary>
[Inject]
public DataUpdateService UpdateService { get; set; } = null!;

/// <inheritdoc />
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;
}
}
}
Loading
Loading