This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
@page "/client-logs"
|
||||||
|
@page "/admin/client-logs"
|
||||||
|
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject HttpClient Http
|
||||||
|
|
||||||
|
<PageTitle>클라이언트 로그</PageTitle>
|
||||||
|
|
||||||
|
<section class="admin-simple-page">
|
||||||
|
<header class="admin-simple-header">
|
||||||
|
<p class="admin-simple-eyebrow">Diagnostics</p>
|
||||||
|
<h1 class="admin-simple-title">클라이언트 로그</h1>
|
||||||
|
<p class="admin-simple-subtitle">브라우저 오류와 JS interop 실패를 최근 순서로 확인합니다.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="admin-simple-card">
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<p>불러오는 중...</p>
|
||||||
|
}
|
||||||
|
else if (_entries.Count == 0)
|
||||||
|
{
|
||||||
|
<p>최근 클라이언트 로그가 없습니다.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="admin-log-list">
|
||||||
|
@foreach (var entry in _entries)
|
||||||
|
{
|
||||||
|
<article class="admin-log-item">
|
||||||
|
<div class="admin-log-item-header">
|
||||||
|
<strong>@(entry.Level ?? "error")</strong>
|
||||||
|
<span>@entry.Timestamp</span>
|
||||||
|
<span>@entry.Source</span>
|
||||||
|
</div>
|
||||||
|
<p>@entry.Message</p>
|
||||||
|
<small>@entry.Route</small>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly List<ClientLogDto> _entries = [];
|
||||||
|
private bool _loading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await Http.GetFromJsonAsync<List<ClientLogDto>>("/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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ public static class AdminNavigationCatalog
|
|||||||
new("블로그 관리", "/taxbaik/admin/blog", Icons.Material.Filled.Article, AllowedRoles: ["Admin", "ContentManager"]),
|
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/season-simulator", Icons.Material.Filled.CalendarMonth, AllowedRoles: ["Admin", "ContentManager"]),
|
||||||
new("문의 관리", "/taxbaik/admin/inquiries", Icons.Material.Filled.Inbox, AllowedRoles: ["Admin", "Manager", "Staff"]),
|
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/settings", Icons.Material.Filled.Settings, AllowedRoles: ["Admin"]),
|
||||||
new("공통관리", "/taxbaik/admin/common-codes", Icons.Material.Filled.Apps, AllowedRoles: ["Admin"]),
|
new("공통관리", "/taxbaik/admin/common-codes", Icons.Material.Filled.Apps, AllowedRoles: ["Admin"]),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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<IReadOnlyList<RecentClientLogEntry>>
|
||||||
|
{
|
||||||
|
private readonly ILogger<GetRecentLogsEndpoint> _logger;
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
|
||||||
|
public GetRecentLogsEndpoint(ILogger<GetRecentLogsEndpoint> 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<RecentClientLogEntry>(), cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = Directory.GetFiles(logDir, "taxbaik-*.log")
|
||||||
|
.OrderByDescending(File.GetLastWriteTimeUtc)
|
||||||
|
.Take(3)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var entries = new List<RecentClientLogEntry>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user