feat(admin): implement live log viewer and searcher in log files page
This commit is contained in:
@@ -1,57 +1,320 @@
|
|||||||
@page "/logfiles"
|
@page "/logfiles"
|
||||||
|
|
||||||
@using System.IO
|
@using System.IO
|
||||||
@using MUnique.OpenMU.Web.AdminPanel.Properties
|
@using MUnique.OpenMU.Web.AdminPanel.Properties
|
||||||
|
@implements IDisposable
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
<PageTitle>OpenMU: @Resources.LogFiles</PageTitle>
|
<PageTitle>OpenMU: @Resources.LogFiles</PageTitle>
|
||||||
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.LogFiles"/>
|
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.LogFiles"/>
|
||||||
<div>
|
|
||||||
<table class="table table-striped table-hover">
|
<div class="row">
|
||||||
<thead>
|
<div class="col-12">
|
||||||
<tr>
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
<th>@Resources.FileName</th>
|
<div class="card-body p-0">
|
||||||
<th>@Resources.LastUpdate</th>
|
<table class="table table-striped table-hover mb-0">
|
||||||
<th>@Resources.Size</th>
|
<thead class="table-light">
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
<th>@Resources.FileName</th>
|
||||||
<tbody>
|
<th>@Resources.LastUpdate</th>
|
||||||
@foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime))
|
<th>@Resources.Size</th>
|
||||||
{
|
<th class="text-end px-4">Actions</th>
|
||||||
<tr>
|
</tr>
|
||||||
<td>
|
</thead>
|
||||||
<a href="logs/@entry.Name">@entry.Name</a>
|
<tbody>
|
||||||
</td>
|
@foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime))
|
||||||
<td>@entry.LastWriteTime</td>
|
{
|
||||||
<td>@FormatFileSize(entry.Length)</td>
|
var isSelected = this._selectedFile?.FullName == entry.FullName;
|
||||||
</tr>
|
<tr class="@(isSelected ? "table-info" : "")">
|
||||||
}
|
<td>
|
||||||
</tbody>
|
<button type="button" class="btn btn-link p-0 text-start font-monospace text-decoration-none fw-bold" @onclick="() => SelectFile(entry)">
|
||||||
</table>
|
<span class="oi oi-terminal me-2 @(isSelected ? "text-primary" : "text-muted")"></span>@entry.Name
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>@entry.LastWriteTime</td>
|
||||||
|
<td>@FormatFileSize(entry.Length)</td>
|
||||||
|
<td class="text-end px-4">
|
||||||
|
<a href="logs/@entry.Name" download class="btn btn-sm btn-outline-secondary py-0 px-2" title="Download File">
|
||||||
|
<span class="oi oi-data-transfer-download" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (this._selectedFile != null)
|
||||||
|
{
|
||||||
|
<div class="card border-secondary shadow-lg mb-4">
|
||||||
|
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center py-2 px-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="oi oi-terminal text-info me-2" aria-hidden="true"></span>
|
||||||
|
<span class="me-2">Log Viewer:</span>
|
||||||
|
<span class="badge bg-secondary font-monospace">@this._selectedFile.Name</span>
|
||||||
|
<span class="badge bg-dark border border-secondary ms-2 text-muted" style="font-size: 11px;">@FormatFileSize(this._selectedFile.Length)</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="form-check form-switch m-0 d-flex align-items-center gap-2">
|
||||||
|
<input class="form-check-input cursor-pointer" type="checkbox" id="liveUpdateSwitch" @onchange="ToggleLiveUpdate" checked="@this._liveUpdate">
|
||||||
|
<label class="form-check-label text-light select-none cursor-pointer" for="liveUpdateSwitch" style="font-size: 13px;">Live Update</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-info d-flex align-items-center gap-1 py-1" @onclick="RefreshLogLines">
|
||||||
|
<span class="oi oi-reload" style="font-size: 11px;"></span> Refresh
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger d-flex align-items-center gap-1 py-1" @onclick="CloseViewer">
|
||||||
|
<span class="oi oi-x" style="font-size: 11px;"></span> Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body bg-dark p-3" style="background-color: #121214 !important;">
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-secondary text-white border-0"><span class="oi oi-magnifying-glass" aria-hidden="true"></span></span>
|
||||||
|
<input type="text" class="form-control bg-secondary text-white border-0" placeholder="Filter log entries..." @bind="this._searchText" @bind:event="oninput" style="background-color: #2b2b30 !important; color: #fff !important;" />
|
||||||
|
@if (!string.IsNullOrEmpty(this._searchText))
|
||||||
|
{
|
||||||
|
<button class="btn btn-secondary border-0" @onclick="ClearSearch"><span class="oi oi-x" aria-hidden="true"></span></button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="log-terminal" class="p-3 rounded" style="height: 450px; overflow-y: auto; font-family: 'Consolas', 'Liberation Mono', Menlo, Courier, monospace; font-size: 13px; line-height: 1.5; white-space: pre-wrap; background-color: #0c0c0d !important; border: 1px solid #2d2d30;">
|
||||||
|
@if (this._logLines.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted text-center py-5">No log entries found.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var filteredLines = GetFilteredLines();
|
||||||
|
@if (filteredLines.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted text-center py-5">No log entries match your filter.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var line in filteredLines)
|
||||||
|
{
|
||||||
|
<div style="@GetLineColorStyle(line)">@line</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2 text-muted" style="font-size: 12px;">
|
||||||
|
<div>
|
||||||
|
Showing @GetFilteredLines().Count of @this._logLines.Count lines (Last 300 lines loaded).
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-1 px-2" style="font-size: 12px; color: #a0a0a8;" @onclick="ScrollToBottom">
|
||||||
|
<span class="oi oi-arrow-bottom" aria-hidden="true"></span> Scroll to Bottom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private readonly List<FileInfo> _files = new ();
|
private readonly List<FileInfo> _files = new ();
|
||||||
|
private FileInfo? _selectedFile;
|
||||||
|
private List<string> _logLines = new ();
|
||||||
|
private string _searchText = string.Empty;
|
||||||
|
private bool _liveUpdate = false;
|
||||||
|
private System.Threading.Timer? _timer;
|
||||||
|
private bool _shouldScrollToBottom = false;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Initializes a new instance of class <see cref="LogFiles"/>.
|
protected override void OnInitialized()
|
||||||
/// </summary>
|
|
||||||
public LogFiles()
|
|
||||||
{
|
{
|
||||||
var files = Directory.GetFiles(Path.Combine(Directory.GetCurrentDirectory(), "logs"));
|
this.RefreshFileList();
|
||||||
foreach (var filePath in files)
|
}
|
||||||
|
|
||||||
|
private void RefreshFileList()
|
||||||
|
{
|
||||||
|
this._files.Clear();
|
||||||
|
var logsPath = Path.Combine(Directory.GetCurrentDirectory(), "logs");
|
||||||
|
if (Directory.Exists(logsPath))
|
||||||
{
|
{
|
||||||
this._files.Add(new FileInfo(filePath));
|
var files = Directory.GetFiles(logsPath);
|
||||||
|
foreach (var filePath in files)
|
||||||
|
{
|
||||||
|
this._files.Add(new FileInfo(filePath));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SelectFile(FileInfo file)
|
||||||
|
{
|
||||||
|
this._selectedFile = file;
|
||||||
|
this._searchText = string.Empty;
|
||||||
|
this.RefreshLogLines();
|
||||||
|
this._shouldScrollToBottom = true;
|
||||||
|
this.SetupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseViewer()
|
||||||
|
{
|
||||||
|
this._selectedFile = null;
|
||||||
|
this._searchText = string.Empty;
|
||||||
|
this._logLines.Clear();
|
||||||
|
this._liveUpdate = false;
|
||||||
|
this.SetupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSearch()
|
||||||
|
{
|
||||||
|
this._searchText = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleLiveUpdate(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
this._liveUpdate = (bool)(e.Value ?? false);
|
||||||
|
this.SetupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupTimer()
|
||||||
|
{
|
||||||
|
if (this._liveUpdate && this._selectedFile != null)
|
||||||
|
{
|
||||||
|
this._timer ??= new System.Threading.Timer(_ =>
|
||||||
|
{
|
||||||
|
InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
this.RefreshLogLines();
|
||||||
|
this.StateHasChanged();
|
||||||
|
});
|
||||||
|
}, null, 0, 2000);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._timer?.Dispose();
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshLogLines()
|
||||||
|
{
|
||||||
|
if (this._selectedFile == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._selectedFile = new FileInfo(this._selectedFile.FullName);
|
||||||
|
this._logLines = this.ReadLastLines(this._selectedFile.FullName, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> ReadLastLines(string path, int maxLines)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
long offset = Math.Max(0, fs.Length - 102400); // Read last 100 KB
|
||||||
|
fs.Seek(offset, SeekOrigin.Begin);
|
||||||
|
using var reader = new StreamReader(fs, System.Text.Encoding.UTF8);
|
||||||
|
|
||||||
|
if (offset > 0)
|
||||||
|
{
|
||||||
|
// Discard partial line
|
||||||
|
reader.ReadLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = reader.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
lines.Add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.Count > maxLines)
|
||||||
|
{
|
||||||
|
lines = lines.Skip(lines.Count - maxLines).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lines.Add($"Error reading log file: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> GetFilteredLines()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(this._searchText))
|
||||||
|
{
|
||||||
|
return this._logLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._logLines
|
||||||
|
.Where(line => line.Contains(this._searchText, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetLineColorStyle(string line)
|
||||||
|
{
|
||||||
|
if (line.Contains("[Error]", StringComparison.OrdinalIgnoreCase) || line.Contains("[Critical]", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "color: #ff6b6b; font-weight: bold;";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Contains("[Warning]", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "color: #feca57;";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Contains("[Debug]", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "color: #8a8d93; font-style: italic;";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Contains("[Information]", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "color: #1dd1a1;";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "color: #d1d2d6;";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScrollToBottom()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("eval", "var el = document.getElementById('log-terminal'); if (el) { el.scrollTop = el.scrollHeight; }");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (this._shouldScrollToBottom)
|
||||||
|
{
|
||||||
|
this._shouldScrollToBottom = false;
|
||||||
|
await this.ScrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
this._timer?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private string FormatFileSize(long size)
|
private string FormatFileSize(long size)
|
||||||
{
|
{
|
||||||
return size switch
|
return size switch
|
||||||
{
|
{
|
||||||
(< 1024 << 10) => $"{Math.Round(size / 1024D, 2)} KiB",
|
(< 1024 << 10) => $"{Math.Round(size / 1024D, 2)} KiB",
|
||||||
(< 1024 << 20) => $"{Math.Round(size * 1D / (1024 << 10), 2)} MiB",
|
(< 1024 << 20) => $"{Math.Round(size * 1D / (1024 << 10), 2)} MiB",
|
||||||
(< 1024L << 30) => $"{Math.Round(size * 1D / (1024L << 20), 2)} GiB",
|
(< 1024L << 30) => $"{Math.Round(size * 1D / (1024L << 20), 2)} GiB",
|
||||||
_ => $"{size} bytes"
|
_ => $"{size} bytes"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user