diff --git a/backend/Kerko.Tests/GeolocationIntegrationTests.cs b/backend/Kerko.Tests/GeolocationIntegrationTests.cs new file mode 100644 index 0000000..dace01a --- /dev/null +++ b/backend/Kerko.Tests/GeolocationIntegrationTests.cs @@ -0,0 +1,261 @@ +using System.Net; +using System.Text.Json; +using Kerko.Analytics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace Kerko.Tests; + +[TestFixture] +public class GeolocationIntegrationTests +{ + private const string ValidToken = "test-token-geo"; + + private string _analyticsTempDb = null!; + private WebApplicationFactory _factory = null!; + + [SetUp] + public void SetUp() + { + _analyticsTempDb = Path.Combine(Path.GetTempPath(), $"analytics_test_{Guid.NewGuid():N}.db"); + + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseEnvironment("Testing"); + builder.ConfigureServices(services => + { + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor != null) + services.Remove(descriptor); + + services.AddDbContext(options => + options.UseSqlite($"Data Source={_analyticsTempDb}")); + + // Remove the backfill hosted service so it doesn't race with our tests + var backfillDescriptor = services.SingleOrDefault( + d => d.ImplementationType == typeof(LocationBackfillService)); + if (backfillDescriptor != null) + services.Remove(backfillDescriptor); + }); + builder.ConfigureAppConfiguration((ctx, cfg) => + { + cfg.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:AnalyticsConnection"] = $"Data Source={_analyticsTempDb}", + ["KERKO_ADMIN_TOKEN"] = ValidToken + }); + }); + }); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + } + + [TearDown] + public void TearDown() + { + _factory?.Dispose(); + if (File.Exists(_analyticsTempDb)) + File.Delete(_analyticsTempDb); + } + + private HttpClient AuthedClient() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Admin-Token", ValidToken); + return client; + } + + private async Task SeedLogsAsync(IEnumerable rows) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.RequestLogs.AddRange(rows); + await db.SaveChangesAsync(); + } + + private static RequestLog MakeLog(string ip, string endpoint = "kerko") => new() + { + TimestampUtc = DateTime.UtcNow.AddMinutes(-1), + Endpoint = endpoint, + PageNumber = 1, + PageSize = 10, + ClientIp = ip, + UserAgentRaw = "test", + UserAgentSimplified = "Unknown/Unknown/Desktop", + StatusCode = 200, + DurationMs = 10, + RequestId = Guid.NewGuid().ToString() + }; + + // ─── IpGeolocationService direct tests ─────────────────────────────────── + + [Test] + public async Task GeoService_ResolvesIpv4MappedAddresses() + { + var service = _factory.Services.GetRequiredService(); + + var logs = new List + { + MakeLog("::ffff:3.71.121.233"), + MakeLog("::ffff:3.71.123.63"), + MakeLog("::ffff:3.127.74.190"), + MakeLog("::ffff:3.127.74.204"), + MakeLog("::ffff:3.127.74.214"), + }; + + await service.ResolveLocationsAsync(logs); + + foreach (var log in logs) + { + Assert.That(log.Location, Is.Not.Null.And.Not.Empty, + $"Expected location for {log.ClientIp} but got null"); + } + + // These are AWS eu-central-1 IPs — should resolve to Germany/Frankfurt area + TestContext.Out.WriteLine("Resolved locations:"); + foreach (var log in logs) + TestContext.Out.WriteLine($" {log.ClientIp} -> {log.Location}"); + } + + [Test] + public async Task GeoService_SkipsPrivateIps() + { + var service = _factory.Services.GetRequiredService(); + + var logs = new List + { + MakeLog("192.168.1.1"), + MakeLog("10.0.0.1"), + MakeLog("127.0.0.1"), + MakeLog("::1"), + }; + + await service.ResolveLocationsAsync(logs); + + foreach (var log in logs) + { + Assert.That(log.Location, Is.Null, + $"Private IP {log.ClientIp} should not have a location"); + } + } + + [Test] + public async Task GeoService_CachesPreviousLookups() + { + var service = _factory.Services.GetRequiredService(); + + var logs1 = new List { MakeLog("::ffff:3.71.121.233") }; + await service.ResolveLocationsAsync(logs1); + var firstLocation = logs1[0].Location; + + // Second call with same IP should return same result from cache + var logs2 = new List { MakeLog("::ffff:3.71.121.233") }; + await service.ResolveLocationsAsync(logs2); + + Assert.That(logs2[0].Location, Is.EqualTo(firstLocation)); + } + + // ─── Backfill endpoint tests ───────────────────────────────────────────── + + [Test] + public async Task BackfillLocations_PopulatesLocationForExistingLogs() + { + // Seed logs with real IPs but no location + await SeedLogsAsync(new[] + { + MakeLog("::ffff:3.71.121.233"), + MakeLog("::ffff:3.71.121.233"), // duplicate IP + MakeLog("::ffff:3.71.123.63"), + MakeLog("::ffff:3.127.74.190"), + }); + + // Verify no locations yet + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var nullCount = await db.RequestLogs.CountAsync(r => r.Location == null); + Assert.That(nullCount, Is.EqualTo(4)); + } + + // Call backfill + var response = await AuthedClient().PostAsync("/api/admin/backfill-locations", null); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + var updated = doc.RootElement.GetProperty("updated").GetInt32(); + Assert.That(updated, Is.GreaterThan(0), "Expected some logs to be updated"); + + TestContext.Out.WriteLine($"Backfill response: {json}"); + + // Verify locations are now populated + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var logs = await db.RequestLogs.ToListAsync(); + + foreach (var log in logs) + { + Assert.That(log.Location, Is.Not.Null.And.Not.Empty, + $"Expected location for {log.ClientIp} after backfill"); + TestContext.Out.WriteLine($" {log.ClientIp} -> {log.Location}"); + } + + // Both rows with same IP should have same location + var grouped = logs.GroupBy(l => l.ClientIp); + foreach (var group in grouped) + { + var locations = group.Select(l => l.Location).Distinct().ToList(); + Assert.That(locations, Has.Count.EqualTo(1), + $"All rows with IP {group.Key} should have the same location"); + } + } + } + + [Test] + public async Task BackfillLocations_RequiresAuth() + { + var client = _factory.CreateClient(); + var response = await client.PostAsync("/api/admin/backfill-locations", null); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task BackfillLocations_NoLogs_ReturnsZeroUpdated() + { + var response = await AuthedClient().PostAsync("/api/admin/backfill-locations", null); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.That(doc.RootElement.GetProperty("updated").GetInt32(), Is.EqualTo(0)); + } + + // ─── Logs endpoint returns location field ──────────────────────────────── + + [Test] + public async Task AdminLogs_ReturnsLocationField() + { + // Seed a log with a pre-set location + var log = MakeLog("::ffff:3.71.121.233"); + log.Location = "Frankfurt am Main, Germany"; + await SeedLogsAsync(new[] { log }); + + var response = await AuthedClient().GetAsync("/api/admin/logs"); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var items = doc.RootElement.GetProperty("items"); + Assert.That(items.GetArrayLength(), Is.EqualTo(1)); + + var location = items[0].GetProperty("location").GetString(); + Assert.That(location, Is.EqualTo("Frankfurt am Main, Germany")); + } +} diff --git a/backend/Kerko/Admin/AdminController.cs b/backend/Kerko/Admin/AdminController.cs index 33c811e..68398bc 100644 --- a/backend/Kerko/Admin/AdminController.cs +++ b/backend/Kerko/Admin/AdminController.cs @@ -8,7 +8,7 @@ namespace Kerko.Admin; [ApiController] [Route("api/admin")] [ResponseCache(NoStore = true)] -public class AdminController(AnalyticsDbContext db) : ControllerBase +public class AdminController(AnalyticsDbContext db, IpGeolocationService geoService) : ControllerBase { [HttpGet("logs")] public async Task Logs( @@ -166,4 +166,30 @@ public async Task Stats([FromQuery] string window = "24h") topQueries }); } + + [HttpPost("backfill-locations")] + public async Task BackfillLocations() + { + // Get distinct IPs that have no location set + var ips = await db.RequestLogs + .Where(r => r.Location == null) + .Select(r => r.ClientIp) + .Distinct() + .ToListAsync(); + + if (ips.Count == 0) + return Ok(new { message = "No logs need backfilling.", updated = 0 }); + + var resolved = await geoService.ResolveBatchAsync(ips); + + var updated = 0; + foreach (var (ip, location) in resolved) + { + if (location == null) continue; + updated += await db.Database.ExecuteSqlAsync( + $"UPDATE RequestLogs SET Location = {location} WHERE ClientIp = {ip} AND Location IS NULL"); + } + + return Ok(new { message = $"Backfilled {updated} log(s) across {resolved.Count(r => r.Value != null)} IP(s).", updated }); + } } diff --git a/backend/Kerko/Analytics/IpGeolocationService.cs b/backend/Kerko/Analytics/IpGeolocationService.cs new file mode 100644 index 0000000..bc58c75 --- /dev/null +++ b/backend/Kerko/Analytics/IpGeolocationService.cs @@ -0,0 +1,159 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Json; +using System.Net.Sockets; +using System.Text.Json.Serialization; + +namespace Kerko.Analytics; + +public class IpGeolocationService(IHttpClientFactory httpClientFactory, ILogger logger) +{ + private readonly ConcurrentDictionary _cache = new(); + + private const string MappedV4Prefix = "::ffff:"; + + public async Task ResolveLocationsAsync(List logs) + { + var uncachedIps = logs + .Select(l => l.ClientIp) + .Where(ip => !IsPrivateIp(ip) && !_cache.ContainsKey(ip)) + .Distinct() + .ToList(); + + if (uncachedIps.Count > 0) + await FetchBatchAsync(uncachedIps); + + foreach (var log in logs) + { + if (_cache.TryGetValue(log.ClientIp, out var location)) + log.Location = location; + } + } + + /// + /// Resolves a list of IPs to locations. Returns a dictionary of original IP -> location. + /// Batches into groups of 100 (ip-api.com limit) with delays to respect rate limits. + /// + public async Task> ResolveBatchAsync(List ips) + { + var toResolve = ips + .Where(ip => !IsPrivateIp(ip) && !_cache.ContainsKey(ip)) + .Distinct() + .ToList(); + + var chunks = toResolve.Chunk(100).ToList(); + for (var i = 0; i < chunks.Count; i++) + { + await FetchBatchAsync(chunks[i].ToList()); + if (i < chunks.Count - 1) + await Task.Delay(1500); + } + + var result = new Dictionary(); + foreach (var ip in ips) + { + if (_cache.TryGetValue(ip, out var location)) + result[ip] = location; + } + return result; + } + + private async Task FetchBatchAsync(List originalIps) + { + // Map original IP -> normalized IP for the API call + var normalizedMap = originalIps.ToDictionary(ip => ip, NormalizeIp); + var lookupIps = normalizedMap.Values.Distinct().ToList(); + + try + { + var client = httpClientFactory.CreateClient("IpGeo"); + var response = await client.PostAsJsonAsync("batch?fields=status,query,country,city", lookupIps); + + if (!response.IsSuccessStatusCode) + { + logger.LogDebug("ip-api.com batch returned {Status}", response.StatusCode); + foreach (var ip in originalIps) _cache.TryAdd(ip, null); + return; + } + + var results = await response.Content.ReadFromJsonAsync>(); + if (results == null) + { + foreach (var ip in originalIps) _cache.TryAdd(ip, null); + return; + } + + // Build normalized IP -> location map from results + var locationByNormalized = new Dictionary(); + foreach (var r in results) + { + if (r.Query == null) continue; + + if (r.Status == "success") + { + var loc = !string.IsNullOrEmpty(r.City) + ? $"{r.City}, {r.Country}" + : r.Country; + locationByNormalized[r.Query] = loc; + } + else + { + locationByNormalized[r.Query] = null; + } + } + + // Cache using the original IP keys + foreach (var (original, normalized) in normalizedMap) + { + var loc = locationByNormalized.GetValueOrDefault(normalized); + _cache.TryAdd(original, loc); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to fetch IP geolocation for {Count} IPs", originalIps.Count); + foreach (var ip in originalIps) _cache.TryAdd(ip, null); + } + } + + private static string NormalizeIp(string ip) + { + if (ip.StartsWith(MappedV4Prefix, StringComparison.OrdinalIgnoreCase)) + return ip[MappedV4Prefix.Length..]; + return ip; + } + + private static bool IsPrivateIp(string ip) + { + if (string.IsNullOrEmpty(ip)) return true; + if (!IPAddress.TryParse(ip, out var addr)) return true; + if (IPAddress.IsLoopback(addr)) return true; + + if (addr.IsIPv4MappedToIPv6) addr = addr.MapToIPv4(); + + if (addr.AddressFamily == AddressFamily.InterNetwork) + { + var bytes = addr.GetAddressBytes(); + return bytes[0] == 10 || + (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) || + (bytes[0] == 192 && bytes[1] == 168); + } + + return addr.IsIPv6LinkLocal || addr.IsIPv6SiteLocal; + } + + private class IpApiResult + { + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("query")] + public string? Query { get; set; } + + [JsonPropertyName("country")] + public string? Country { get; set; } + + [JsonPropertyName("city")] + public string? City { get; set; } + } +} diff --git a/backend/Kerko/Analytics/LocationBackfillService.cs b/backend/Kerko/Analytics/LocationBackfillService.cs new file mode 100644 index 0000000..e6b8c05 --- /dev/null +++ b/backend/Kerko/Analytics/LocationBackfillService.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; + +namespace Kerko.Analytics; + +/// +/// One-shot background service that backfills Location for existing logs on startup. +/// +public class LocationBackfillService( + IServiceProvider serviceProvider, + IpGeolocationService geoService, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Let the app finish starting before backfilling + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + + try + { + await using var scope = serviceProvider.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var ips = await db.RequestLogs + .Where(r => r.Location == null) + .Select(r => r.ClientIp) + .Distinct() + .ToListAsync(stoppingToken); + + if (ips.Count == 0) + { + logger.LogInformation("Location backfill: nothing to do"); + return; + } + + logger.LogInformation("Location backfill: resolving {Count} unique IP(s)", ips.Count); + + var resolved = await geoService.ResolveBatchAsync(ips); + + var updated = 0; + foreach (var (ip, location) in resolved) + { + if (location == null) continue; + updated += await db.Database.ExecuteSqlAsync( + $"UPDATE RequestLogs SET Location = {location} WHERE ClientIp = {ip} AND Location IS NULL", + stoppingToken); + } + + logger.LogInformation("Location backfill: updated {Updated} log(s)", updated); + } + catch (OperationCanceledException) + { + // Shutdown requested + } + catch (Exception ex) + { + logger.LogWarning(ex, "Location backfill failed"); + } + } +} diff --git a/backend/Kerko/Analytics/RequestLog.cs b/backend/Kerko/Analytics/RequestLog.cs index 8de0d42..11c2790 100644 --- a/backend/Kerko/Analytics/RequestLog.cs +++ b/backend/Kerko/Analytics/RequestLog.cs @@ -18,4 +18,5 @@ public class RequestLog public int DurationMs { get; set; } public int? ResultCount { get; set; } public string RequestId { get; set; } = string.Empty; + public string? Location { get; set; } } diff --git a/backend/Kerko/Analytics/RequestLogWriter.cs b/backend/Kerko/Analytics/RequestLogWriter.cs index d6989da..9b564a4 100644 --- a/backend/Kerko/Analytics/RequestLogWriter.cs +++ b/backend/Kerko/Analytics/RequestLogWriter.cs @@ -6,6 +6,7 @@ namespace Kerko.Analytics; public class RequestLogWriter( Channel channel, IServiceProvider serviceProvider, + IpGeolocationService geoService, ILogger logger) : IHostedService, IDisposable { private Task? _backgroundTask; @@ -132,6 +133,15 @@ private async Task FlushBatchAsync(List batch) { if (batch.Count == 0) return; + try + { + await geoService.ResolveLocationsAsync(batch); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Geolocation resolution failed, persisting without location"); + } + try { await using var scope = serviceProvider.CreateAsyncScope(); diff --git a/backend/Kerko/Program.cs b/backend/Kerko/Program.cs index 0a08533..3f424cc 100644 --- a/backend/Kerko/Program.cs +++ b/backend/Kerko/Program.cs @@ -129,6 +129,15 @@ // Register analytics writer hosted service builder.Services.AddHostedService(); +// Register IP geolocation service +builder.Services.AddHttpClient("IpGeo", c => +{ + c.BaseAddress = new Uri("http://ip-api.com/"); + c.Timeout = TimeSpan.FromSeconds(5); +}); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + // Register services builder.Services.AddScoped(); @@ -146,6 +155,10 @@ { var analyticsDb = scope.ServiceProvider.GetRequiredService(); analyticsDb.Database.EnsureCreated(); + + // Add Location column for existing DBs (EnsureCreated won't alter existing tables) + try { analyticsDb.Database.ExecuteSqlRaw("ALTER TABLE RequestLogs ADD COLUMN Location TEXT"); } + catch (Microsoft.Data.Sqlite.SqliteException) { /* column already exists */ } } app.UseForwardedHeaders(); diff --git a/frontend/src/app/admin/LogsTable.tsx b/frontend/src/app/admin/LogsTable.tsx index aa6bc9c..da794e5 100644 --- a/frontend/src/app/admin/LogsTable.tsx +++ b/frontend/src/app/admin/LogsTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -28,9 +28,6 @@ function formatQuerySummary(log: RequestLog): string { function formatLocalTime(utcString: string): string { return new Date(utcString).toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", @@ -41,12 +38,51 @@ function formatUtcTime(utcString: string): string { return new Date(utcString).toISOString(); } +function formatDateHeader(utcString: string): string { + const date = new Date(utcString); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const logDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diffDays = Math.round( + (today.getTime() - logDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + + return date.toLocaleDateString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function getDateKey(utcString: string): string { + const d = new Date(utcString); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; +} + function statusColor(code: number): string { if (code >= 500) return "text-destructive"; if (code >= 400) return "text-yellow-600 dark:text-yellow-400"; return "text-green-700 dark:text-green-400"; } +const thClass = "text-left px-3 py-2 text-text-tertiary font-medium text-xs"; + +function DateSeparator({ label }: { label: string }) { + return ( +
+
+ + {label} + +
+
+ ); +} + function LogCard({ log }: { log: RequestLog }) { const localTime = formatLocalTime(log.timestampUtc); const utcTime = formatUtcTime(log.timestampUtc); @@ -66,9 +102,12 @@ function LogCard({ log }: { log: RequestLog }) { {formatQuerySummary(log)}
- {/* IP + UA */} + {/* IP + Location + UA */}
{log.clientIp} + {log.location && ( + · {log.location} + )} {" · "} {log.userAgentSimplified || log.userAgentRaw}
@@ -88,6 +127,32 @@ function LogCard({ log }: { log: RequestLog }) { ); } +interface GroupedLogs { + dateKey: string; + dateLabel: string; + logs: RequestLog[]; +} + +function groupLogsByDate(logs: RequestLog[]): GroupedLogs[] { + const groups: GroupedLogs[] = []; + let currentKey = ""; + + for (const log of logs) { + const key = getDateKey(log.timestampUtc); + if (key !== currentKey) { + currentKey = key; + groups.push({ + dateKey: key, + dateLabel: formatDateHeader(log.timestampUtc), + logs: [], + }); + } + groups[groups.length - 1].logs.push(log); + } + + return groups; +} + export function LogsTable({ filters, onUnauthorized }: LogsTableProps) { const [logs, setLogs] = useState([]); const [nextCursor, setNextCursor] = useState(null); @@ -95,6 +160,8 @@ export function LogsTable({ filters, onUnauthorized }: LogsTableProps) { const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); + const groups = useMemo(() => groupLogsByDate(logs), [logs]); + const loadInitial = useCallback(() => { setLoading(true); setError(null); @@ -164,58 +231,76 @@ export function LogsTable({ filters, onUnauthorized }: LogsTableProps) { return (
- {/* Mobile: cards */} + {/* Mobile: cards grouped by date */}
- {logs.map((log) => ( - + {groups.map((group) => ( +
+ +
+ {group.logs.map((log) => ( + + ))} +
+
))}
- {/* Desktop: table */} -
- -
- - - - - - - - - - - - - - {logs.map((log) => { - const localTime = formatLocalTime(log.timestampUtc); - const utcTime = formatUtcTime(log.timestampUtc); - return ( - - - - - - - - + {/* Desktop: table grouped by date */} +
+ {groups.map((group) => ( +
+ + +
+
TimeEndpointQueryIP / UAStatusDurationResults
- {localTime} - {log.endpoint}{formatQuerySummary(log)} -
{log.clientIp}
-
{log.userAgentSimplified || log.userAgentRaw}
-
- {log.statusCode} - {log.durationMs}ms - {log.resultCount ?? "—"} -
+ + + + + + + + + + - ); - })} - -
TimeEndpointQueryIP / LocationUAStatusDurationResults
+ + + {group.logs.map((log) => { + const localTime = formatLocalTime(log.timestampUtc); + const utcTime = formatUtcTime(log.timestampUtc); + return ( + + + {localTime} + + {log.endpoint} + {formatQuerySummary(log)} + +
{log.clientIp}
+ {log.location && ( +
{log.location}
+ )} + + + {log.userAgentSimplified || log.userAgentRaw} + + + {log.statusCode} + + {log.durationMs}ms + + {log.resultCount ?? "—"} + + + ); + })} + + +
+
- + ))}
{nextCursor && ( diff --git a/frontend/src/app/admin/api.ts b/frontend/src/app/admin/api.ts index 6f270e6..a624c3a 100644 --- a/frontend/src/app/admin/api.ts +++ b/frontend/src/app/admin/api.ts @@ -83,6 +83,7 @@ export interface RequestLog { durationMs: number; resultCount: number | null; requestId: string; + location: string | null; } export interface LogsResponse {