Launcher milestone 2: player list, kick/ban, live stats
Adds an admin-protocol client (token-authed TCP JSON on 127.0.0.1:AdminPort, reusing the server's .admin-token) that polls connected players and status every 3s, plus Kick/Ban on the selected player and a players/uptime readout.
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace CommonwealthOnline.Host.Services;
|
||||||
|
|
||||||
|
// Speaks the server's token-authenticated admin protocol on 127.0.0.1:AdminPort:
|
||||||
|
// read .admin-token, send {..,"adminToken"}\n, read one newline-terminated JSON reply.
|
||||||
|
public sealed class AdminClient
|
||||||
|
{
|
||||||
|
private const int MaxResponseBytes = 1_000_000;
|
||||||
|
|
||||||
|
private readonly int _port;
|
||||||
|
private readonly string _tokenPath;
|
||||||
|
|
||||||
|
public AdminClient(int adminPort, string tokenPath)
|
||||||
|
{
|
||||||
|
_port = adminPort;
|
||||||
|
_tokenPath = tokenPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JsonObject?> SendAsync(JsonObject request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var token = (await File.ReadAllTextAsync(_tokenPath, ct).ConfigureAwait(false)).Trim();
|
||||||
|
var authenticated = (JsonObject)request.DeepClone();
|
||||||
|
authenticated["adminToken"] = token;
|
||||||
|
var payload = JsonSerializer.SerializeToUtf8Bytes(authenticated);
|
||||||
|
|
||||||
|
using var client = new TcpClient();
|
||||||
|
await client.ConnectAsync(IPAddress.Loopback, _port, ct).ConfigureAwait(false);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
await stream.WriteAsync(payload, ct).ConfigureAwait(false);
|
||||||
|
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
var one = new byte[1];
|
||||||
|
while (buffer.Length < MaxResponseBytes)
|
||||||
|
{
|
||||||
|
var read = await stream.ReadAsync(one, ct).ConfigureAwait(false);
|
||||||
|
if (read == 0 || one[0] == (byte)'\n')
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.WriteByte(one[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonNode.Parse(buffer.ToArray()) as JsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<JsonObject?> StatusAsync(CancellationToken ct = default) =>
|
||||||
|
SendAsync(new JsonObject { ["cmd"] = "status" }, ct);
|
||||||
|
|
||||||
|
public Task<JsonObject?> ClientsAsync(CancellationToken ct = default) =>
|
||||||
|
SendAsync(new JsonObject { ["cmd"] = "clients" }, ct);
|
||||||
|
|
||||||
|
public Task<JsonObject?> KickAsync(uint playerId, string reason, CancellationToken ct = default) =>
|
||||||
|
SendAsync(new JsonObject { ["cmd"] = "kick", ["playerId"] = playerId, ["reason"] = reason }, ct);
|
||||||
|
|
||||||
|
public Task<JsonObject?> BanAsync(uint playerId, string reason, CancellationToken ct = default) =>
|
||||||
|
SendAsync(new JsonObject { ["cmd"] = "ban", ["playerId"] = playerId, ["reason"] = reason }, ct);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using CommonwealthOnline.Host.Services;
|
using CommonwealthOnline.Host.Services;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
@@ -12,9 +15,11 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
private const int MaxLogLines = 2000;
|
private const int MaxLogLines = 2000;
|
||||||
|
|
||||||
private readonly ServerController _controller = new();
|
private readonly ServerController _controller = new();
|
||||||
private readonly string _serverDir = System.IO.Directory.GetCurrentDirectory();
|
private readonly string _serverDir = Directory.GetCurrentDirectory();
|
||||||
private readonly string _configPath =
|
private readonly string _configPath =
|
||||||
Path.Combine(System.IO.Directory.GetCurrentDirectory(), "commonwealth-server.json");
|
Path.Combine(Directory.GetCurrentDirectory(), "commonwealth-server.json");
|
||||||
|
private readonly DispatcherTimer _pollTimer;
|
||||||
|
private bool _polling;
|
||||||
|
|
||||||
[ObservableProperty] private string _serverName;
|
[ObservableProperty] private string _serverName;
|
||||||
[ObservableProperty] private string _host;
|
[ObservableProperty] private string _host;
|
||||||
@@ -25,8 +30,15 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _enableGnsTransport;
|
[ObservableProperty] private bool _enableGnsTransport;
|
||||||
[ObservableProperty] private bool _isRunning;
|
[ObservableProperty] private bool _isRunning;
|
||||||
[ObservableProperty] private string _statusText = "Stopped";
|
[ObservableProperty] private string _statusText = "Stopped";
|
||||||
|
[ObservableProperty] private string _statsText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(KickCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(BanCommand))]
|
||||||
|
private PlayerRow? _selectedPlayer;
|
||||||
|
|
||||||
public ObservableCollection<string> Log { get; } = new();
|
public ObservableCollection<string> Log { get; } = new();
|
||||||
|
public ObservableCollection<PlayerRow> Players { get; } = new();
|
||||||
|
|
||||||
public string[] VerbosityOptions { get; } = { "error", "warning", "info", "debug" };
|
public string[] VerbosityOptions { get; } = { "error", "warning", "info", "debug" };
|
||||||
|
|
||||||
@@ -50,7 +62,100 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
StatusText = running ? "Running" : "Stopped";
|
StatusText = running ? "Running" : "Stopped";
|
||||||
StartCommand.NotifyCanExecuteChanged();
|
StartCommand.NotifyCanExecuteChanged();
|
||||||
StopCommand.NotifyCanExecuteChanged();
|
StopCommand.NotifyCanExecuteChanged();
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
Players.Clear();
|
||||||
|
StatsText = string.Empty;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_pollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
|
||||||
|
_pollTimer.Tick += async (_, _) => await PollAsync();
|
||||||
|
_pollTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminClient CreateAdminClient()
|
||||||
|
{
|
||||||
|
var tokenPath = Path.Combine(Path.GetDirectoryName(_configPath) ?? _serverDir, ".admin-token");
|
||||||
|
return new AdminClient(AdminPort, tokenPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PollAsync()
|
||||||
|
{
|
||||||
|
if (!IsRunning || _polling)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_polling = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var admin = CreateAdminClient();
|
||||||
|
var clients = await admin.ClientsAsync().ConfigureAwait(true);
|
||||||
|
ApplyClients(clients);
|
||||||
|
|
||||||
|
var status = await admin.StatusAsync().ConfigureAwait(true);
|
||||||
|
ApplyStatus(status);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Server still starting, admin port not up yet, or token not written — ignore this tick.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_polling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyClients(JsonObject? response)
|
||||||
|
{
|
||||||
|
if (response?["clients"] is not JsonArray array)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previouslySelected = SelectedPlayer?.PlayerId;
|
||||||
|
Players.Clear();
|
||||||
|
foreach (var node in array)
|
||||||
|
{
|
||||||
|
if (node is not JsonObject client)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Players.Add(new PlayerRow
|
||||||
|
{
|
||||||
|
PlayerId = (uint)(client["player_id"]?.GetValue<long>() ?? 0),
|
||||||
|
Label = client["label"]?.GetValue<string>() ?? string.Empty,
|
||||||
|
Address = client["address"]?.GetValue<string>() ?? string.Empty,
|
||||||
|
PacketsReceived = client["packets_received"]?.GetValue<long>() ?? 0,
|
||||||
|
PacketsSent = client["packets_sent"]?.GetValue<long>() ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previouslySelected is { } id)
|
||||||
|
{
|
||||||
|
foreach (var row in Players)
|
||||||
|
{
|
||||||
|
if (row.PlayerId == id)
|
||||||
|
{
|
||||||
|
SelectedPlayer = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyStatus(JsonObject? response)
|
||||||
|
{
|
||||||
|
if (response is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var connected = response["connected_clients"]?.GetValue<long>() ?? Players.Count;
|
||||||
|
var uptime = response["uptime_seconds"]?.GetValue<long>() ?? 0;
|
||||||
|
StatsText = $"{connected}/{MaxPlayers} players · up {uptime}s";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Append(string line)
|
private void Append(string line)
|
||||||
@@ -94,4 +199,45 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bool CanStop() => IsRunning;
|
private bool CanStop() => IsRunning;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanActOnPlayer))]
|
||||||
|
private async Task Kick()
|
||||||
|
{
|
||||||
|
if (SelectedPlayer is not { } player)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RunAdminAction(admin => admin.KickAsync(player.PlayerId, "Kicked by host"),
|
||||||
|
$"[host] kick #{player.PlayerId}").ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanActOnPlayer))]
|
||||||
|
private async Task Ban()
|
||||||
|
{
|
||||||
|
if (SelectedPlayer is not { } player)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RunAdminAction(admin => admin.BanAsync(player.PlayerId, "Banned by host"),
|
||||||
|
$"[host] ban #{player.PlayerId}").ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanActOnPlayer() => IsRunning && SelectedPlayer is not null;
|
||||||
|
|
||||||
|
private async Task RunAdminAction(Func<AdminClient, Task<JsonObject?>> action, string label)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await action(CreateAdminClient()).ConfigureAwait(true);
|
||||||
|
var message = response?["message"]?.GetValue<string>();
|
||||||
|
Append(string.IsNullOrEmpty(message) ? $"{label} sent" : $"{label}: {message}");
|
||||||
|
await PollAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Append($"{label} failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace CommonwealthOnline.Host.ViewModels;
|
||||||
|
|
||||||
|
public sealed class PlayerRow
|
||||||
|
{
|
||||||
|
public uint PlayerId { get; init; }
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
public string Address { get; init; } = string.Empty;
|
||||||
|
public long PacketsReceived { get; init; }
|
||||||
|
public long PacketsSent { get; init; }
|
||||||
|
|
||||||
|
public string Display =>
|
||||||
|
$"#{PlayerId} {(string.IsNullOrEmpty(Label) ? "player" : Label)} {Address} ↓{PacketsReceived} ↑{PacketsSent}";
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
xmlns:vm="clr-namespace:CommonwealthOnline.Host.ViewModels"
|
xmlns:vm="clr-namespace:CommonwealthOnline.Host.ViewModels"
|
||||||
x:Class="CommonwealthOnline.Host.Views.MainWindow"
|
x:Class="CommonwealthOnline.Host.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
Width="920" Height="640"
|
Width="960" Height="700"
|
||||||
MinWidth="760" MinHeight="520"
|
MinWidth="820" MinHeight="600"
|
||||||
Title="Commonwealth Online — Server Host"
|
Title="Commonwealth Online — Server Host"
|
||||||
Background="{StaticResource CoBackgroundBrush}">
|
Background="{StaticResource CoBackgroundBrush}">
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto" Margin="18">
|
<Grid RowDefinitions="Auto,*,Auto" Margin="18">
|
||||||
|
|
||||||
<Image Grid.Row="0" Source="/Assets/logo.png" Height="88"
|
<Image Grid.Row="0" Source="/Assets/logo.png" Height="84"
|
||||||
HorizontalAlignment="Left" Margin="0,0,0,14" />
|
HorizontalAlignment="Left" Margin="0,0,0,14" />
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="330,*">
|
<Grid Grid.Row="1" ColumnDefinitions="330,*">
|
||||||
@@ -38,7 +38,9 @@
|
|||||||
HorizontalAlignment="Stretch" />
|
HorizontalAlignment="Stretch" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Border Grid.Column="1" Background="{StaticResource CoSurfaceBrush}"
|
<Grid Grid.Column="1" RowDefinitions="*,Auto">
|
||||||
|
|
||||||
|
<Border Grid.Row="0" Background="{StaticResource CoSurfaceBrush}"
|
||||||
CornerRadius="4" Padding="10">
|
CornerRadius="4" Padding="10">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<ItemsControl ItemsSource="{Binding Log}">
|
<ItemsControl ItemsSource="{Binding Log}">
|
||||||
@@ -53,6 +55,35 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Row="1" Background="{StaticResource CoSurfaceBrush}"
|
||||||
|
CornerRadius="4" Padding="10" Margin="0,10,0,0" Height="188">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
<DockPanel Grid.Row="0" Margin="0,0,0,6">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="PLAYERS"
|
||||||
|
Foreground="{StaticResource CoAccentBrush}" FontWeight="Bold" FontSize="12" />
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding StatsText}"
|
||||||
|
Foreground="{StaticResource CoTextBrush}" FontSize="11"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
</DockPanel>
|
||||||
|
<ListBox Grid.Row="1" Background="Transparent"
|
||||||
|
ItemsSource="{Binding Players}" SelectedItem="{Binding SelectedPlayer}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding Display}" Foreground="{StaticResource CoTextBrush}"
|
||||||
|
FontFamily="Cascadia Mono,Consolas,monospace" FontSize="12" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
|
||||||
|
<Button Content="Kick" Command="{Binding KickCommand}" Padding="18,4" />
|
||||||
|
<Button Content="Ban" Command="{Binding BanCommand}" Padding="18,4"
|
||||||
|
Background="{StaticResource CoDangerBrush}" Foreground="White" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto,Auto" Margin="0,14,0,0">
|
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto,Auto" Margin="0,14,0,0">
|
||||||
|
|||||||
Reference in New Issue
Block a user