mirror of
https://github.com/BililiveRecorder/BililiveRecorder.git
synced 2024-11-15 19:22:19 +08:00
feat(web): add log api
This commit is contained in:
parent
050226fdcc
commit
f99a9b59e4
|
@ -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" />
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
60
BililiveRecorder.Web/Api/LogController.cs
Normal file
60
BililiveRecorder.Web/Api/LogController.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
15
BililiveRecorder.Web/Models/Rest/Logs/JsonLog.cs
Normal file
15
BililiveRecorder.Web/Models/Rest/Logs/JsonLog.cs
Normal 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);
|
||||
}
|
||||
}
|
16
BililiveRecorder.Web/Models/Rest/Logs/JsonLogDto.cs
Normal file
16
BililiveRecorder.Web/Models/Rest/Logs/JsonLogDto.cs
Normal 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>();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
66
BililiveRecorder.Web/Models/Rest/Logs/WebApiLogEventSink.cs
Normal file
66
BililiveRecorder.Web/Models/Rest/Logs/WebApiLogEventSink.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
82
BililiveRecorder.Web/embeded/logs.html
Normal file
82
BililiveRecorder.Web/embeded/logs.html
Normal 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>
|
Loading…
Reference in New Issue
Block a user