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
31 changes: 31 additions & 0 deletions src/Web/AdminPanel/Components/PartyBadge.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
@if (PartyMaster is null || PartySize <= 0)
{
<span class="text-muted">—</span>
}
else
{
<span class="badge" title="Party of @PartyMaster: @PartySize members" style="background-color: @GetPartyColor(); color: #fff;">@PartyMaster (@PartySize)</span>
}

@code {
/// <summary>
/// Gets or sets the character name of the party master.
/// </summary>
[Parameter]
public string? PartyMaster { get; set; }

/// <summary>
/// Gets or sets the number of party members.
/// </summary>
[Parameter]
public int PartySize { get; set; }

private string GetPartyColor()
{
// Deterministic hue per party master, so all members of one party share the same badge color
// and different parties are visually distinct.
var hash = System.HashCode.Combine(this.PartyMaster!.ToUpperInvariant());
var hue = ((hash % 360) + 360) % 360;
return $"hsl({hue}, 45%, 45%)";
}
}
178 changes: 132 additions & 46 deletions src/Web/AdminPanel/Pages/LoggedIn.razor
Original file line number Diff line number Diff line change
Expand Up @@ -7,66 +7,152 @@

@inject LoggedInAccountService AccountService;
@inject OfflineAccountService OfflineService;
@inject BotAccountService BotService;
@inject IServiceProvider ServiceProvider;

<PageTitle>OpenMU: @Resources.OnlineAccounts</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.OnlineAccounts"/>
<h1>@Resources.OnlineAccounts</h1>

<div class="w-100">
<DataTable TItem=@LoggedInAccount>
<TableHeader>
<th class="col-3">@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))</th>
<th class="col-2">@Resources.ServerID</th>
<th class="col-2">@Resources.Action</th>
</TableHeader>
<ItemTemplate Context="item">
<td>@item.LoginName</td>
<td>@item.Server</td>
<td>
<button class="btn btn-warning btn-sm" type="button" @onclick="() => this.AccountService.SetAccountOfflineAsync(item)">
<span class="oi oi-bolt" aria-hidden="true" /> @Resources.Disconnect
</button>
@if (this.IsNetworkAnalyzerAvailable)
{
<a class="btn btn-secondary btn-sm ms-1" title="@Resources.AnalyzeTraffic"
href="@($"{NetworkAnalyzer.PlayerRoute}{item.Server}/{Uri.EscapeDataString(item.LoginName)}")">
<span class="oi oi-pulse" aria-hidden="true" /> @Resources.NetworkAnalyzer
</a>
}
</td>
</ItemTemplate>
</DataTable>
</div>

<h2>@Resources.ActiveOfflinePlayer</h2>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button" role="tab" aria-selected="@(_activeTab == Tab.Players)"
class="nav-link @(_activeTab == Tab.Players ? "active" : null)"
@onclick="() => _activeTab = Tab.Players">
@Resources.Players
</button>
</li>
@if (_showOfflevel)
{
<li class="nav-item" role="presentation">
<button type="button" role="tab" aria-selected="@(_activeTab == Tab.Offlevel)"
class="nav-link @(_activeTab == Tab.Offlevel ? "active" : null)"
@onclick="() => _activeTab = Tab.Offlevel">
@Resources.OffLevelPlayers
</button>
</li>
}
@if (_showBots)
{
<li class="nav-item" role="presentation">
<button type="button" role="tab" aria-selected="@(_activeTab == Tab.Bots)"
class="nav-link @(_activeTab == Tab.Bots ? "active" : null)"
@onclick="() => _activeTab = Tab.Bots">
@Resources.BotPlayers
</button>
</li>
}
</ul>

<div class="w-100">
<DataTable TItem=@OfflineAccount>
<TableHeader>
<th class="col-3">@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))</th>
<th class="col-2">@Resources.ServerID</th>
<th class="col-3">@Resources.StartedAt</th>
<th class="col-2">@Resources.Action</th>
</TableHeader>
<ItemTemplate Context="item">
<td>@item.LoginName</td>
<td>@item.ServerId</td>
<td>@item.StartedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC</td>
<td>
<button class="btn btn-warning btn-sm" type="button" @onclick="() => this.OfflineService.StopOfflinePlayerAsync(item)">
<span class="oi oi-media-stop" aria-hidden="true" /> @Resources.Stop
</button>
</td>
</ItemTemplate>
</DataTable>
<div class="tab-content pt-3">
@if (_activeTab == Tab.Players)
{
<div class="w-100" role="tabpanel">
<DataTable TItem=@LoggedInAccount>
<TableHeader>
<th class="col-3">@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))</th>
<th class="col-2">@Resources.Character</th>
<th class="col-1">@Resources.ServerID</th>
<th class="col-3">@Resources.Party</th>
<th class="col-3">@Resources.Action</th>
</TableHeader>
<ItemTemplate Context="item">
<td>@item.LoginName</td>
<td>@(item.CharacterName ?? "—")</td>
<td>@item.Server</td>
<td><PartyBadge PartyMaster="@item.PartyMaster" PartySize="@item.PartySize" /></td>
<td>
<button class="btn btn-warning btn-sm" type="button" @onclick="() => this.AccountService.SetAccountOfflineAsync(item)">
<span class="oi oi-bolt" aria-hidden="true" /> @Resources.Disconnect
</button>
@if (this.IsNetworkAnalyzerAvailable)
{
<a class="btn btn-secondary btn-sm ms-1" title="@Resources.AnalyzeTraffic"
href="@($"{NetworkAnalyzer.PlayerRoute}{item.Server}/{Uri.EscapeDataString(item.LoginName)}")">
<span class="oi oi-pulse" aria-hidden="true" /> @Resources.NetworkAnalyzer
</a>
}
</td>
</ItemTemplate>
</DataTable>
</div>
}
else if (_activeTab == Tab.Offlevel)
{
<div class="w-100" role="tabpanel">
<DataTable TItem=@OfflineAccount>
<TableHeader>
<th class="col-2">@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))</th>
<th class="col-2">@Resources.Character</th>
<th class="col-1">@Resources.ServerID</th>
<th class="col-2">@Resources.Party</th>
<th class="col-3">@Resources.StartedAt</th>
<th class="col-2">@Resources.Action</th>
</TableHeader>
<ItemTemplate Context="item">
<td>@item.LoginName</td>
<td>@(item.CharacterName ?? "—")</td>
<td>@item.ServerId</td>
<td><PartyBadge PartyMaster="@item.PartyMaster" PartySize="@item.PartySize" /></td>
<td>@item.StartedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC</td>
<td>
<button class="btn btn-warning btn-sm" type="button" @onclick="() => this.OfflineService.StopOfflinePlayerAsync(item)">
<span class="oi oi-media-stop" aria-hidden="true" /> @Resources.Stop
</button>
</td>
</ItemTemplate>
</DataTable>
</div>
}
else
{
<div class="w-100" role="tabpanel">
<DataTable TItem=@BotAccount>
<TableHeader>
<th class="col-3">@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))</th>
<th class="col-2">@Resources.Character</th>
<th class="col-1">@Resources.ServerID</th>
<th class="col-3">@Resources.Party</th>
<th class="col-3">@Resources.StartedAt</th>
</TableHeader>
<ItemTemplate Context="item">
<td>@item.LoginName</td>
<td>@(item.CharacterName ?? "—")</td>
<td>@item.ServerId</td>
<td><PartyBadge PartyMaster="@item.PartyMaster" PartySize="@item.PartySize" /></td>
<td>@item.StartedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC</td>
</ItemTemplate>
</DataTable>
</div>
}
</div>

@code {
private Tab _activeTab = Tab.Players;

private bool _showOfflevel;

private bool _showBots;

/// <summary>
/// Gets a value indicating whether the network analyzer is available. It needs the servers
/// in the same process, so it's only registered in the all-in-one deployment.
/// </summary>
private bool IsNetworkAnalyzerAvailable => this.ServiceProvider.GetService(typeof(IPacketCaptureService)) is not null;

protected override void OnInitialized()
{
base.OnInitialized();
// Tabs are only shown when the corresponding feature plugin is active on an in-process game server.
// In a distributed deployment the contexts aren't available, so the tabs stay hidden.
this._showOfflevel = this.OfflineService.IsOfflevelFeatureAvailable();
this._showBots = this.BotService.IsBotFeatureAvailable();
}

private enum Tab
{
Players,
Offlevel,
Bots,
}
}
38 changes: 37 additions & 1 deletion src/Web/AdminPanel/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/Web/AdminPanel/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1242,4 +1242,16 @@
<data name="DownloadArchivedSession" xml:space="preserve">
<value>Download the archived session</value>
</data>
<data name="Players" xml:space="preserve">
<value>Players</value>
</data>
<data name="OffLevelPlayers" xml:space="preserve">
<value>Off-level Players</value>
</data>
<data name="BotPlayers" xml:space="preserve">
<value>Bot Players</value>
</data>
<data name="Party" xml:space="preserve">
<value>Party</value>
</data>
</root>
2 changes: 2 additions & 0 deletions src/Web/AdminPanel/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ public static WebApplicationBuilder AddAdminPanel(this WebApplicationBuilder bui
services.AddScoped<IDataService<LoggedInAccount>>(serviceProvider => serviceProvider.GetService<LoggedInAccountService>()!);
services.AddScoped<OfflineAccountService>();
services.AddScoped<IDataService<OfflineAccount>>(serviceProvider => serviceProvider.GetService<OfflineAccountService>()!);
services.AddScoped<BotAccountService>();
services.AddScoped<IDataService<BotAccount>>(serviceProvider => serviceProvider.GetService<BotAccountService>()!);

StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
return builder;
Expand Down
16 changes: 16 additions & 0 deletions src/Web/Shared/Services/BotAccount.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// <copyright file="BotAccount.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Web.Shared.Services;

/// <summary>
/// Keeps the displayed data of an active server-side bot.
/// </summary>
/// <param name="LoginName">The account login name the bot is driving.</param>
/// <param name="ServerId">The server identifier.</param>
/// <param name="CharacterName">The character which the bot is driving.</param>
/// <param name="StartedAt">The start timestamp of the bot session.</param>
/// <param name="PartyMaster">The character name of the party master, if the bot is in a party.</param>
/// <param name="PartySize">The number of party members, if the bot is in a party.</param>
public record BotAccount(string LoginName, byte ServerId, string? CharacterName, DateTime StartedAt, string? PartyMaster = null, int PartySize = 0) : IPartyGroupedAccount;
65 changes: 65 additions & 0 deletions src/Web/Shared/Services/BotAccountService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// <copyright file="BotAccountService.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Web.Shared.Services;

using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.Interfaces;

/// <summary>
/// Service for the bot player table on the <c>LoggedIn</c> page.
/// Bots are connection-less <see cref="BotPlayer"/> instances in the game contexts,
/// so they are only visible in the all-in-one deployment.
/// The table is read-only: there is no supported way to stop a single bot from the admin panel.
/// </summary>
public class BotAccountService : IDataService<BotAccount>
{
private readonly IServerProvider _serverProvider;

/// <summary>
/// Initializes a new instance of the <see cref="BotAccountService"/> class.
/// </summary>
/// <param name="serverProvider">The server provider.</param>
public BotAccountService(IServerProvider serverProvider)
{
this._serverProvider = serverProvider;
}

/// <summary>
/// Determines whether the bot feature plugin is active on any in-process game server.
/// </summary>
public bool IsBotFeatureAvailable()
{
return this._serverProvider.Servers
.OfType<IGameServerContextProvider>()
.Any(s => s.Context.PlugInManager.IsPlugInActive(typeof(BotFeaturePlugIn)));
}

/// <inheritdoc />
public async Task<List<BotAccount>> GetAsync(int offset, int count)
{
var result = new List<BotAccount>();
foreach (var server in this._serverProvider.Servers.OfType<IGameServerContextProvider>())
{
var serverId = (byte)((IManageableServer)server).Id;
var players = await server.Context.GetPlayersAsync().ConfigureAwait(false);
result.AddRange(players
.OfType<BotPlayer>()
.Select(p => new BotAccount(
p.Account?.LoginName ?? string.Empty,
serverId,
p.SelectedCharacter?.Name,
p.StartTimestamp,
p.Party?.PartyMaster?.Name,
p.Party?.PartyList.Count ?? 0)));
}

return result
.OrderPartyGrouped()
.Skip(offset)
.Take(count)
.ToList();
}
}
Loading