mirror of https://github.com/ErsatzTV/ErsatzTV.git
11 changed files with 242 additions and 16 deletions
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using Serilog.Events; |
||||
|
||||
namespace ErsatzTV.Application.Logs; |
||||
|
||||
public record LogEntryViewModel( |
||||
DateTimeOffset Timestamp, |
||||
LogEventLevel Level, |
||||
string Message); |
||||
@ -0,0 +1,31 @@
@@ -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 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Logs; |
||||
|
||||
public record PagedLogEntriesViewModel(int TotalCount, List<LogEntryViewModel> Page); |
||||
@ -0,0 +1,9 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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