feat(web): add log api

This commit is contained in:
genteure 2022-08-31 14:46:35 +08:00
parent 050226fdcc
commit f99a9b59e4
10 changed files with 274 additions and 6 deletions

View File

@ -31,6 +31,7 @@
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
<PackageReference Include="Serilog.Expressions" Version="3.4.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />

View File

@ -19,6 +19,7 @@ using BililiveRecorder.DependencyInjection;
using BililiveRecorder.Flv.Pipeline;
using BililiveRecorder.ToolBox;
using BililiveRecorder.Web;
using BililiveRecorder.Web.Models.Rest.Logs;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
@ -112,7 +113,7 @@ namespace BililiveRecorder.Cli
{
var path = Path.GetFullPath(args.Path);
using var logger = BuildLogger(args.LogLevel, args.LogFileLevel);
using var logger = BuildLogger(args.LogLevel, args.LogFileLevel, enableWebLog: args.HttpBind is not null);
Log.Logger = logger;
path = Path.GetFullPath(path);
@ -145,7 +146,7 @@ namespace BililiveRecorder.Cli
private static async Task<int> RunPortableModeAsync(PortableModeArguments args)
{
using var logger = BuildLogger(args.LogLevel, args.LogFileLevel);
using var logger = BuildLogger(args.LogLevel, args.LogFileLevel, enableWebLog: args.HttpBind is not null);
Log.Logger = logger;
var config = new ConfigV3()
@ -426,7 +427,7 @@ namespace BililiveRecorder.Cli
.AddRecorder()
.BuildServiceProvider();
private static Logger BuildLogger(LogEventLevel logLevel, LogEventLevel logFileLevel)
private static Logger BuildLogger(LogEventLevel logLevel, LogEventLevel logFileLevel, bool enableWebLog = false)
{
var logFilePath = Environment.GetEnvironmentVariable("BILILIVERECORDER_LOG_FILE_PATH");
if (string.IsNullOrWhiteSpace(logFilePath))
@ -470,6 +471,18 @@ namespace BililiveRecorder.Cli
;
});
if (enableWebLog)
{
var webSink = new WebApiLogEventSink(new CompactJsonFormatter());
WebApiLogEventSink.Instance = webSink;
builder.WriteTo.Logger(sl =>
{
sl
.Filter.ByExcluding(matchMicrosoft)
.WriteTo.Async(l => l.Sink(webSink, restrictedToMinimumLevel: LogEventLevel.Debug));
});
}
if (ansiColorSupport)
{
builder.WriteTo.Console(new ExpressionTemplate("[{@t:HH:mm:ss} {@l:u3}{#if SourceContext is not null} ({SourceContext}){#end}]{#if RoomId is not null} [{RoomId}]{#end} {@m}{#if ExceptionDetail is not null}\n [{ExceptionDetail['Type']}]: {ExceptionDetail['Message']}{#end}\n", theme: Serilog.Templates.Themes.TemplateTheme.Code), logLevel);

View File

@ -377,6 +377,9 @@
<PackageReference Include="Serilog.Formatting.Compact">
<Version>1.1.0</Version>
</PackageReference>
<PackageReference Include="Serilog.Sinks.Async">
<Version>1.5.0</Version>
</PackageReference>
<PackageReference Include="Serilog.Sinks.Console">
<Version>4.0.1</Version>
</PackageReference>

View File

@ -301,11 +301,11 @@ namespace BililiveRecorder.WPF
.WriteTo.Console(levelSwitch: levelSwitchConsole)
#if DEBUG
.WriteTo.Debug()
.WriteTo.Sink<WpfLogEventSink>(Serilog.Events.LogEventLevel.Debug)
.WriteTo.Async(l => l.Sink<WpfLogEventSink>(Serilog.Events.LogEventLevel.Debug))
#else
.WriteTo.Sink<WpfLogEventSink>(Serilog.Events.LogEventLevel.Information)
.WriteTo.Async(l => l.Sink<WpfLogEventSink>(Serilog.Events.LogEventLevel.Information))
#endif
.WriteTo.File(new CompactJsonFormatter(), logFilePath, shared: true, rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true)
.WriteTo.Async(l => l.File(new CompactJsonFormatter(), logFilePath, shared: true, rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true))
.WriteTo.Sentry(o =>
{
o.Dsn = "https://6f92720d5ce84b2dba5db75ab5a5014d@o210546.ingest.sentry.io/5556540";

View File

@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.Linq;
using BililiveRecorder.Web.Models.Rest.Logs;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StructLinq;
namespace BililiveRecorder.Web.Api
{
[ApiController, Route("api/[controller]", Name = "[controller] [action]")]
public sealed class LogController : ControllerBase
{
public LogController()
{
}
/// <summary>
/// 获取 JSON 日志
/// </summary>
/// <param name="after">只获取此 id 之后的日志</param>
/// <returns></returns>
[HttpGet("fetch")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<JsonLogDto> GetJsonLog([FromQuery] long? after)
{
var sink = WebApiLogEventSink.Instance;
if (sink is null)
{
return new JsonLogDto();
}
List<JsonLog> logs = null!;
sink.ReadLogs(queue =>
{
logs = queue.ToList();
});
if (!after.HasValue)
{
return new JsonLogDto
{
Continuous = false,
Cursor = logs[^1].Id,
Logs = logs.Select(x => x.Log)
};
}
else
{
var index = logs.BinarySearch(new JsonLog { Id = after.Value });
return new JsonLogDto
{
Continuous = index >= 0,
Cursor = logs[^1].Id,
Logs = logs.TakeLast((index >= 0) ? (logs.Count - index - 1) : logs.Count).Select(x => x.Log)
};
}
}
}
}

View File

@ -0,0 +1,15 @@
using System;
using Newtonsoft.Json;
namespace BililiveRecorder.Web.Models.Rest.Logs
{
public class JsonLog : IComparable<JsonLog>
{
public long Id { get; set; }
[JsonConverter(typeof(RawJsonStringConverter))]
public string Log { get; set; } = "{}";
int IComparable<JsonLog>.CompareTo(JsonLog? other) => this.Id.CompareTo(other?.Id);
}
}

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BililiveRecorder.Web.Models.Rest.Logs
{
public class JsonLogDto
{
public bool Continuous { get; set; }
public long Cursor { get; set; }
[JsonProperty(ItemConverterType = typeof(RawJsonStringConverter))]
public IEnumerable<string> Logs { get; set; } = Array.Empty<string>();
}
}

View File

@ -0,0 +1,12 @@
using System;
using Newtonsoft.Json;
namespace BililiveRecorder.Web.Models.Rest.Logs
{
internal sealed class RawJsonStringConverter : JsonConverter<string>
{
public override string? ReadJson(JsonReader reader, Type objectType, string? existingValue, bool hasExistingValue, JsonSerializer serializer) => (string?)reader.Value;
public override void WriteJson(JsonWriter writer, string? value, JsonSerializer serializer) => writer.WriteRawValue(value);
}
}

View File

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting;
namespace BililiveRecorder.Web.Models.Rest.Logs
{
public class WebApiLogEventSink : ILogEventSink
{
public static WebApiLogEventSink? Instance;
private const int MAX_LOG = 100;
private readonly ReaderWriterLockSlim readerWriterLock = new();
private readonly ITextFormatter textFormatter;
private readonly Queue<JsonLog> logs = new Queue<JsonLog>();
private int logId = 0;
public WebApiLogEventSink(ITextFormatter textFormatter)
{
this.textFormatter = textFormatter ?? throw new ArgumentNullException(nameof(textFormatter));
}
public void Emit(LogEvent logEvent)
{
using var writer = new StringWriter();
this.textFormatter.Format(logEvent, writer);
var json = writer.ToString();
if (this.readerWriterLock.TryEnterWriteLock(5000))
{
try
{
this.logs.Enqueue(new JsonLog { Id = Interlocked.Increment(ref this.logId), Log = json });
while (this.logs.Count > MAX_LOG)
this.logs.Dequeue();
}
finally
{
this.readerWriterLock.ExitWriteLock();
}
}
}
public void ReadLogs(Action<Queue<JsonLog>> callback)
{
if (this.readerWriterLock.TryEnterReadLock(1000))
{
try
{
callback(this.logs);
}
finally
{
this.readerWriterLock.ExitReadLock();
}
}
}
}
}

View File

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>录播姬日志演示页</title>
</head>
<body>
<p>
当前 cursor: <span id="cursor_id"></span>
</p>
<div>
<table id="log_table">
<tr>
<th>日期</th>
<th>级别</th>
<th>房间号</th>
<th>消息</th>
</tr>
</table>
</div>
<script>
const cursor_span = document.getElementById('cursor_id');
const log_table = document.getElementById('log_table');
const page_url = new URL(location);
const api = page_url.searchParams.get('api') || './api/log/fetch';
let cursor = 0;
function fetchLogs() {
fetch(api + '?after=' + cursor)
.then(x => x.json())
.then(x => {
cursor = x.cursor;
cursor_span.textContent = cursor;
if (!x.continuous) {
appendLogText("不连续的日志");
}
x.logs.forEach(log => {
const formatted_str = log['@mt'].replace(/\{(.+?)\}/g, (match, key) => log[key]);
appendLogText(log['@t'], (log['@l'] || 'Info'), (log['RoomId'] || ''), formatted_str);
});
})
}
function appendLogText(date, level, roomid, text) {
console.log(text);
const tr = document.createElement('tr');
let td = document.createElement('td');
td.textContent = date;
tr.appendChild(td);
td = document.createElement('td');
td.textContent = level;
tr.appendChild(td);
td = document.createElement('td');
td.textContent = roomid;
tr.appendChild(td);
td = document.createElement('td');
td.textContent = text;
tr.appendChild(td);
log_table.appendChild(tr);
}
setInterval(() => {
fetchLogs();
}, 1000);
</script>
</body>
</html>