Group repeated client logs for faster diagnosis
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -23,6 +23,24 @@
|
|||||||
<div><strong>Info</strong> @_summary.InfoCount</div>
|
<div><strong>Info</strong> @_summary.InfoCount</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (_groups.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="admin-log-group-list">
|
||||||
|
@foreach (var group in _groups)
|
||||||
|
{
|
||||||
|
<article class="admin-log-group-item">
|
||||||
|
<div class="admin-log-item-header">
|
||||||
|
<strong>@group.Count</strong>
|
||||||
|
<span>@group.Level</span>
|
||||||
|
<span>@group.Source</span>
|
||||||
|
<span>@group.Route</span>
|
||||||
|
</div>
|
||||||
|
<p>@group.Message</p>
|
||||||
|
<small>@group.LastSeen</small>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</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">
|
||||||
@@ -63,6 +81,7 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private readonly List<ClientLogDto> _entries = [];
|
private readonly List<ClientLogDto> _entries = [];
|
||||||
|
private readonly List<ClientLogGroupDto> _groups = [];
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private string _query = "";
|
private string _query = "";
|
||||||
private string _level = "";
|
private string _level = "";
|
||||||
@@ -77,16 +96,22 @@
|
|||||||
{
|
{
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_entries.Clear();
|
_entries.Clear();
|
||||||
|
_groups.Clear();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_summary = await Http.GetFromJsonAsync<ClientLogSummaryDto>("/taxbaik/api/client-logs/summary");
|
_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);
|
||||||
|
var grouped = await Http.GetFromJsonAsync<List<ClientLogGroupDto>>("/taxbaik/api/client-logs/groups");
|
||||||
if (result is not null)
|
if (result is not null)
|
||||||
{
|
{
|
||||||
_entries.AddRange(result);
|
_entries.AddRange(result);
|
||||||
}
|
}
|
||||||
|
if (grouped is not null)
|
||||||
|
{
|
||||||
|
_groups.AddRange(grouped);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -114,4 +139,15 @@
|
|||||||
public int WarningCount { get; set; }
|
public int WarningCount { get; set; }
|
||||||
public int InfoCount { get; set; }
|
public int InfoCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ClientLogGroupDto
|
||||||
|
{
|
||||||
|
public string? Key { get; set; }
|
||||||
|
public string? Level { get; set; }
|
||||||
|
public string? Source { get; set; }
|
||||||
|
public string? Route { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
public string? LastSeen { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
|
||||||
|
namespace TaxBaik.Web.Endpoints.ClientLogs;
|
||||||
|
|
||||||
|
public sealed class ClientLogGroupItem
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public string? Level { get; set; }
|
||||||
|
public string? Source { get; set; }
|
||||||
|
public string? Route { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
public string? LastSeen { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GetGroupedLogsEndpoint : EndpointWithoutRequest<IReadOnlyList<ClientLogGroupItem>>
|
||||||
|
{
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
|
||||||
|
public GetGroupedLogsEndpoint(IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
_environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/client-logs/groups");
|
||||||
|
Policies("AdminOnly");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var logDir = Path.Combine(_environment.ContentRootPath, "logs");
|
||||||
|
if (!Directory.Exists(logDir))
|
||||||
|
{
|
||||||
|
await SendAsync(Array.Empty<ClientLogGroupItem>(), cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = Directory.GetFiles(logDir, "taxbaik-*.log")
|
||||||
|
.OrderByDescending(File.GetLastWriteTimeUtc)
|
||||||
|
.Take(3)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var groups = new Dictionary<string, ClientLogGroupItem>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
foreach (var line in await File.ReadAllLinesAsync(file, ct))
|
||||||
|
{
|
||||||
|
if (!line.Contains("ClientLog ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var entry = Parse(line);
|
||||||
|
var key = $"{entry.Level}|{entry.Source}|{entry.Route}|{Normalize(entry.Message)}";
|
||||||
|
if (!groups.TryGetValue(key, out var group))
|
||||||
|
{
|
||||||
|
group = new ClientLogGroupItem
|
||||||
|
{
|
||||||
|
Key = key,
|
||||||
|
Level = entry.Level,
|
||||||
|
Source = entry.Source,
|
||||||
|
Route = entry.Route,
|
||||||
|
Message = entry.Message,
|
||||||
|
Count = 0,
|
||||||
|
LastSeen = entry.Timestamp
|
||||||
|
};
|
||||||
|
groups[key] = group;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Count++;
|
||||||
|
group.LastSeen = entry.Timestamp ?? group.LastSeen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendAsync(groups.Values
|
||||||
|
.OrderByDescending(item => item.Count)
|
||||||
|
.ThenByDescending(item => item.LastSeen)
|
||||||
|
.Take(10)
|
||||||
|
.ToList(), cancellation: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RecentClientLogEntry Parse(string line)
|
||||||
|
{
|
||||||
|
return new RecentClientLogEntry
|
||||||
|
{
|
||||||
|
Timestamp = ExtractTimestamp(line),
|
||||||
|
Level = ExtractBetween(line, "] [", "]"),
|
||||||
|
Source = ExtractTokenAfter(line, "ClientLog ", 1),
|
||||||
|
Route = ExtractNamedValue(line, "Route="),
|
||||||
|
Message = ExtractMessage(line)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return "";
|
||||||
|
return value
|
||||||
|
.Replace("0x", "0x*", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("\"", string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractTimestamp(string line)
|
||||||
|
{
|
||||||
|
if (!line.StartsWith("[")) return null;
|
||||||
|
var end = line.IndexOf("] [", StringComparison.Ordinal);
|
||||||
|
return end > 1 ? line[1..end] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractBetween(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? ExtractTokenAfter(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? ExtractMessage(string line)
|
||||||
|
{
|
||||||
|
var stackIndex = line.IndexOf("Stack=", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (stackIndex >= 0)
|
||||||
|
{
|
||||||
|
return line[..stackIndex].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientIndex = line.IndexOf("ClientLog ", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return clientIndex >= 0 ? line[(clientIndex + "ClientLog ".Length)..] : line;
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,18 +71,53 @@ public sealed class GetRecentLogsEndpoint : EndpointWithoutRequest<IReadOnlyList
|
|||||||
private static RecentClientLogEntry Parse(string line)
|
private static RecentClientLogEntry Parse(string line)
|
||||||
{
|
{
|
||||||
var entry = new RecentClientLogEntry();
|
var entry = new RecentClientLogEntry();
|
||||||
var messageIndex = line.IndexOf("ClientLog ", StringComparison.OrdinalIgnoreCase);
|
entry.Timestamp = ExtractTimestamp(line);
|
||||||
entry.Message = messageIndex >= 0 ? line[(messageIndex + "ClientLog ".Length)..] : line;
|
entry.Level = ExtractBetween(line, "] [", "]");
|
||||||
|
entry.Source = ExtractTokenAfter(line, "ClientLog ", 1);
|
||||||
|
entry.Route = ExtractNamedValue(line, "Route=");
|
||||||
|
entry.Screen = ExtractNamedValue(line, "Screen=");
|
||||||
|
entry.Feature = ExtractNamedValue(line, "Feature=");
|
||||||
|
entry.Action = ExtractNamedValue(line, "Action=");
|
||||||
|
entry.Step = ExtractNamedValue(line, "Step=");
|
||||||
|
entry.BuildVersion = ExtractNamedValue(line, "BuildVersion=");
|
||||||
|
entry.Message = ExtractMessage(line);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
var timestampEnd = line.IndexOf("] [", StringComparison.Ordinal);
|
private static string? ExtractTimestamp(string line)
|
||||||
if (line.StartsWith("[") && timestampEnd > 1)
|
{
|
||||||
|
if (!line.StartsWith("[")) return null;
|
||||||
|
var end = line.IndexOf("] [", StringComparison.Ordinal);
|
||||||
|
return end > 1 ? line[1..end] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractBetween(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? ExtractTokenAfter(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? ExtractMessage(string line)
|
||||||
|
{
|
||||||
|
var messageIndex = line.IndexOf("Stack=", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (messageIndex >= 0)
|
||||||
{
|
{
|
||||||
entry.Timestamp = line[1..timestampEnd];
|
return line[..messageIndex].Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.Level = Extract(line, "ClientLog ", " ");
|
var clientIndex = line.IndexOf("ClientLog ", StringComparison.OrdinalIgnoreCase);
|
||||||
entry.Source = ExtractAfter(line, "ClientLog ", 1);
|
return clientIndex >= 0 ? line[(clientIndex + "ClientLog ".Length)..] : line;
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool Matches(RecentClientLogEntry entry, string? level, string? source, string? route, string? term)
|
private static bool Matches(RecentClientLogEntry entry, string? level, string? source, string? route, string? term)
|
||||||
@@ -115,18 +150,12 @@ public sealed class GetRecentLogsEndpoint : EndpointWithoutRequest<IReadOnlyList
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? Extract(string text, string start, string end)
|
private static string? ExtractNamedValue(string text, string prefix)
|
||||||
{
|
{
|
||||||
var index = text.IndexOf(start, StringComparison.OrdinalIgnoreCase);
|
var index = text.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
|
||||||
if (index < 0) return null;
|
if (index < 0) return null;
|
||||||
var slice = text[(index + start.Length)..];
|
var slice = text[(index + prefix.Length)..];
|
||||||
var endIndex = slice.IndexOf(end, StringComparison.OrdinalIgnoreCase);
|
var end = slice.IndexOf(' ');
|
||||||
return endIndex < 0 ? slice : slice[..endIndex];
|
return end < 0 ? slice : slice[..end];
|
||||||
}
|
|
||||||
|
|
||||||
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