Add client log summary and dashboard shortcut
TaxBaik CI/CD / build-and-deploy (push) Successful in 4m51s
TaxBaik CI/CD / build-and-deploy (push) Successful in 4m51s
This commit is contained in:
@@ -14,6 +14,15 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="admin-simple-card">
|
<div class="admin-simple-card">
|
||||||
|
@if (_summary is not null)
|
||||||
|
{
|
||||||
|
<div class="admin-log-summary">
|
||||||
|
<div><strong>Total</strong> @_summary.Total</div>
|
||||||
|
<div><strong>Error</strong> @_summary.ErrorCount</div>
|
||||||
|
<div><strong>Warning</strong> @_summary.WarningCount</div>
|
||||||
|
<div><strong>Info</strong> @_summary.InfoCount</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="admin-log-filter-row">
|
<div class="admin-log-filter-row">
|
||||||
<input class="mud-input-slot" placeholder="검색어" @bind="_query" @bind:event="oninput" />
|
<input class="mud-input-slot" placeholder="검색어" @bind="_query" @bind:event="oninput" />
|
||||||
<select class="mud-input-slot" @bind="_level">
|
<select class="mud-input-slot" @bind="_level">
|
||||||
@@ -57,6 +66,7 @@
|
|||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private string _query = "";
|
private string _query = "";
|
||||||
private string _level = "";
|
private string _level = "";
|
||||||
|
private ClientLogSummaryDto? _summary;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -70,6 +80,7 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_summary = await Http.GetFromJsonAsync<ClientLogSummaryDto>("/taxbaik/api/client-logs/summary");
|
||||||
var url = $"/taxbaik/api/client-logs/recent?q={Uri.EscapeDataString(_query ?? string.Empty)}&level={Uri.EscapeDataString(_level ?? string.Empty)}";
|
var url = $"/taxbaik/api/client-logs/recent?q={Uri.EscapeDataString(_query ?? string.Empty)}&level={Uri.EscapeDataString(_level ?? string.Empty)}";
|
||||||
var result = await Http.GetFromJsonAsync<List<ClientLogDto>>(url);
|
var result = await Http.GetFromJsonAsync<List<ClientLogDto>>(url);
|
||||||
if (result is not null)
|
if (result is not null)
|
||||||
@@ -95,4 +106,12 @@
|
|||||||
public string? Message { get; set; }
|
public string? Message { get; set; }
|
||||||
public string? Route { get; set; }
|
public string? Route { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ClientLogSummaryDto
|
||||||
|
{
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int ErrorCount { get; set; }
|
||||||
|
public int WarningCount { get; set; }
|
||||||
|
public int InfoCount { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,5 +23,10 @@
|
|||||||
<div>하위 경로 배포와 렌더러 분리를 유지하도록 라우트 린트를 적용합니다.</div>
|
<div>하위 경로 배포와 렌더러 분리를 유지하도록 라우트 린트를 적용합니다.</div>
|
||||||
<a href="/taxbaik/admin/client-logs">최근 에러 확인</a>
|
<a href="/taxbaik/admin/client-logs">최근 에러 확인</a>
|
||||||
</article>
|
</article>
|
||||||
|
<article class="admin-simple-card">
|
||||||
|
<strong>원인 규명</strong>
|
||||||
|
<div>중복되는 오류는 최근 로그 요약에서 source와 route 기준으로 묶어 봅니다.</div>
|
||||||
|
<a href="/taxbaik/admin/client-logs?q=invoke">오류 검색</a>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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<KeyValuePair<string, int>> TopSources { get; set; } = [];
|
||||||
|
public IReadOnlyList<KeyValuePair<string, int>> TopRoutes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GetSummaryEndpoint : EndpointWithoutRequest<ClientLogSummary>
|
||||||
|
{
|
||||||
|
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<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var routeCounts = new Dictionary<string, int>(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];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user