diff --git a/ArchiSteamFarm/IPC/Controllers/Api/NLogController.cs b/ArchiSteamFarm/IPC/Controllers/Api/NLogController.cs index e6415a60f..12bfd5453 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/NLogController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/NLogController.cs @@ -32,6 +32,7 @@ using System.Threading.Tasks; using ArchiSteamFarm.Core; using ArchiSteamFarm.IPC.Responses; using ArchiSteamFarm.Localization; +using ArchiSteamFarm.NLog; using ArchiSteamFarm.NLog.Targets; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Mvc; @@ -43,6 +44,43 @@ namespace ArchiSteamFarm.IPC.Controllers.Api; public sealed class NLogController : ArchiController { private static readonly ConcurrentDictionary ActiveLogWebSockets = new(); + /// + /// Fetches ASF log file, this works on assumption that the log file is in fact generated, as user could disable it through custom configuration. + /// + /// Maximum amount of lines from the log file returned. The respone naturally might have less amount than specified, if you've read whole file already. + /// Ending index, used for pagination. Omit it for the first request, then initialize to TotalLines returned, and on every following request subtract count that you've used in the previous request from it until you hit 0 or less, which means you've read whole file already. + [HttpGet("File")] + [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] + public async Task> FileGet(int count = 100, int lastAt = 0) { + if (count <= 0) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(count)))); + } + + if (lastAt < 0) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(lastAt)))); + } + + if (!Logging.LogFileExists) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(SharedInfo.LogFile)))); + } + + string[]? lines = await Logging.ReadLogFileLines().ConfigureAwait(false); + + if ((lines == null) || (lines.Length == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(SharedInfo.LogFile)))); + } + + if ((lastAt == 0) || (lastAt > lines.Length)) { + lastAt = lines.Length; + } + + int startFrom = Math.Max(lastAt - count, 0); + + return Ok(new GenericResponse(new LogResponse(lines.Length, lines[startFrom..lastAt]))); + } + /// /// Fetches ASF log in realtime. /// @@ -52,7 +90,7 @@ public sealed class NLogController : ArchiController { [HttpGet] [ProducesResponseType(typeof(IEnumerable>), (int) HttpStatusCode.OK)] [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task NLogGet(CancellationToken cancellationToken) { + public async Task Get(CancellationToken cancellationToken) { if (HttpContext == null) { throw new InvalidOperationException(nameof(HttpContext)); } diff --git a/ArchiSteamFarm/IPC/Responses/LogResponse.cs b/ArchiSteamFarm/IPC/Responses/LogResponse.cs new file mode 100644 index 000000000..2e568ed46 --- /dev/null +++ b/ArchiSteamFarm/IPC/Responses/LogResponse.cs @@ -0,0 +1,52 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2022 Ɓukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; + +namespace ArchiSteamFarm.IPC.Responses; + +public sealed class LogResponse { + /// + /// Content of the log file which consists of lines read from it - in chronological order. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public IReadOnlyList Content { get; private set; } + + /// + /// Total number of lines of the log file returned, can be used as an index for future requests. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public int TotalLines { get; private set; } + + internal LogResponse(int totalLines, IReadOnlyList content) { + if (totalLines < 0) { + throw new ArgumentOutOfRangeException(nameof(totalLines)); + } + + TotalLines = totalLines; + Content = content ?? throw new ArgumentNullException(nameof(content)); + } +} diff --git a/ArchiSteamFarm/NLog/Logging.cs b/ArchiSteamFarm/NLog/Logging.cs index f8f3b609d..0f422c570 100644 --- a/ArchiSteamFarm/NLog/Logging.cs +++ b/ArchiSteamFarm/NLog/Logging.cs @@ -47,6 +47,8 @@ internal static class Logging { private const string GeneralLayout = $@"${{date:format=yyyy-MM-dd HH\:mm\:ss}}|${{processname}}-${{processid}}|${{level:uppercase=true}}|{LayoutMessage}"; private const string LayoutMessage = @"${logger}|${message}${onexception:inner= ${exception:format=toString,Data}}"; + internal static bool LogFileExists => File.Exists(SharedInfo.LogFile); + private static readonly ConcurrentHashSet ConsoleLoggingRules = new(); private static readonly SemaphoreSlim ConsoleSemaphore = new(1, 1); @@ -181,6 +183,10 @@ internal static class Logging { CleanupFileName = false, DeleteOldFileOnStartup = true, FileName = Path.Combine("${currentdir}", SharedInfo.LogFile), + + // For GET /Api/NLog/File ASF API usage on Windows (sigh) + KeepFileOpen = !OperatingSystem.IsWindows(), + Layout = GeneralLayout, MaxArchiveFiles = 10 }; @@ -235,6 +241,16 @@ internal static class Logging { ArchiKestrel.OnNewHistoryTarget(historyTarget); } + internal static async Task ReadLogFileLines() { + try { + return await File.ReadAllLinesAsync(SharedInfo.LogFile).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + } + internal static void StartInteractiveConsole() { Utilities.InBackground(HandleConsoleInteractively, true); ASF.ArchiLogger.LogGenericInfo(Strings.InteractiveConsoleEnabled); diff --git a/Directory.Build.props b/Directory.Build.props index fc029bf8f..f8cd1963e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -33,6 +33,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 249b2843a..08dca92bf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,7 +27,7 @@ - + @@ -38,5 +38,6 @@ +