33using Microsoft . AspNetCore . Mvc ;
44
55using XtremeIdiots . Portal . Repository . Abstractions . Constants . V1 ;
6+ using XtremeIdiots . Portal . Repository . Abstractions . Models . V1 . GameServers ;
67using XtremeIdiots . Portal . Repository . Abstractions . Models . V1 . LiveStatus ;
78using XtremeIdiots . Portal . Repository . Api . Client . V1 ;
89using XtremeIdiots . Portal . Web . Auth . Constants ;
910using XtremeIdiots . Portal . Web . Extensions ;
11+ using XtremeIdiots . Portal . Web . Models ;
1012using XtremeIdiots . Portal . Web . ViewModels ;
1113
1214namespace 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 ) ]
2720public 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}
0 commit comments