diff --git a/host/Services/AdminClient.cs b/host/Services/AdminClient.cs new file mode 100644 index 0000000..07a6beb --- /dev/null +++ b/host/Services/AdminClient.cs @@ -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 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 StatusAsync(CancellationToken ct = default) => + SendAsync(new JsonObject { ["cmd"] = "status" }, ct); + + public Task ClientsAsync(CancellationToken ct = default) => + SendAsync(new JsonObject { ["cmd"] = "clients" }, ct); + + public Task KickAsync(uint playerId, string reason, CancellationToken ct = default) => + SendAsync(new JsonObject { ["cmd"] = "kick", ["playerId"] = playerId, ["reason"] = reason }, ct); + + public Task BanAsync(uint playerId, string reason, CancellationToken ct = default) => + SendAsync(new JsonObject { ["cmd"] = "ban", ["playerId"] = playerId, ["reason"] = reason }, ct); +} diff --git a/host/ViewModels/MainWindowViewModel.cs b/host/ViewModels/MainWindowViewModel.cs index ca90b46..7f3b25b 100644 --- a/host/ViewModels/MainWindowViewModel.cs +++ b/host/ViewModels/MainWindowViewModel.cs @@ -1,5 +1,8 @@ +using System; using System.Collections.ObjectModel; using System.IO; +using System.Text.Json.Nodes; +using System.Threading.Tasks; using Avalonia.Threading; using CommonwealthOnline.Host.Services; using CommunityToolkit.Mvvm.ComponentModel; @@ -12,9 +15,11 @@ public partial class MainWindowViewModel : ObservableObject private const int MaxLogLines = 2000; private readonly ServerController _controller = new(); - private readonly string _serverDir = System.IO.Directory.GetCurrentDirectory(); + private readonly string _serverDir = Directory.GetCurrentDirectory(); 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 _host; @@ -25,8 +30,15 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private bool _enableGnsTransport; [ObservableProperty] private bool _isRunning; [ObservableProperty] private string _statusText = "Stopped"; + [ObservableProperty] private string _statsText = string.Empty; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(KickCommand))] + [NotifyCanExecuteChangedFor(nameof(BanCommand))] + private PlayerRow? _selectedPlayer; public ObservableCollection Log { get; } = new(); + public ObservableCollection Players { get; } = new(); public string[] VerbosityOptions { get; } = { "error", "warning", "info", "debug" }; @@ -50,7 +62,100 @@ public partial class MainWindowViewModel : ObservableObject StatusText = running ? "Running" : "Stopped"; StartCommand.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() ?? 0), + Label = client["label"]?.GetValue() ?? string.Empty, + Address = client["address"]?.GetValue() ?? string.Empty, + PacketsReceived = client["packets_received"]?.GetValue() ?? 0, + PacketsSent = client["packets_sent"]?.GetValue() ?? 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() ?? Players.Count; + var uptime = response["uptime_seconds"]?.GetValue() ?? 0; + StatsText = $"{connected}/{MaxPlayers} players · up {uptime}s"; } private void Append(string line) @@ -94,4 +199,45 @@ public partial class MainWindowViewModel : ObservableObject } 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> action, string label) + { + try + { + var response = await action(CreateAdminClient()).ConfigureAwait(true); + var message = response?["message"]?.GetValue(); + Append(string.IsNullOrEmpty(message) ? $"{label} sent" : $"{label}: {message}"); + await PollAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + Append($"{label} failed: {ex.Message}"); + } + } } diff --git a/host/ViewModels/PlayerRow.cs b/host/ViewModels/PlayerRow.cs new file mode 100644 index 0000000..267747a --- /dev/null +++ b/host/ViewModels/PlayerRow.cs @@ -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}"; +} diff --git a/host/Views/MainWindow.axaml b/host/Views/MainWindow.axaml index 90b9df7..810293b 100644 --- a/host/Views/MainWindow.axaml +++ b/host/Views/MainWindow.axaml @@ -3,14 +3,14 @@ xmlns:vm="clr-namespace:CommonwealthOnline.Host.ViewModels" x:Class="CommonwealthOnline.Host.Views.MainWindow" x:DataType="vm:MainWindowViewModel" - Width="920" Height="640" - MinWidth="760" MinHeight="520" + Width="960" Height="700" + MinWidth="820" MinHeight="600" Title="Commonwealth Online — Server Host" Background="{StaticResource CoBackgroundBrush}"> - @@ -38,20 +38,51 @@ HorizontalAlignment="Stretch" /> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + +