diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/ClientLogs.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/ClientLogs.razor new file mode 100644 index 0000000..a8ea13e --- /dev/null +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/ClientLogs.razor @@ -0,0 +1,76 @@ +@page "/client-logs" +@page "/admin/client-logs" +@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) +@attribute [Authorize] +@inject HttpClient Http + +클라이언트 로그 + +
+
+

Diagnostics

+

클라이언트 로그

+

브라우저 오류와 JS interop 실패를 최근 순서로 확인합니다.

+
+ +
+ @if (_loading) + { +

불러오는 중...

+ } + else if (_entries.Count == 0) + { +

최근 클라이언트 로그가 없습니다.

+ } + else + { +
+ @foreach (var entry in _entries) + { +
+
+ @(entry.Level ?? "error") + @entry.Timestamp + @entry.Source +
+

@entry.Message

+ @entry.Route +
+ } +
+ } +
+
+ +@code { + private readonly List _entries = []; + private bool _loading = true; + + protected override async Task OnInitializedAsync() + { + try + { + var result = await Http.GetFromJsonAsync>("/taxbaik/api/client-logs/recent"); + if (result is not null) + { + _entries.AddRange(result); + } + } + catch + { + } + finally + { + _loading = false; + } + } + + private sealed class ClientLogDto + { + public string? Timestamp { get; set; } + public string? Level { get; set; } + public string? Source { get; set; } + public string? Message { get; set; } + public string? Route { get; set; } + } +} diff --git a/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminNavigationModel.cs b/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminNavigationModel.cs index 655c9d9..1b1e6a4 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminNavigationModel.cs +++ b/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminNavigationModel.cs @@ -38,6 +38,7 @@ public static class AdminNavigationCatalog new("블로그 관리", "/taxbaik/admin/blog", Icons.Material.Filled.Article, AllowedRoles: ["Admin", "ContentManager"]), new("시즌 시뮬레이터", "/taxbaik/admin/season-simulator", Icons.Material.Filled.CalendarMonth, AllowedRoles: ["Admin", "ContentManager"]), new("문의 관리", "/taxbaik/admin/inquiries", Icons.Material.Filled.Inbox, AllowedRoles: ["Admin", "Manager", "Staff"]), + new("클라이언트 로그", "/taxbaik/admin/client-logs", Icons.Material.Filled.BugReport, AllowedRoles: ["Admin"]), new("설정", "/taxbaik/admin/settings", Icons.Material.Filled.Settings, AllowedRoles: ["Admin"]), new("공통관리", "/taxbaik/admin/common-codes", Icons.Material.Filled.Apps, AllowedRoles: ["Admin"]), ]), diff --git a/src/TaxBaik.Web/Endpoints/ClientLogs/GetRecentLogsEndpoint.cs b/src/TaxBaik.Web/Endpoints/ClientLogs/GetRecentLogsEndpoint.cs new file mode 100644 index 0000000..c8745b2 --- /dev/null +++ b/src/TaxBaik.Web/Endpoints/ClientLogs/GetRecentLogsEndpoint.cs @@ -0,0 +1,97 @@ +using FastEndpoints; + +namespace TaxBaik.Web.Endpoints.ClientLogs; + +public sealed class RecentClientLogEntry +{ + public string? Timestamp { get; set; } + public string? Level { get; set; } + public string? Source { get; set; } + public string? Message { get; set; } + public string? Route { get; set; } + public string? Screen { get; set; } + public string? Feature { get; set; } + public string? Action { get; set; } + public string? Step { get; set; } + public string? BuildVersion { get; set; } +} + +public sealed class GetRecentLogsEndpoint : EndpointWithoutRequest> +{ + private readonly ILogger _logger; + private readonly IWebHostEnvironment _environment; + + public GetRecentLogsEndpoint(ILogger logger, IWebHostEnvironment environment) + { + _logger = logger; + _environment = environment; + } + + public override void Configure() + { + Get("/api/client-logs/recent"); + Policies("AdminOnly"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var logDir = Path.Combine(_environment.ContentRootPath, "logs"); + if (!Directory.Exists(logDir)) + { + await SendAsync(Array.Empty(), cancellation: ct); + return; + } + + var files = Directory.GetFiles(logDir, "taxbaik-*.log") + .OrderByDescending(File.GetLastWriteTimeUtc) + .Take(3) + .ToArray(); + + var entries = new List(); + + foreach (var file in files) + { + foreach (var line in await File.ReadAllLinesAsync(file, ct)) + { + if (!line.Contains("ClientLog ", StringComparison.OrdinalIgnoreCase)) + continue; + + entries.Add(Parse(line)); + } + } + + await SendAsync(entries.TakeLast(50).Reverse().ToList(), cancellation: ct); + } + + private static RecentClientLogEntry Parse(string line) + { + var entry = new RecentClientLogEntry(); + var messageIndex = line.IndexOf("ClientLog ", StringComparison.OrdinalIgnoreCase); + entry.Message = messageIndex >= 0 ? line[(messageIndex + "ClientLog ".Length)..] : line; + + var timestampEnd = line.IndexOf("] [", StringComparison.Ordinal); + if (line.StartsWith("[") && timestampEnd > 1) + { + entry.Timestamp = line[1..timestampEnd]; + } + + entry.Level = Extract(line, "ClientLog ", " "); + entry.Source = ExtractAfter(line, "ClientLog ", 1); + return entry; + } + + private static string? Extract(string text, string start, string end) + { + var index = text.IndexOf(start, StringComparison.OrdinalIgnoreCase); + if (index < 0) return null; + var slice = text[(index + start.Length)..]; + var endIndex = slice.IndexOf(end, StringComparison.OrdinalIgnoreCase); + return endIndex < 0 ? slice : slice[..endIndex]; + } + + private static string? ExtractAfter(string text, string start, int tokenIndex) + { + var tokens = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return tokens.Length > tokenIndex ? tokens[tokenIndex] : null; + } +}