Lol
This commit is contained in:
418
AdminPanel.cs
418
AdminPanel.cs
@@ -151,6 +151,21 @@ public class AdminPanel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P13d: archive endpoints with archive id in the URL path
|
||||||
|
// Layout: /admin/api/archive/<id>/events and /admin/api/archive/<id>/positions
|
||||||
|
if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/events"))
|
||||||
|
{
|
||||||
|
var archiveId = path["/admin/api/archive/".Length..^"/events".Length];
|
||||||
|
await HandleArchiveEventsAsync(archiveId, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/positions"))
|
||||||
|
{
|
||||||
|
var archiveId = path["/admin/api/archive/".Length..^"/positions".Length];
|
||||||
|
await HandleArchivePositionsAsync(archiveId, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// REST API endpointy
|
// REST API endpointy
|
||||||
switch (path)
|
switch (path)
|
||||||
{
|
{
|
||||||
@@ -194,6 +209,11 @@ public class AdminPanel
|
|||||||
await HandleLobbyUpdateSettingsAsync(request, response);
|
await HandleLobbyUpdateSettingsAsync(request, response);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ── P13d: archive listing endpoint ───────────────────────
|
||||||
|
case "/admin/api/archive":
|
||||||
|
await HandleListArchivesAsync(response);
|
||||||
|
break;
|
||||||
|
|
||||||
case "/admin/api/logout":
|
case "/admin/api/logout":
|
||||||
_sessions.TryRemove(sessionId, out _);
|
_sessions.TryRemove(sessionId, out _);
|
||||||
response.SetCookie(new Cookie("geosus_admin_session", "", "/admin") { Expired = true });
|
response.SetCookie(new Cookie("geosus_admin_session", "", "/admin") { Expired = true });
|
||||||
@@ -330,44 +350,114 @@ public class AdminPanel
|
|||||||
await WriteJsonAsync(response, new { lobbies });
|
await WriteJsonAsync(response, new { lobbies });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// P13b: full ServerConfig round-trip. The previous hand-rolled selective
|
||||||
|
/// serialization only exposed ~10 fields; the admin panel now needs the
|
||||||
|
/// entire appsettings.json shape (~50 fields). We serialize through the
|
||||||
|
/// same JsonOptions the file loader uses so camelCase / enum-as-string
|
||||||
|
/// stays consistent. The StatsApiKey is masked in the GET response so
|
||||||
|
/// the secret doesn't leak into a browser dump.
|
||||||
|
/// </summary>
|
||||||
private async Task HandleGetConfigAsync(HttpListenerResponse response)
|
private async Task HandleGetConfigAsync(HttpListenerResponse response)
|
||||||
{
|
{
|
||||||
// Vrátíme pouze bezpečné konfigurace (ne hesla apod.)
|
// Round-trip through JSON to get a clean copy + apply the server's
|
||||||
var config = new
|
// own naming policy / enum converters. Mask StatsApiKey on the way out.
|
||||||
{
|
var json = JsonSerializer.Serialize(_config, JsonOptions.Indented);
|
||||||
tcpPort = _config.TcpPort,
|
var node = JsonDocument.Parse(json).RootElement;
|
||||||
httpPort = _config.HttpPort,
|
|
||||||
maxPlayersPerLobby = _config.MaxPlayersPerLobby,
|
|
||||||
killDistanceM = _config.KillDistanceM,
|
|
||||||
killCooldownMs = _config.KillCooldownMs,
|
|
||||||
discussionPhaseMs = _config.DiscussionPhaseMs,
|
|
||||||
votingPhaseMs = _config.VotingPhaseMs,
|
|
||||||
taskStartDistanceM = _config.TaskStartDistanceM,
|
|
||||||
sabotageCooldownMs = _config.SabotageCooldownMs,
|
|
||||||
criticalMeltdownDeadlineMs = _config.CriticalMeltdownDeadlineMs,
|
|
||||||
maxSpeedMps = _config.MaxSpeedMps,
|
|
||||||
teleportThresholdMeters = _config.TeleportThresholdMeters
|
|
||||||
};
|
|
||||||
|
|
||||||
await WriteJsonAsync(response, config);
|
// Re-emit with the api-key masked.
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true }))
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
foreach (var prop in node.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (prop.NameEquals("statsApiKey"))
|
||||||
|
{
|
||||||
|
var raw = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : null;
|
||||||
|
if (string.IsNullOrEmpty(raw)) writer.WriteNull("statsApiKey");
|
||||||
|
else writer.WriteString("statsApiKey", "********");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prop.WriteTo(writer);
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
response.ContentType = "application/json; charset=utf-8";
|
||||||
|
var bytes = ms.ToArray();
|
||||||
|
response.ContentLength64 = bytes.Length;
|
||||||
|
await response.OutputStream.WriteAsync(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// P13b: accept arbitrary subset of ServerConfig fields. Only updates
|
||||||
|
/// fields the request actually carries; ignores unknown fields. Persists
|
||||||
|
/// the file at appsettings.json on every successful update so changes
|
||||||
|
/// survive restart. Apply rule: changes affect ONLY new lobbies created
|
||||||
|
/// after this point - existing lobbies keep their snapshot of the values
|
||||||
|
/// taken at their creation time. (See LobbySettings.FromDefaults.)
|
||||||
|
/// </summary>
|
||||||
private async Task HandleSetConfigAsync(HttpListenerRequest request, HttpListenerResponse response)
|
private async Task HandleSetConfigAsync(HttpListenerRequest request, HttpListenerResponse response)
|
||||||
{
|
{
|
||||||
using var reader = new StreamReader(request.InputStream);
|
using var reader = new StreamReader(request.InputStream);
|
||||||
var body = await reader.ReadToEndAsync();
|
var body = await reader.ReadToEndAsync();
|
||||||
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
var data = JsonSerializer.Deserialize<JsonElement>(body);
|
||||||
|
|
||||||
// Aplikuj změny
|
// Reflection-based update: walk every public settable property on
|
||||||
if (data.TryGetProperty("killDistanceM", out var kd)) _config.KillDistanceM = kd.GetDouble();
|
// ServerConfig, look for a matching camelCased field in the JSON,
|
||||||
if (data.TryGetProperty("killCooldownMs", out var kc)) _config.KillCooldownMs = kc.GetInt32();
|
// try to coerce. Skip the masked key sentinel so the user can leave
|
||||||
if (data.TryGetProperty("discussionPhaseMs", out var dp)) _config.DiscussionPhaseMs = dp.GetInt32();
|
// ******** in the form without nuking their real key.
|
||||||
if (data.TryGetProperty("votingPhaseMs", out var vp)) _config.VotingPhaseMs = vp.GetInt32();
|
var props = typeof(ServerConfig).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
||||||
if (data.TryGetProperty("maxSpeedMps", out var ms)) _config.MaxSpeedMps = ms.GetDouble();
|
int applied = 0;
|
||||||
|
foreach (var p in props)
|
||||||
|
{
|
||||||
|
if (!p.CanWrite) continue;
|
||||||
|
var camel = char.ToLowerInvariant(p.Name[0]) + p.Name[1..];
|
||||||
|
if (!data.TryGetProperty(camel, out var el)) continue;
|
||||||
|
|
||||||
_logger.LogInformation("Config updated via admin panel");
|
try
|
||||||
|
{
|
||||||
|
if (p.Name == nameof(ServerConfig.StatsApiKey) &&
|
||||||
|
el.ValueKind == JsonValueKind.String && el.GetString() == "********")
|
||||||
|
{
|
||||||
|
continue; // masked sentinel - leave existing
|
||||||
|
}
|
||||||
|
|
||||||
await WriteJsonAsync(response, new { success = true });
|
object? val = (Type.GetTypeCode(p.PropertyType)) switch
|
||||||
|
{
|
||||||
|
TypeCode.Int32 => el.ValueKind == JsonValueKind.Number ? el.GetInt32() : int.TryParse(el.GetString(), out var i) ? i : (object?)null,
|
||||||
|
TypeCode.Int64 => el.ValueKind == JsonValueKind.Number ? el.GetInt64() : long.TryParse(el.GetString(), out var l) ? l : (object?)null,
|
||||||
|
TypeCode.Double => el.ValueKind == JsonValueKind.Number ? el.GetDouble() : double.TryParse(el.GetString(), out var d) ? d : (object?)null,
|
||||||
|
TypeCode.Boolean => el.ValueKind == JsonValueKind.True ? true : el.ValueKind == JsonValueKind.False ? false : (object?)null,
|
||||||
|
TypeCode.String => el.ValueKind == JsonValueKind.String ? el.GetString() : el.ValueKind == JsonValueKind.Null ? null : (object?)null,
|
||||||
|
_ => p.PropertyType.IsEnum && el.ValueKind == JsonValueKind.String && Enum.TryParse(p.PropertyType, el.GetString(), true, out var ev) ? ev : null
|
||||||
|
};
|
||||||
|
if (val != null || p.PropertyType == typeof(string))
|
||||||
|
{
|
||||||
|
p.SetValue(_config, val);
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Admin SetConfig: failed to set {Property}", p.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to disk so the change survives restart.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_config.Save("appsettings.json");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to persist admin config update to appsettings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Config updated via admin panel: {Count} field(s)", applied);
|
||||||
|
|
||||||
|
await WriteJsonAsync(response, new { success = true, fieldsApplied = applied });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandlePlayersAsync(HttpListenerRequest request, HttpListenerResponse response)
|
private async Task HandlePlayersAsync(HttpListenerRequest request, HttpListenerResponse response)
|
||||||
@@ -553,6 +643,284 @@ public class AdminPanel
|
|||||||
await WriteJsonAsync(response, new { success = true });
|
await WriteJsonAsync(response, new { success = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region ═══════════════════════════════════════════════════════════════════
|
||||||
|
// P13d: ARCHIVE
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// P13d: list every archived lobby on disk. Persistence.ArchiveLobby
|
||||||
|
/// drops them at <DataPath>/archive/<lobbyId>_<yyyyMMddHHmmss>/. We parse
|
||||||
|
/// the directory name back into (lobbyId, timestamp) for the UI, count
|
||||||
|
/// the WAL events, and report on-disk size so the operator can spot
|
||||||
|
/// abnormally-large or empty runs at a glance.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleListArchivesAsync(HttpListenerResponse response)
|
||||||
|
{
|
||||||
|
var archiveDir = Path.Combine(_config.DataPath, "archive");
|
||||||
|
if (!Directory.Exists(archiveDir))
|
||||||
|
{
|
||||||
|
await WriteJsonAsync(response, new { archives = Array.Empty<object>() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var archives = Directory.GetDirectories(archiveDir)
|
||||||
|
.Select(d => new DirectoryInfo(d))
|
||||||
|
.OrderByDescending(d => d.CreationTimeUtc)
|
||||||
|
.Select(d =>
|
||||||
|
{
|
||||||
|
var name = d.Name;
|
||||||
|
var lastUnderscore = name.LastIndexOf('_');
|
||||||
|
string lobbyId = name;
|
||||||
|
string timestampRaw = "";
|
||||||
|
if (lastUnderscore > 0)
|
||||||
|
{
|
||||||
|
lobbyId = name[..lastUnderscore];
|
||||||
|
timestampRaw = name[(lastUnderscore + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
string isoTimestamp = "";
|
||||||
|
if (DateTime.TryParseExact(timestampRaw, "yyyyMMddHHmmss",
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
||||||
|
out var parsedTs))
|
||||||
|
{
|
||||||
|
isoTimestamp = parsedTs.ToString("o");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fallback: directory creation time.
|
||||||
|
isoTimestamp = d.CreationTimeUtc.ToString("o");
|
||||||
|
}
|
||||||
|
|
||||||
|
int eventCount = 0;
|
||||||
|
long sizeBytes = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var w in Directory.GetFiles(d.FullName, "wal_*.ndjson"))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(w);
|
||||||
|
sizeBytes += fi.Length;
|
||||||
|
eventCount += File.ReadLines(w).Count(l => !string.IsNullOrWhiteSpace(l));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to stat archive {Dir}", d.FullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
id = name,
|
||||||
|
lobbyId,
|
||||||
|
timestamp = isoTimestamp,
|
||||||
|
eventCount,
|
||||||
|
sizeBytes
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await WriteJsonAsync(response, new { archives });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// P13d: stream a single archive's WAL events back to the admin panel as
|
||||||
|
/// a JSON array. The on-disk format is NDJSON (one already-serialized
|
||||||
|
/// GameEvent per line); we glue the lines with commas inside [] rather
|
||||||
|
/// than re-parsing each one - the admin panel doesn't need the parsed
|
||||||
|
/// tree on the server side, and zero-copy keeps memory usage bounded
|
||||||
|
/// even for hour-long lobbies.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleArchiveEventsAsync(string archiveId, HttpListenerResponse response)
|
||||||
|
{
|
||||||
|
if (!IsSafeArchiveId(archiveId))
|
||||||
|
{
|
||||||
|
response.StatusCode = 400;
|
||||||
|
await WriteJsonAsync(response, new { error = "Invalid archive id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var archivePath = Path.Combine(_config.DataPath, "archive", archiveId);
|
||||||
|
if (!Directory.Exists(archivePath))
|
||||||
|
{
|
||||||
|
response.StatusCode = 404;
|
||||||
|
await WriteJsonAsync(response, new { error = "Archive not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson")
|
||||||
|
.OrderBy(f => f)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Buffer the whole array in memory so we can set Content-Length and
|
||||||
|
// avoid chunked transfer headaches. Typical lobby archives are well
|
||||||
|
// under 10MB; if we ever need to stream, swap in chunked-encoding here.
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append('[');
|
||||||
|
bool first = true;
|
||||||
|
foreach (var w in walFiles)
|
||||||
|
{
|
||||||
|
string[] lines;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lines = await File.ReadAllLinesAsync(w);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read archive WAL {Path}", w);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
if (!first) sb.Append(',');
|
||||||
|
sb.Append(line);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.Append(']');
|
||||||
|
|
||||||
|
response.ContentType = "application/json; charset=utf-8";
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||||
|
response.ContentLength64 = bytes.Length;
|
||||||
|
await response.OutputStream.WriteAsync(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// P13d: extract per-player polylines from an archive's WAL. We don't
|
||||||
|
/// persist UpdatePosition (high-rate client telemetry), so we reconstruct
|
||||||
|
/// movement traces from the events that DO carry coordinates: kill/body/
|
||||||
|
/// task-start/meeting-call/etc. Each event with a `location` or `position`
|
||||||
|
/// payload is attributed to the actor (preferred) or the victim/clientUuid
|
||||||
|
/// inside the payload, then stitched into a per-player time-ordered list
|
||||||
|
/// the frontend renders as a Leaflet polyline.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleArchivePositionsAsync(string archiveId, HttpListenerResponse response)
|
||||||
|
{
|
||||||
|
if (!IsSafeArchiveId(archiveId))
|
||||||
|
{
|
||||||
|
response.StatusCode = 400;
|
||||||
|
await WriteJsonAsync(response, new { error = "Invalid archive id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var archivePath = Path.Combine(_config.DataPath, "archive", archiveId);
|
||||||
|
if (!Directory.Exists(archivePath))
|
||||||
|
{
|
||||||
|
response.StatusCode = 404;
|
||||||
|
await WriteJsonAsync(response, new { error = "Archive not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson")
|
||||||
|
.OrderBy(f => f)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var polylines = new Dictionary<string, List<object>>();
|
||||||
|
var playerNames = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
foreach (var w in walFiles)
|
||||||
|
{
|
||||||
|
string[] lines;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lines = await File.ReadAllLinesAsync(w);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read archive WAL {Path}", w);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
|
||||||
|
JsonDocument? doc = null;
|
||||||
|
try { doc = JsonDocument.Parse(line); }
|
||||||
|
catch { continue; }
|
||||||
|
using (doc)
|
||||||
|
{
|
||||||
|
var root = doc.RootElement;
|
||||||
|
string eventType = root.TryGetProperty("eventType", out var et) ? (et.GetString() ?? "") : "";
|
||||||
|
string actor = root.TryGetProperty("actor", out var a) ? (a.GetString() ?? "") : "";
|
||||||
|
string ts = root.TryGetProperty("timestamp", out var t) ? (t.GetString() ?? "") : "";
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Capture display names from PlayerJoined for legend labels.
|
||||||
|
if (eventType == "PlayerJoined")
|
||||||
|
{
|
||||||
|
string cid = payload.TryGetProperty("clientUuid", out var c) ? (c.GetString() ?? "") : "";
|
||||||
|
string dn = payload.TryGetProperty("displayName", out var d) ? (d.GetString() ?? "") : "";
|
||||||
|
if (!string.IsNullOrEmpty(cid) && !string.IsNullOrEmpty(dn))
|
||||||
|
playerNames[cid] = dn;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a (lat,lon) pair in either `location` or `position`.
|
||||||
|
double? lat = null, lon = null;
|
||||||
|
if (payload.TryGetProperty("location", out var loc) && loc.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (loc.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble();
|
||||||
|
if (loc.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble();
|
||||||
|
}
|
||||||
|
else if (payload.TryGetProperty("position", out var pos) && pos.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (pos.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble();
|
||||||
|
if (pos.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lat.HasValue || !lon.HasValue) continue;
|
||||||
|
|
||||||
|
// Attribute the position to a specific player. PlayerKilled
|
||||||
|
// gives us the victim's last position (more useful than the
|
||||||
|
// killer's for trace reconstruction); everything else falls
|
||||||
|
// through to actor / payload.clientUuid.
|
||||||
|
string? pid = null;
|
||||||
|
if (eventType == "PlayerKilled")
|
||||||
|
pid = payload.TryGetProperty("victimId", out var v) ? v.GetString() : null;
|
||||||
|
else if (!string.IsNullOrEmpty(actor))
|
||||||
|
pid = actor;
|
||||||
|
else if (payload.TryGetProperty("clientUuid", out var c2))
|
||||||
|
pid = c2.GetString();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(pid)) continue;
|
||||||
|
|
||||||
|
if (!polylines.TryGetValue(pid, out var list))
|
||||||
|
{
|
||||||
|
list = new List<object>();
|
||||||
|
polylines[pid] = list;
|
||||||
|
}
|
||||||
|
list.Add(new { lat = lat.Value, lon = lon.Value, ts, evt = eventType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = polylines.Select(kvp => new
|
||||||
|
{
|
||||||
|
playerId = kvp.Key,
|
||||||
|
displayName = playerNames.TryGetValue(kvp.Key, out var n) ? n : kvp.Key,
|
||||||
|
points = kvp.Value
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await WriteJsonAsync(response, new { polylines = result });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reject any archive id containing a path-traversal token. We also bar
|
||||||
|
/// raw separators because the on-disk layout is one flat directory per
|
||||||
|
/// archive - no nesting expected.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsSafeArchiveId(string archiveId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(archiveId)) return false;
|
||||||
|
if (archiveId.Contains("..")) return false;
|
||||||
|
if (archiveId.Contains('/') || archiveId.Contains('\\')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
#region ═══════════════════════════════════════════════════════════════════
|
#region ═══════════════════════════════════════════════════════════════════
|
||||||
// WEBSOCKET - SPECTATE
|
// WEBSOCKET - SPECTATE
|
||||||
// ════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ public static class AdminResources
|
|||||||
<span class=""nav-icon"">📢</span>
|
<span class=""nav-icon"">📢</span>
|
||||||
<span>Broadcast</span>
|
<span>Broadcast</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href=""#"" class=""nav-item"" data-view=""archive"">
|
||||||
|
<span class=""nav-icon"">📁</span>
|
||||||
|
<span>Archiv</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=""sidebar-footer"">
|
<div class=""sidebar-footer"">
|
||||||
@@ -351,6 +355,48 @@ public static class AdminResources
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Archive View (P13d) -->
|
||||||
|
<div id=""view-archive"" class=""view"">
|
||||||
|
<div class=""view-header"">
|
||||||
|
<h1>Archiv her</h1>
|
||||||
|
<div class=""header-actions"">
|
||||||
|
<button class=""btn"" onclick=""loadArchiveList()"">🔄 Obnovit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id=""archive-list-container"" class=""archive-container"">
|
||||||
|
<div id=""archive-list"" class=""archive-list"">
|
||||||
|
<div class=""empty-state"">Načítám archivované hry...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id=""archive-detail-container"" class=""archive-detail"" style=""display: none;"">
|
||||||
|
<div class=""detail-header"">
|
||||||
|
<button class=""btn"" onclick=""closeArchiveDetail()"">← Zpět na seznam</button>
|
||||||
|
<button class=""btn btn-primary"" onclick=""window.print()"">🖨️ Vytisknout report</button>
|
||||||
|
</div>
|
||||||
|
<div class=""print-page"">
|
||||||
|
<h2 id=""archive-detail-title"">Archiv hry</h2>
|
||||||
|
<div id=""archive-detail-summary"" class=""archive-summary""></div>
|
||||||
|
|
||||||
|
<div class=""archive-detail-grid"">
|
||||||
|
<div class=""card"">
|
||||||
|
<div class=""card-header""><h3>📍 Trasy hráčů</h3></div>
|
||||||
|
<div class=""card-body"">
|
||||||
|
<div id=""archive-map"" style=""height: 500px; border-radius: 8px;""></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class=""card"">
|
||||||
|
<div class=""card-header""><h3>📋 Časová osa</h3></div>
|
||||||
|
<div class=""card-body"">
|
||||||
|
<div id=""archive-timeline"" class=""archive-timeline""></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1648,6 +1694,172 @@ body {
|
|||||||
|
|
||||||
.shake {
|
.shake {
|
||||||
animation: shake 0.3s ease;
|
animation: shake 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
ARCHIVE VIEW (P13d)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.archive-container {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-summary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-timeline {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-left: 3px solid var(--accent-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event-type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event-payload {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: monospace;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.archive-detail-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
PRINT (P13d)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Reset for paper - dark theme is unprintable. */
|
||||||
|
body, html { background: white !important; color: black !important; }
|
||||||
|
|
||||||
|
/* Hide everything except the per-game detail report. */
|
||||||
|
.sidebar, .modal-overlay, .modal,
|
||||||
|
#login-screen, #view-overview, #view-lobbies, #view-spectate,
|
||||||
|
#view-players, #view-config, #view-broadcast,
|
||||||
|
#archive-list-container, .detail-header, .header-actions,
|
||||||
|
.sidebar-footer, .nav-section { display: none !important; }
|
||||||
|
|
||||||
|
.main-content { margin-left: 0 !important; padding: 0 !important; }
|
||||||
|
.view-header h1 { color: black !important; }
|
||||||
|
|
||||||
|
.archive-detail, .archive-summary, .card, .card-header, .card-body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail-grid { grid-template-columns: 1fr !important; gap: 12px !important; }
|
||||||
|
|
||||||
|
.timeline-event {
|
||||||
|
background: #f5f5f5 !important;
|
||||||
|
color: black !important;
|
||||||
|
border-left-color: #555 !important;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.timeline-event-type { color: #333 !important; }
|
||||||
|
.timeline-event-meta,
|
||||||
|
.timeline-event-payload { color: #555 !important; }
|
||||||
|
.archive-timeline { max-height: none !important; overflow: visible !important; }
|
||||||
|
|
||||||
|
/* Force the map to a printable size and let it overflow into multiple pages
|
||||||
|
gracefully (browsers can't print live tiles, but a static screenshot of the
|
||||||
|
canvas will be embedded by most modern browsers). */
|
||||||
|
#archive-map { height: 400px !important; page-break-inside: avoid; }
|
||||||
}";
|
}";
|
||||||
|
|
||||||
public static string JavaScript => @"/* ═══════════════════════════════════════════════════════════════════════════
|
public static string JavaScript => @"/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -1832,6 +2044,7 @@ function showView(viewName) {
|
|||||||
if (viewName === 'players') loadPlayers();
|
if (viewName === 'players') loadPlayers();
|
||||||
if (viewName === 'config') loadConfig();
|
if (viewName === 'config') loadConfig();
|
||||||
if (viewName === 'broadcast') loadBroadcastTargets();
|
if (viewName === 'broadcast') loadBroadcastTargets();
|
||||||
|
if (viewName === 'archive') loadArchiveList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -2370,5 +2583,158 @@ function debounce(func, wait) {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/[&<>""]/g, c => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '""': '"'
|
||||||
|
})[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// ARCHIVE (P13d)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let archiveMap = null;
|
||||||
|
let currentArchiveId = null;
|
||||||
|
|
||||||
|
async function loadArchiveList() {
|
||||||
|
const container = document.getElementById('archive-list');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<div class=""empty-state loading"">Načítám...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/api/archive');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.archives || data.archives.length === 0) {
|
||||||
|
container.innerHTML = '<div class=""empty-state"">Žádné archivované hry</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render via data attributes + delegated click handler so we don't
|
||||||
|
// have to embed user-controlled values into an inline onclick string
|
||||||
|
// (which is a quoting nightmare given the verbatim C# wrapping).
|
||||||
|
container.innerHTML = data.archives.map(a => {
|
||||||
|
const ts = a.timestamp ? new Date(a.timestamp).toLocaleString() : '?';
|
||||||
|
const sizeKb = a.sizeBytes ? Math.round(a.sizeBytes / 1024) + ' KB' : '?';
|
||||||
|
const lobbyDisplay = escapeHtml(a.lobbyId || '');
|
||||||
|
return '<div class=""archive-card"" data-id=""' + escapeHtml(a.id) + '"" data-lobby=""' + lobbyDisplay + '"" data-ts=""' + escapeHtml(ts) + '"">' +
|
||||||
|
'<div class=""archive-card-title"">' + lobbyDisplay + '</div>' +
|
||||||
|
'<div class=""archive-card-meta""><span>' + escapeHtml(ts) + '</span><span>' + a.eventCount + ' eventů, ' + sizeKb + '</span></div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.archive-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
openArchiveDetail(card.dataset.id, card.dataset.lobby, card.dataset.ts);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class=""empty-state"">Chyba načítání archivu</div>';
|
||||||
|
console.error('loadArchiveList:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openArchiveDetail(id, lobbyId, ts) {
|
||||||
|
currentArchiveId = id;
|
||||||
|
document.getElementById('archive-list-container').style.display = 'none';
|
||||||
|
document.getElementById('archive-detail-container').style.display = 'block';
|
||||||
|
document.getElementById('archive-detail-title').textContent = 'Archiv: ' + lobbyId;
|
||||||
|
document.getElementById('archive-detail-summary').innerHTML =
|
||||||
|
'<strong>Lobby ID:</strong> ' + escapeHtml(lobbyId) +
|
||||||
|
' | <strong>Archivováno:</strong> ' + escapeHtml(ts);
|
||||||
|
|
||||||
|
// Lazy-init the Leaflet map. Subsequent opens reuse the same instance and
|
||||||
|
// just clear out the previously-drawn polylines/markers.
|
||||||
|
if (!archiveMap) {
|
||||||
|
archiveMap = L.map('archive-map').setView([0, 0], 2);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap',
|
||||||
|
maxZoom: 19
|
||||||
|
}).addTo(archiveMap);
|
||||||
|
} else {
|
||||||
|
archiveMap.eachLayer(l => {
|
||||||
|
if (l instanceof L.Polyline || l instanceof L.CircleMarker || l instanceof L.Marker) {
|
||||||
|
archiveMap.removeLayer(l);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Force layout recalculation after the container becomes visible.
|
||||||
|
setTimeout(() => archiveMap && archiveMap.invalidateSize(), 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [eventsRes, positionsRes] = await Promise.all([
|
||||||
|
fetch('/admin/api/archive/' + encodeURIComponent(currentArchiveId) + '/events'),
|
||||||
|
fetch('/admin/api/archive/' + encodeURIComponent(currentArchiveId) + '/positions')
|
||||||
|
]);
|
||||||
|
const events = await eventsRes.json();
|
||||||
|
const positions = await positionsRes.json();
|
||||||
|
|
||||||
|
renderArchiveTimeline(events);
|
||||||
|
renderArchivePolylines(positions);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('openArchiveDetail:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeArchiveDetail() {
|
||||||
|
document.getElementById('archive-list-container').style.display = 'block';
|
||||||
|
document.getElementById('archive-detail-container').style.display = 'none';
|
||||||
|
currentArchiveId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderArchiveTimeline(events) {
|
||||||
|
const container = document.getElementById('archive-timeline');
|
||||||
|
if (!container) return;
|
||||||
|
if (!events || !Array.isArray(events) || events.length === 0) {
|
||||||
|
container.innerHTML = '<div class=""empty-state"">Žádné eventy</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = events.map(e => {
|
||||||
|
const ts = e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : '?';
|
||||||
|
const payload = e.payload ? escapeHtml(JSON.stringify(e.payload, null, 2)) : '';
|
||||||
|
const actor = e.actor ? ' (actor: ' + escapeHtml(String(e.actor).substring(0, 8)) + ')' : '';
|
||||||
|
const type = escapeHtml(e.eventType || '?');
|
||||||
|
return '<div class=""timeline-event"">' +
|
||||||
|
'<div class=""timeline-event-type"">' + type + '</div>' +
|
||||||
|
'<div class=""timeline-event-meta"">[' + ts + ']' + actor + ' #' + (e.eventId || '?') + '</div>' +
|
||||||
|
(payload ? '<div class=""timeline-event-payload"">' + payload + '</div>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderArchivePolylines(data) {
|
||||||
|
if (!data || !data.polylines || data.polylines.length === 0) return;
|
||||||
|
|
||||||
|
const colors = ['#00d4ff', '#7c3aed', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#3b82f6', '#ec4899'];
|
||||||
|
let bounds = null;
|
||||||
|
|
||||||
|
data.polylines.forEach((player, idx) => {
|
||||||
|
if (!player.points || player.points.length === 0) return;
|
||||||
|
const latlngs = player.points.map(p => [p.lat, p.lon]);
|
||||||
|
const color = colors[idx % colors.length];
|
||||||
|
const label = player.displayName || player.playerId || '?';
|
||||||
|
|
||||||
|
const line = L.polyline(latlngs, { color: color, weight: 3, opacity: 0.8 })
|
||||||
|
.bindTooltip(label)
|
||||||
|
.addTo(archiveMap);
|
||||||
|
|
||||||
|
L.circleMarker(latlngs[0], { radius: 6, color: color, fillColor: color, fillOpacity: 1, weight: 1 })
|
||||||
|
.bindTooltip(label + ' (start)')
|
||||||
|
.addTo(archiveMap);
|
||||||
|
if (latlngs.length > 1) {
|
||||||
|
L.circleMarker(latlngs[latlngs.length - 1], { radius: 6, color: color, fillColor: '#fff', fillOpacity: 1, weight: 2 })
|
||||||
|
.bindTooltip(label + ' (end)')
|
||||||
|
.addTo(archiveMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bounds) bounds = line.getBounds();
|
||||||
|
else bounds.extend(line.getBounds());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bounds && bounds.isValid()) archiveMap.fitBounds(bounds.pad(0.15));
|
||||||
}";
|
}";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user