Skip to content

Commit cfa4412

Browse files
feat: Implement public server details page with live status and player data integration
1 parent 386e87d commit cfa4412

4 files changed

Lines changed: 662 additions & 17 deletions

File tree

src/XtremeIdiots.Portal.Web/Controllers/ServersController.cs

Lines changed: 202 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,19 @@
33
using Microsoft.AspNetCore.Mvc;
44

55
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
6+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.GameServers;
67
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.LiveStatus;
78
using XtremeIdiots.Portal.Repository.Api.Client.V1;
89
using XtremeIdiots.Portal.Web.Auth.Constants;
910
using XtremeIdiots.Portal.Web.Extensions;
11+
using XtremeIdiots.Portal.Web.Models;
1012
using XtremeIdiots.Portal.Web.ViewModels;
1113

1214
namespace XtremeIdiots.Portal.Web.Controllers;
1315

1416
/// <summary>
15-
/// Controller for managing server information and functionality
17+
/// Controller for public server information pages
1618
/// </summary>
17-
/// <remarks>
18-
/// Initializes a new instance of the ServersController
19-
/// </remarks>
20-
/// <param name="repositoryApiClient">Client for repository API operations</param>
21-
/// <param name="agentTelemetryService">Service for querying agent telemetry from Application Insights</param>
22-
/// <param name="telemetryClient">Client for application telemetry</param>
23-
/// <param name="logger">Logger instance for this controller</param>
24-
/// <param name="configuration">Application configuration</param>
25-
/// <exception cref="ArgumentNullException">Thrown when required dependencies are null</exception>
2619
[Authorize(Policy = AuthPolicies.AccessServers)]
2720
public class ServersController(
2821
IRepositoryApiClient repositoryApiClient,
@@ -33,8 +26,6 @@ public class ServersController(
3326
/// <summary>
3427
/// Displays the main servers listing page
3528
/// </summary>
36-
/// <param name="cancellationToken">Cancellation token for the async operation</param>
37-
/// <returns>View with list of enabled servers or error page on failure</returns>
3829
[HttpGet]
3930
public async Task<IActionResult> Index(CancellationToken cancellationToken = default)
4031
{
@@ -71,11 +62,172 @@ public async Task<IActionResult> Index(CancellationToken cancellationToken = def
7162
}, nameof(Index)).ConfigureAwait(false);
7263
}
7364

65+
/// <summary>
66+
/// Displays the public server detail page with overview, maps, and player map tabs
67+
/// </summary>
68+
[HttpGet]
69+
public async Task<IActionResult> Details(Guid id, CancellationToken cancellationToken = default)
70+
{
71+
return await ExecuteWithErrorHandlingAsync(async () =>
72+
{
73+
var gameServerApiResponse = await repositoryApiClient.GameServers.V1.GetGameServer(id, cancellationToken).ConfigureAwait(false);
74+
75+
if (gameServerApiResponse.IsNotFound || gameServerApiResponse.Result?.Data is null)
76+
{
77+
Logger.LogWarning("Game server {ServerId} not found for public Details", id);
78+
return NotFound();
79+
}
80+
81+
var gs = gameServerApiResponse.Result.Data;
82+
83+
if (!gs.ServerListEnabled)
84+
{
85+
Logger.LogWarning("User {UserId} attempted to access non-public server {ServerId}", User.XtremeIdiotsId(), id);
86+
return NotFound();
87+
}
88+
89+
var liveStatusTask = repositoryApiClient.LiveStatus.V1.GetGameServerLiveStatus(gs.GameServerId, cancellationToken);
90+
var statsTask = repositoryApiClient.GameServersStats.V1.GetGameServerStatusStats(
91+
gs.GameServerId, DateTime.UtcNow.AddDays(-2), cancellationToken);
92+
93+
await Task.WhenAll(liveStatusTask, statsTask).ConfigureAwait(false);
94+
95+
var liveStatusResponse = await liveStatusTask.ConfigureAwait(false);
96+
var statsResponse = await statsTask.ConfigureAwait(false);
97+
98+
var viewModel = new ServerInfoViewModel
99+
{
100+
GameServer = gs,
101+
LiveStatus = liveStatusResponse.IsSuccess ? liveStatusResponse.Result?.Data : null
102+
};
103+
104+
if (statsResponse.IsSuccess && statsResponse.Result?.Data?.Items is not null)
105+
{
106+
viewModel.GameServerStats = [.. statsResponse.Result.Data.Items];
107+
108+
GameServerStatDto? current = null;
109+
var orderedStats = statsResponse.Result.Data.Items.OrderBy(s => s.Timestamp).ToList();
110+
foreach (var stat in orderedStats)
111+
{
112+
if (current is null) { current = stat; continue; }
113+
if (current.MapName != stat.MapName)
114+
{
115+
viewModel.MapTimelineDataPoints.Add(new MapTimelineDataPoint(
116+
current.MapName, current.Timestamp, stat.Timestamp));
117+
current = stat;
118+
}
119+
if (stat == orderedStats.Last())
120+
viewModel.MapTimelineDataPoints.Add(new MapTimelineDataPoint(
121+
current.MapName, current.Timestamp, DateTime.UtcNow));
122+
}
123+
}
124+
125+
return View(viewModel);
126+
}, nameof(Details)).ConfigureAwait(false);
127+
}
128+
129+
/// <summary>
130+
/// Returns live player data for a server, projecting only public-safe fields
131+
/// </summary>
132+
[HttpGet]
133+
public async Task<IActionResult> GetLivePlayers(Guid id, CancellationToken cancellationToken = default)
134+
{
135+
return await ExecuteWithErrorHandlingAsync(async () =>
136+
{
137+
var gameServerApiResponse = await repositoryApiClient.GameServers.V1.GetGameServer(id, cancellationToken).ConfigureAwait(false);
138+
if (gameServerApiResponse.IsNotFound || gameServerApiResponse.Result?.Data is null || !gameServerApiResponse.Result.Data.ServerListEnabled)
139+
{
140+
return Json(new { data = Array.Empty<object>() });
141+
}
142+
143+
var livePlayersResponse = await repositoryApiClient.LiveStatus.V1.GetGameServerLivePlayers(id, cancellationToken).ConfigureAwait(false);
144+
145+
if (!livePlayersResponse.IsSuccess || livePlayersResponse.Result?.Data?.Items is null)
146+
{
147+
return Json(new { data = Array.Empty<object>() });
148+
}
149+
150+
// Project only public-safe fields — no IPs, GUIDs, risk data, or player profiles
151+
var safePlayers = livePlayersResponse.Result.Data.Items
152+
.OrderBy(p => p.Num)
153+
.Select(p => new
154+
{
155+
num = p.Num,
156+
name = p.Name,
157+
score = p.Score,
158+
ping = p.Ping,
159+
countryCode = p.GeoIntelligence?.CountryCode,
160+
countryName = p.GeoIntelligence?.CountryName
161+
})
162+
.ToList();
163+
164+
return Json(new { data = safePlayers });
165+
}, nameof(GetLivePlayers)).ConfigureAwait(false);
166+
}
167+
168+
/// <summary>
169+
/// Returns the map rotation for a server from the repository (not RCON)
170+
/// </summary>
171+
[HttpGet]
172+
public async Task<IActionResult> GetPublicMapRotation(Guid id, CancellationToken cancellationToken = default)
173+
{
174+
return await ExecuteWithErrorHandlingAsync(async () =>
175+
{
176+
var gameServerApiResponse = await repositoryApiClient.GameServers.V1.GetGameServer(id, cancellationToken).ConfigureAwait(false);
177+
if (gameServerApiResponse.IsNotFound || gameServerApiResponse.Result?.Data is null || !gameServerApiResponse.Result.Data.ServerListEnabled)
178+
{
179+
return Json(new { success = false, maps = Array.Empty<object>() });
180+
}
181+
182+
// Find active map rotation assignments for this server
183+
var assignmentsResponse = await repositoryApiClient.MapRotations.V1.GetServerAssignments(
184+
null, id, null, 0, 10, cancellationToken).ConfigureAwait(false);
185+
186+
if (!assignmentsResponse.IsSuccess || assignmentsResponse.Result?.Data?.Items is null || !assignmentsResponse.Result.Data.Items.Any())
187+
{
188+
return Json(new { success = false, maps = Array.Empty<object>() });
189+
}
190+
191+
// Use the first active/synced assignment
192+
var activeAssignment = assignmentsResponse.Result.Data.Items
193+
.FirstOrDefault(a => a.ActivationState == ActivationState.Active)
194+
?? assignmentsResponse.Result.Data.Items.First();
195+
196+
var rotationResponse = await repositoryApiClient.MapRotations.V1.GetMapRotation(
197+
activeAssignment.MapRotationId, cancellationToken).ConfigureAwait(false);
198+
199+
if (!rotationResponse.IsSuccess || rotationResponse.Result?.Data is null)
200+
{
201+
return Json(new { success = false, maps = Array.Empty<object>() });
202+
}
203+
204+
var rotation = rotationResponse.Result.Data;
205+
var orderedMaps = rotation.MapRotationMaps.OrderBy(m => m.SortOrder).ToList();
206+
207+
// Fetch all map details in parallel to avoid N+1
208+
var mapTasks = orderedMaps.Select(rotMap =>
209+
repositoryApiClient.Maps.V1.GetMap(rotMap.MapId, cancellationToken));
210+
var mapResponses = await Task.WhenAll(mapTasks).ConfigureAwait(false);
211+
212+
var enrichedMaps = mapResponses.Select(mapResponse =>
213+
{
214+
var mapData = mapResponse.Result?.Data;
215+
return new
216+
{
217+
mapName = mapData?.MapName ?? "Unknown",
218+
mapTitle = mapData?.DisplayName ?? mapData?.MapName ?? "Unknown",
219+
mapImageUri = !string.IsNullOrEmpty(mapData?.MapImageUri) ? mapData.MapImageUri : null,
220+
hasImage = !string.IsNullOrEmpty(mapData?.MapImageUri)
221+
};
222+
}).ToList();
223+
224+
return Json(new { success = true, maps = enrichedMaps, rotationTitle = rotation.Title });
225+
}, nameof(GetPublicMapRotation)).ConfigureAwait(false);
226+
}
227+
74228
/// <summary>
75229
/// Displays the interactive map view showing recent player locations
76230
/// </summary>
77-
/// <param name="cancellationToken">Cancellation token for the async operation</param>
78-
/// <returns>View with geo-located recent players or empty list on failure</returns>
79231
[HttpGet]
80232
public async Task<IActionResult> Map(CancellationToken cancellationToken = default)
81233
{
@@ -98,4 +250,40 @@ public async Task<IActionResult> Map(CancellationToken cancellationToken = defau
98250
return View(response.Result.Data.Items.ToList());
99251
}, nameof(Map)).ConfigureAwait(false);
100252
}
253+
254+
/// <summary>
255+
/// Returns geo-located recent players for a specific server (for the Player Map tab)
256+
/// </summary>
257+
[HttpGet]
258+
public async Task<IActionResult> GetRecentPlayersForMap(Guid id, CancellationToken cancellationToken = default)
259+
{
260+
return await ExecuteWithErrorHandlingAsync(async () =>
261+
{
262+
var gameServerApiResponse = await repositoryApiClient.GameServers.V1.GetGameServer(id, cancellationToken).ConfigureAwait(false);
263+
if (gameServerApiResponse.IsNotFound || gameServerApiResponse.Result?.Data is null || !gameServerApiResponse.Result.Data.ServerListEnabled)
264+
{
265+
return Json(new { players = Array.Empty<object>() });
266+
}
267+
268+
var response = await repositoryApiClient.RecentPlayers.V1.GetRecentPlayers(
269+
null, id, DateTime.UtcNow.AddHours(-48), RecentPlayersFilter.GeoLocated,
270+
0, 200, null, cancellationToken).ConfigureAwait(false);
271+
272+
if (response.Result?.Data?.Items is null)
273+
{
274+
return Json(new { players = Array.Empty<object>() });
275+
}
276+
277+
var players = response.Result.Data.Items
278+
.Select(p => new
279+
{
280+
lat = p.Lat,
281+
lng = p.Long,
282+
gameType = p.GameType.ToString()
283+
})
284+
.ToList();
285+
286+
return Json(new { players });
287+
}, nameof(GetRecentPlayersForMap)).ConfigureAwait(false);
288+
}
101289
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.GameServers;
2+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.LiveStatus;
3+
using XtremeIdiots.Portal.Web.Models;
4+
5+
namespace XtremeIdiots.Portal.Web.ViewModels;
6+
7+
/// <summary>
8+
/// View model for the public server info page. Contains only non-sensitive data.
9+
/// </summary>
10+
public class ServerInfoViewModel
11+
{
12+
public required GameServerDto GameServer { get; set; }
13+
public GameServerLiveStatusDto? LiveStatus { get; set; }
14+
15+
public List<GameServerStatDto> GameServerStats { get; set; } = [];
16+
public List<MapTimelineDataPoint> MapTimelineDataPoints { get; set; } = [];
17+
}

0 commit comments

Comments
 (0)