mirror of https://github.com/ErsatzTV/ErsatzTV.git
11 changed files with 242 additions and 16 deletions
@ -0,0 +1,8 @@ |
|||||||
|
using Serilog.Events; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Logs; |
||||||
|
|
||||||
|
public record LogEntryViewModel( |
||||||
|
DateTimeOffset Timestamp, |
||||||
|
LogEventLevel Level, |
||||||
|
string Message); |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
using System.Text.RegularExpressions; |
||||||
|
using Serilog.Events; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Logs; |
||||||
|
|
||||||
|
internal partial class Mapper |
||||||
|
{ |
||||||
|
[GeneratedRegex(@"(.*)\[(DBG|INF|WRN|ERR|FTL)\](.*)")]
|
||||||
|
private static partial Regex LogEntryRegex(); |
||||||
|
|
||||||
|
internal static Option<LogEntryViewModel> ProjectToViewModel(string line) |
||||||
|
{ |
||||||
|
Match match = LogEntryRegex().Match(line); |
||||||
|
if (!match.Success) |
||||||
|
{ |
||||||
|
return None; |
||||||
|
} |
||||||
|
|
||||||
|
var timestamp = DateTimeOffset.Parse(match.Groups[1].Value); |
||||||
|
LogEventLevel level = match.Groups[2].Value switch |
||||||
|
{ |
||||||
|
"FTL" => LogEventLevel.Fatal, |
||||||
|
"ERR" => LogEventLevel.Error, |
||||||
|
"WRN" => LogEventLevel.Warning, |
||||||
|
"INF" => LogEventLevel.Information, |
||||||
|
_ => LogEventLevel.Debug |
||||||
|
}; |
||||||
|
|
||||||
|
return new LogEntryViewModel(timestamp, level, match.Groups[3].Value); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
namespace ErsatzTV.Application.Logs; |
||||||
|
|
||||||
|
public record PagedLogEntriesViewModel(int TotalCount, List<LogEntryViewModel> Page); |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
using System.Linq.Expressions; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Logs; |
||||||
|
|
||||||
|
public record GetRecentLogEntries(int PageNum, int PageSize, string Filter) : IRequest<PagedLogEntriesViewModel> |
||||||
|
{ |
||||||
|
public Expression<Func<LogEntryViewModel, object>> SortExpression { get; init; } |
||||||
|
public Option<bool> SortDescending { get; init; } |
||||||
|
} |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Interfaces.Metadata; |
||||||
|
using static ErsatzTV.Application.Logs.Mapper; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Logs; |
||||||
|
|
||||||
|
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, PagedLogEntriesViewModel> |
||||||
|
{ |
||||||
|
private readonly ILocalFileSystem _localFileSystem; |
||||||
|
|
||||||
|
public GetRecentLogEntriesHandler(ILocalFileSystem localFileSystem) |
||||||
|
{ |
||||||
|
_localFileSystem = localFileSystem; |
||||||
|
} |
||||||
|
|
||||||
|
public Task<PagedLogEntriesViewModel> Handle( |
||||||
|
GetRecentLogEntries request, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
// get most recent file
|
||||||
|
string logFileName = _localFileSystem.ListFiles(FileSystemLayout.LogsFolder) |
||||||
|
.OrderDescending() |
||||||
|
.FirstOrDefault(); |
||||||
|
|
||||||
|
if (logFileName is not null) |
||||||
|
{ |
||||||
|
IQueryable<LogEntryViewModel> entries = ReadFrom(logFileName) |
||||||
|
.Bind(line => ProjectToViewModel(line)) |
||||||
|
.AsQueryable(); |
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Filter)) |
||||||
|
{ |
||||||
|
entries = entries.Filter( |
||||||
|
le => le.Level.ToString().Contains(request.Filter, StringComparison.OrdinalIgnoreCase) || |
||||||
|
le.Message.Contains(request.Filter, StringComparison.OrdinalIgnoreCase)); |
||||||
|
} |
||||||
|
|
||||||
|
int count = entries.Count(); |
||||||
|
|
||||||
|
IOrderedQueryable<LogEntryViewModel> ordered = request.SortDescending.Match( |
||||||
|
descending => descending |
||||||
|
? entries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Timestamp) |
||||||
|
: entries.OrderBy(request.SortExpression).ThenByDescending(le => le.Timestamp), |
||||||
|
() => entries.OrderByDescending(le => le.Timestamp)); |
||||||
|
|
||||||
|
var page = ordered |
||||||
|
.Skip(request.PageNum * request.PageSize) |
||||||
|
.Take(request.PageSize) |
||||||
|
.ToList(); |
||||||
|
|
||||||
|
return new PagedLogEntriesViewModel(count, page).AsTask(); |
||||||
|
} |
||||||
|
|
||||||
|
return new PagedLogEntriesViewModel(0, new List<LogEntryViewModel>()).AsTask(); |
||||||
|
} |
||||||
|
|
||||||
|
private static IEnumerable<string> ReadFrom(string file) |
||||||
|
{ |
||||||
|
using StreamReader reader = File.OpenText(file); |
||||||
|
while (reader.ReadLine() is { } line) |
||||||
|
{ |
||||||
|
yield return line; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -1,9 +0,0 @@ |
|||||||
namespace ErsatzTV.Core.Domain; |
|
||||||
|
|
||||||
public record LogEntry( |
|
||||||
int Id, |
|
||||||
DateTime Timestamp, |
|
||||||
string Level, |
|
||||||
string Exception, |
|
||||||
string RenderedMessage, |
|
||||||
string Properties); |
|
||||||
@ -0,0 +1,109 @@ |
|||||||
|
@page "/system/logs" |
||||||
|
@using ErsatzTV.Application.Logs |
||||||
|
@using ErsatzTV.Application.Configuration |
||||||
|
@implements IDisposable |
||||||
|
@inject IMediator Mediator |
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> |
||||||
|
<MudTable FixedHeader="true" |
||||||
|
@bind-RowsPerPage="@_rowsPerPage" |
||||||
|
ServerData="@(new Func<TableState, Task<TableData<LogEntryViewModel>>>(ServerReload))" |
||||||
|
Dense="true" |
||||||
|
@ref="_table"> |
||||||
|
<ToolBarContent> |
||||||
|
<MudText Typo="Typo.h6">Logs</MudText> |
||||||
|
<MudSpacer/> |
||||||
|
<MudTextField T="string" ValueChanged="@(s => OnSearch(s))" Placeholder="Search" Adornment="Adornment.Start" |
||||||
|
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField> |
||||||
|
</ToolBarContent> |
||||||
|
<HeaderContent> |
||||||
|
<MudTh> |
||||||
|
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Timestamp"> |
||||||
|
Timestamp |
||||||
|
</MudTableSortLabel> |
||||||
|
</MudTh> |
||||||
|
<MudTh> |
||||||
|
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Level"> |
||||||
|
Level |
||||||
|
</MudTableSortLabel> |
||||||
|
</MudTh> |
||||||
|
<MudTh>Message</MudTh> |
||||||
|
</HeaderContent> |
||||||
|
<RowTemplate> |
||||||
|
<MudTd DataLabel="Timestamp">@context.Timestamp</MudTd> |
||||||
|
<MudTd DataLabel="Level">@context.Level</MudTd> |
||||||
|
<MudTd DataLabel="Message">@context.Message</MudTd> |
||||||
|
</RowTemplate> |
||||||
|
<NoRecordsContent> |
||||||
|
<MudText>No matching records found</MudText> |
||||||
|
</NoRecordsContent> |
||||||
|
<LoadingContent> |
||||||
|
<MudText>Loading...</MudText> |
||||||
|
</LoadingContent> |
||||||
|
<PagerContent> |
||||||
|
<MudTablePager/> |
||||||
|
</PagerContent> |
||||||
|
</MudTable> |
||||||
|
</MudContainer> |
||||||
|
|
||||||
|
@code { |
||||||
|
private readonly CancellationTokenSource _cts = new(); |
||||||
|
|
||||||
|
private MudTable<LogEntryViewModel> _table; |
||||||
|
private int _rowsPerPage; |
||||||
|
private string _searchString; |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
_cts.Cancel(); |
||||||
|
_cts.Dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() => _rowsPerPage = |
||||||
|
await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.LogsPageSize), _cts.Token) |
||||||
|
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); |
||||||
|
|
||||||
|
private async Task<TableData<LogEntryViewModel>> ServerReload(TableState state) |
||||||
|
{ |
||||||
|
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.LogsPageSize, state.PageSize.ToString()), _cts.Token); |
||||||
|
|
||||||
|
PagedLogEntriesViewModel data; |
||||||
|
|
||||||
|
switch (state.SortLabel?.ToLowerInvariant()) |
||||||
|
{ |
||||||
|
case "timestamp": |
||||||
|
data = await Mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize, _searchString) |
||||||
|
{ |
||||||
|
SortExpression = le => le.Timestamp, |
||||||
|
SortDescending = state.SortDirection == SortDirection.None |
||||||
|
? Option<bool>.None |
||||||
|
: state.SortDirection == SortDirection.Descending |
||||||
|
}, _cts.Token); |
||||||
|
break; |
||||||
|
case "level": |
||||||
|
data = await Mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize, _searchString) |
||||||
|
{ |
||||||
|
SortExpression = le => le.Level, |
||||||
|
SortDescending = state.SortDirection == SortDirection.None |
||||||
|
? Option<bool>.None |
||||||
|
: state.SortDirection == SortDirection.Descending |
||||||
|
}, _cts.Token); |
||||||
|
break; |
||||||
|
default: |
||||||
|
data = await Mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize, _searchString) |
||||||
|
{ |
||||||
|
SortDescending = Option<bool>.None |
||||||
|
}, _cts.Token); |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
return new TableData<LogEntryViewModel> { TotalItems = data.TotalCount, Items = data.Page }; |
||||||
|
} |
||||||
|
|
||||||
|
private void OnSearch(string text) |
||||||
|
{ |
||||||
|
_searchString = text; |
||||||
|
_table.ReloadServerData(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue