diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/ClientLogs.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/ClientLogs.razor
index f33b0ae..03697d9 100644
--- a/src/TaxBaik.Web.Client/Components/Admin/Pages/ClientLogs.razor
+++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/ClientLogs.razor
@@ -14,6 +14,15 @@
+ @if (_summary is not null)
+ {
+
+
Total @_summary.Total
+
Error @_summary.ErrorCount
+
Warning @_summary.WarningCount
+
Info @_summary.InfoCount
+
+ }
diff --git a/src/TaxBaik.Web/Endpoints/ClientLogs/GetSummaryEndpoint.cs b/src/TaxBaik.Web/Endpoints/ClientLogs/GetSummaryEndpoint.cs
new file mode 100644
index 0000000..2d53f82
--- /dev/null
+++ b/src/TaxBaik.Web/Endpoints/ClientLogs/GetSummaryEndpoint.cs
@@ -0,0 +1,107 @@
+using FastEndpoints;
+
+namespace TaxBaik.Web.Endpoints.ClientLogs;
+
+public sealed class ClientLogSummary
+{
+ public int Total { get; set; }
+ public int ErrorCount { get; set; }
+ public int WarningCount { get; set; }
+ public int InfoCount { get; set; }
+ public IReadOnlyList
> TopSources { get; set; } = [];
+ public IReadOnlyList> TopRoutes { get; set; } = [];
+}
+
+public sealed class GetSummaryEndpoint : EndpointWithoutRequest
+{
+ private readonly IWebHostEnvironment _environment;
+
+ public GetSummaryEndpoint(IWebHostEnvironment environment)
+ {
+ _environment = environment;
+ }
+
+ public override void Configure()
+ {
+ Get("/api/client-logs/summary");
+ Policies("AdminOnly");
+ }
+
+ public override async Task HandleAsync(CancellationToken ct)
+ {
+ var logDir = Path.Combine(_environment.ContentRootPath, "logs");
+ var summary = new ClientLogSummary();
+ if (!Directory.Exists(logDir))
+ {
+ await SendAsync(summary, cancellation: ct);
+ return;
+ }
+
+ var files = Directory.GetFiles(logDir, "taxbaik-*.log")
+ .OrderByDescending(File.GetLastWriteTimeUtc)
+ .Take(3)
+ .ToArray();
+
+ var sourceCounts = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var routeCounts = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var file in files)
+ {
+ foreach (var line in await File.ReadAllLinesAsync(file, ct))
+ {
+ if (!line.Contains("ClientLog ", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ summary.Total++;
+ var level = ExtractLevel(line);
+ if (string.Equals(level, "error", StringComparison.OrdinalIgnoreCase))
+ summary.ErrorCount++;
+ else if (string.Equals(level, "warning", StringComparison.OrdinalIgnoreCase))
+ summary.WarningCount++;
+ else
+ summary.InfoCount++;
+
+ var source = ExtractSegment(line, "ClientLog ", 2) ?? "unknown";
+ var route = ExtractNamedValue(line, "Route=") ?? "unknown";
+ sourceCounts[source] = sourceCounts.TryGetValue(source, out var sourceCount) ? sourceCount + 1 : 1;
+ routeCounts[route] = routeCounts.TryGetValue(route, out var routeCount) ? routeCount + 1 : 1;
+ }
+ }
+
+ summary.TopSources = sourceCounts
+ .OrderByDescending(kvp => kvp.Value)
+ .Take(5)
+ .ToList();
+ summary.TopRoutes = routeCounts
+ .OrderByDescending(kvp => kvp.Value)
+ .Take(5)
+ .ToList();
+
+ await SendAsync(summary, cancellation: ct);
+ }
+
+ private static string? ExtractLevel(string line)
+ {
+ var start = line.IndexOf("ClientLog ", StringComparison.OrdinalIgnoreCase);
+ if (start < 0) return null;
+ var tokens = line[start..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ return tokens.Length > 1 ? tokens[1] : null;
+ }
+
+ private static string? ExtractSegment(string text, string start, int tokenIndex)
+ {
+ var index = text.IndexOf(start, StringComparison.OrdinalIgnoreCase);
+ if (index < 0) return null;
+ var tokens = text[index..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ return tokens.Length > tokenIndex ? tokens[tokenIndex] : null;
+ }
+
+ private static string? ExtractNamedValue(string text, string prefix)
+ {
+ var index = text.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
+ if (index < 0) return null;
+ var slice = text[(index + prefix.Length)..];
+ var end = slice.IndexOf(' ');
+ return end < 0 ? slice : slice[..end];
+ }
+}