diff --git a/docs-website/docs/admin-panel/authentication.md b/docs-website/docs/admin-panel/authentication.md index 26cdc33770..d388c70b5b 100644 --- a/docs-website/docs/admin-panel/authentication.md +++ b/docs-website/docs/admin-panel/authentication.md @@ -134,7 +134,7 @@ Each user has one role. They build up on each other: |---|---| | **Viewer** | See the servers, accounts and the configuration | | **Operator** | Everything above, plus operating the servers and editing accounts | -| **Administrator** | Everything above, plus the setup, plugins, configuration updates, log files and the user management | +| **Administrator** | Everything above, plus the setup, plugins, configuration updates, log files, the logging configuration and the user management | Give each administrator their own user, so you can remove one without changing everybody else's password. diff --git a/docs-website/docs/admin-panel/logs-and-monitoring.md b/docs-website/docs/admin-panel/logs-and-monitoring.md index f946025632..54b58042f9 100644 --- a/docs-website/docs/admin-panel/logs-and-monitoring.md +++ b/docs-website/docs/admin-panel/logs-and-monitoring.md @@ -42,10 +42,36 @@ file, so a very large log stays fast. ### Configuring what is logged +**Navigation:** *Logging Configuration* — route `/logging-configuration` +(administrators only) + Logging is configured in `appsettings.json` with -[Serilog](https://github.com/serilog/serilog-settings-configuration). The server -makes good use of log scopes, so you can configure it to log only the actions of -certain players, of a certain server, or of a certain logger. See +[Serilog](https://github.com/serilog/serilog-settings-configuration). In the +all-in-one deployment, you can edit it in the admin panel instead of editing the +file. The page shows the path of the file it edits, and offers two tabs: + +| Tab | Effect | +|---|---| +| **Log Levels** | The default minimum level, and a table of levels per source. A source is the name of a logger — usually the full name of a class, like `MUnique.OpenMU.ConnectServer.ClientListener` — or a namespace like `MUnique.OpenMU.GameLogic`, which covers all loggers in it. The most specific source wins. | +| **Advanced (JSON)** | The whole `Serilog` section of the file, for everything the table doesn't cover, like sinks, output templates or filters. Invalid JSON is rejected. | + +![Log levels of the logging configuration](/img/admin-panel/logging-configuration-levels.png) + +![Advanced JSON editor of the logging configuration](/img/admin-panel/logging-configuration-json.png) + +**Save** writes the file; the rest of `appsettings.json` stays as it is (comments +in it are lost, though). The server watches the file, so a changed level of the +default or of an existing source applies at once — no restart needed. A newly +added or removed source and everything else in the `Serilog` section take effect +after the next restart. Settings from environment variables or an +`appsettings..json` file override the file, so changing them here +has no effect. + +In a docker container, the file lives inside the container: mount it as a +volume, if a change should survive recreating the container. + +The server makes good use of log scopes, so you can configure it to log only the +actions of certain players, of a certain server, or of a certain logger. See [Startup parameters](../deployment/startup-parameters.md#logging). ### Grafana (all-in-one, optional) diff --git a/docs-website/docs/deployment/startup-parameters.md b/docs-website/docs/deployment/startup-parameters.md index 33858aef96..1d927c8f0d 100644 --- a/docs-website/docs/deployment/startup-parameters.md +++ b/docs-website/docs/deployment/startup-parameters.md @@ -92,7 +92,9 @@ Logging can be configured in the `appsettings.json` file. By default, not a lot configured. To extend the configuration, have a look at the [Serilog documentation](https://github.com/serilog/serilog-settings-configuration). The server makes good use of scopes, so you can configure it to log only the -actions of certain players, for example. +actions of certain players, for example. In the all-in-one deployment, the admin +panel can edit the logging configuration, too — see +[Logs and monitoring](../admin-panel/logs-and-monitoring.md#configuring-what-is-logged). ## Settings priority diff --git a/docs-website/static/img/admin-panel/logging-configuration-json.png b/docs-website/static/img/admin-panel/logging-configuration-json.png new file mode 100644 index 0000000000..9e6388d326 Binary files /dev/null and b/docs-website/static/img/admin-panel/logging-configuration-json.png differ diff --git a/docs-website/static/img/admin-panel/logging-configuration-levels.png b/docs-website/static/img/admin-panel/logging-configuration-levels.png new file mode 100644 index 0000000000..51b0a50bef Binary files /dev/null and b/docs-website/static/img/admin-panel/logging-configuration-levels.png differ diff --git a/src/Web/AdminPanel/Components/Layout/NavMenu.razor b/src/Web/AdminPanel/Components/Layout/NavMenu.razor index f34d55c8ec..37a00cceff 100644 --- a/src/Web/AdminPanel/Components/Layout/NavMenu.razor +++ b/src/Web/AdminPanel/Components/Layout/NavMenu.razor @@ -105,6 +105,13 @@ @Resources.LogFiles + + + } @if (AdminPanelEnvironment.IsGrafanaAvailable) { diff --git a/src/Web/AdminPanel/Pages/LoggingConfiguration.razor b/src/Web/AdminPanel/Pages/LoggingConfiguration.razor new file mode 100644 index 0000000000..68d4ba86c0 --- /dev/null +++ b/src/Web/AdminPanel/Pages/LoggingConfiguration.razor @@ -0,0 +1,109 @@ +@page "/logging-configuration" +@attribute [Authorize(Policy = AdminPolicies.Administrator)] + +@using MUnique.OpenMU.Web.AdminPanel.Properties +@using MUnique.OpenMU.Web.AdminPanel.Services + +OpenMU: @Resources.LoggingConfiguration + +

@Resources.LoggingConfiguration

+ +@if (!AdminPanelEnvironment.IsHostingEmbedded) +{ + + return; +} + +

@string.Format(Resources.LoggingConfigurationDescription, this.LoggingConfigurationService.FilePath)

+ +@if (this._loadError is { } loadError) +{ + + + return; +} + +@if (this._settings is not { } settings) +{ +

@Resources.Loading

+ return; +} + + + +@if (this._showJson) +{ +

+ @Resources.AdvancedLoggingJsonDescription + Serilog.Settings.Configuration +

+ +} +else +{ +
+ + +
+ +
@Resources.LogLevelOverrides
+

@Resources.LogLevelOverridesDescription

+ + + + + + + + + + @foreach (var levelOverride in settings.Overrides) + { + + + + + + } + +
@Resources.LogSource@Resources.Level@Resources.Actions
+ + + + + +
+ +} + +
+ + +
diff --git a/src/Web/AdminPanel/Pages/LoggingConfiguration.razor.cs b/src/Web/AdminPanel/Pages/LoggingConfiguration.razor.cs new file mode 100644 index 0000000000..2d5b01b329 --- /dev/null +++ b/src/Web/AdminPanel/Pages/LoggingConfiguration.razor.cs @@ -0,0 +1,103 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using System.IO; +using System.Text.Json; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.AdminPanel.Services; +using MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// The page which edits the logging configuration of the server. +/// +public partial class LoggingConfiguration +{ + private LoggingSettings? _settings; + private string _sectionJson = string.Empty; + private string? _loadError; + private bool _showJson; + private bool _isSaving; + + [Inject] + private LoggingConfigurationService LoggingConfigurationService { get; set; } = null!; + + [Inject] + private IToastService ToastService { get; set; } = null!; + + [Inject] + private ILogger Logger { get; set; } = null!; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(true); + if (AdminPanelEnvironment.IsHostingEmbedded) + { + await this.LoadAsync().ConfigureAwait(true); + } + } + + private async Task LoadAsync() + { + try + { + this._settings = await this.LoggingConfigurationService.LoadAsync().ConfigureAwait(true); + this._sectionJson = this._settings.SectionJson; + this._loadError = null; + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + this.Logger.LogError(ex, "Couldn't load the logging configuration from {filePath}.", this.LoggingConfigurationService.FilePath); + this._settings = null; + this._loadError = ex.Message; + } + } + + private async Task SaveAsync() + { + if (this._settings is not { } settings) + { + return; + } + + this._isSaving = true; + try + { + if (this._showJson) + { + await this.LoggingConfigurationService.SaveSectionAsync(this._sectionJson).ConfigureAwait(true); + } + else if (settings.Overrides.Any(o => string.IsNullOrWhiteSpace(o.Source)) + || settings.Overrides.GroupBy(o => o.Source.Trim()).Any(g => g.Count() > 1)) + { + this.ToastService.ShowError(Resources.LogLevelOverridesInvalid); + return; + } + else + { + await this.LoggingConfigurationService.SaveMinimumLevelsAsync(settings.DefaultLevel, settings.Overrides).ConfigureAwait(true); + } + + this.ToastService.ShowSuccess(Resources.LoggingConfigurationSaved); + await this.LoadAsync().ConfigureAwait(true); + } + catch (JsonException ex) when (this._showJson) + { + this.ToastService.ShowError(string.Format(Resources.LoggingConfigurationInvalidJson, ex.Message)); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + this.Logger.LogError(ex, "Couldn't save the logging configuration to {filePath}.", this.LoggingConfigurationService.FilePath); + this.ToastService.ShowError(string.Format(Resources.LoggingConfigurationSaveError, ex.Message)); + } + finally + { + this._isSaving = false; + } + } +} diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 760744d797..0769ec8753 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -213,6 +213,24 @@ public static string AdminUsers { } } + /// + /// Looks up a localized string similar to Advanced (JSON). + /// + public static string AdvancedJson { + get { + return ResourceManager.GetString("AdvancedJson", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Here you can edit the whole Serilog section of the configuration file, for example to add sinks or filters. See the Serilog documentation for the available settings.. + /// + public static string AdvancedLoggingJsonDescription { + get { + return ResourceManager.GetString("AdvancedLoggingJsonDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to All. /// @@ -1311,6 +1329,15 @@ public static string Deactivate { } } + /// + /// Looks up a localized string similar to Default level. + /// + public static string DefaultLogLevel { + get { + return ResourceManager.GetString("DefaultLogLevel", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete. /// @@ -2196,6 +2223,69 @@ public static string LogFiles { } } + /// + /// Looks up a localized string similar to Logging Configuration. + /// + public static string LoggingConfiguration { + get { + return ResourceManager.GetString("LoggingConfiguration", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The logging of the server is configured in the file {0}. When you save, changed log levels are applied at once. Added or removed sources and all other settings take effect after a restart of the server.. + /// + public static string LoggingConfigurationDescription { + get { + return ResourceManager.GetString("LoggingConfigurationDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The JSON is invalid: {0}. + /// + public static string LoggingConfigurationInvalidJson { + get { + return ResourceManager.GetString("LoggingConfigurationInvalidJson", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The logging configuration couldn't be loaded: {0}. + /// + public static string LoggingConfigurationLoadError { + get { + return ResourceManager.GetString("LoggingConfigurationLoadError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The logging configuration can only be edited when the admin panel runs within the server process (all-in-one deployment).. + /// + public static string LoggingConfigurationNotAvailable { + get { + return ResourceManager.GetString("LoggingConfigurationNotAvailable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The logging configuration has been saved.. + /// + public static string LoggingConfigurationSaved { + get { + return ResourceManager.GetString("LoggingConfigurationSaved", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The logging configuration couldn't be saved: {0}. + /// + public static string LoggingConfigurationSaveError { + get { + return ResourceManager.GetString("LoggingConfigurationSaveError", resourceCulture); + } + } + /// /// Looks up a localized string similar to Login. /// @@ -2214,6 +2304,42 @@ public static string LoginName { } } + /// + /// Looks up a localized string similar to Levels per source. + /// + public static string LogLevelOverrides { + get { + return ResourceManager.GetString("LogLevelOverrides", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A source is the name of a logger, usually the full name of a class, or a namespace which covers all loggers in it. The most specific source wins.. + /// + public static string LogLevelOverridesDescription { + get { + return ResourceManager.GetString("LogLevelOverridesDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Every source needs a name, and each name may only be used once.. + /// + public static string LogLevelOverridesInvalid { + get { + return ResourceManager.GetString("LogLevelOverridesInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Log Levels. + /// + public static string LogLevels { + get { + return ResourceManager.GetString("LogLevels", resourceCulture); + } + } + /// /// Looks up a localized string similar to Logout. /// @@ -2232,6 +2358,15 @@ public static string Logs { } } + /// + /// Looks up a localized string similar to Source. + /// + public static string LogSource { + get { + return ResourceManager.GetString("LogSource", resourceCulture); + } + } + /// /// Looks up a localized string similar to Log Viewer. /// diff --git a/src/Web/AdminPanel/Properties/Resources.de.resx b/src/Web/AdminPanel/Properties/Resources.de.resx index ffab9d6522..1a4747c13f 100644 --- a/src/Web/AdminPanel/Properties/Resources.de.resx +++ b/src/Web/AdminPanel/Properties/Resources.de.resx @@ -1878,4 +1878,49 @@ Produkt {0} hat eine Menge kleiner als 1. + + Logging-Konfiguration + + + Das Logging des Servers wird in der Datei {0} konfiguriert. Beim Speichern werden geänderte Log-Level sofort angewendet. Hinzugefügte oder entfernte Quellen und alle anderen Einstellungen werden nach einem Neustart des Servers wirksam. + + + Die Logging-Konfiguration kann nur bearbeitet werden, wenn das Admin-Panel im Serverprozess läuft (All-in-One-Bereitstellung). + + + Log-Level + + + Standard-Level + + + Level pro Quelle + + + Eine Quelle ist der Name eines Loggers, meist der vollständige Name einer Klasse, oder ein Namespace, der alle darin enthaltenen Logger umfasst. Die spezifischste Quelle gewinnt. + + + Quelle + + + Erweitert (JSON) + + + Hier kann der gesamte Serilog-Abschnitt der Konfigurationsdatei bearbeitet werden, zum Beispiel um Sinks oder Filter hinzuzufügen. Die verfügbaren Einstellungen sind in der Serilog-Dokumentation beschrieben. + + + Die Logging-Konfiguration wurde gespeichert. + + + Die Logging-Konfiguration konnte nicht geladen werden: {0} + + + Die Logging-Konfiguration konnte nicht gespeichert werden: {0} + + + Das JSON ist ungültig: {0} + + + Jede Quelle benötigt einen Namen, und jeder Name darf nur einmal verwendet werden. + diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index d290a086c9..f75fb3aad3 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -1957,4 +1957,49 @@ Product {0} has a quantity below 1. + + Logging Configuration + + + The logging of the server is configured in the file {0}. When you save, changed log levels are applied at once. Added or removed sources and all other settings take effect after a restart of the server. + + + The logging configuration can only be edited when the admin panel runs within the server process (all-in-one deployment). + + + Log Levels + + + Default level + + + Levels per source + + + A source is the name of a logger, usually the full name of a class, or a namespace which covers all loggers in it. The most specific source wins. + + + Source + + + Advanced (JSON) + + + Here you can edit the whole Serilog section of the configuration file, for example to add sinks or filters. See the Serilog documentation for the available settings. + + + The logging configuration has been saved. + + + The logging configuration couldn't be loaded: {0} + + + The logging configuration couldn't be saved: {0} + + + The JSON is invalid: {0} + + + Every source needs a name, and each name may only be used once. + diff --git a/src/Web/AdminPanel/Services/LogLevelOverride.cs b/src/Web/AdminPanel/Services/LogLevelOverride.cs new file mode 100644 index 0000000000..1ddfd34096 --- /dev/null +++ b/src/Web/AdminPanel/Services/LogLevelOverride.cs @@ -0,0 +1,22 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Services; + +/// +/// A minimum log level which applies to the loggers of a source, instead of the default level. +/// +public class LogLevelOverride +{ + /// + /// Gets or sets the source, which is the name of a logger (usually the full name of a class) + /// or a prefix of it, like a namespace. + /// + public string Source { get; set; } = string.Empty; + + /// + /// Gets or sets the minimum level of the log entries of the . + /// + public string Level { get; set; } = LoggingConfigurationService.DefaultLevel; +} diff --git a/src/Web/AdminPanel/Services/LoggingConfigurationService.cs b/src/Web/AdminPanel/Services/LoggingConfigurationService.cs new file mode 100644 index 0000000000..c7925dab9b --- /dev/null +++ b/src/Web/AdminPanel/Services/LoggingConfigurationService.cs @@ -0,0 +1,219 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Services; + +using System.IO; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using Nito.AsyncEx; + +/// +/// Reads and writes the logging configuration of the server process, which is the +/// Serilog section of its appsettings.json. +/// +/// +/// The configuration file is watched by the server. When it's saved, Serilog applies the changed +/// minimum levels at once. Everything else, including added or removed overrides, takes effect +/// after a restart of the server. +/// +public class LoggingConfigurationService +{ + /// + /// The level which is used when nothing else is configured. + /// + public const string DefaultLevel = "Information"; + + /// + /// The name of the section which holds the logging configuration. + /// + public const string SectionName = "Serilog"; + + private const string MinimumLevelKey = "MinimumLevel"; + private const string DefaultKey = "Default"; + private const string OverrideKey = "Override"; + + private static readonly JsonDocumentOptions DocumentOptions = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + + // The file is edited by humans, too - so we don't escape characters like '+' or '<'. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private readonly AsyncLock _lock = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The path of the configuration file. + public LoggingConfigurationService(string filePath) + { + this.FilePath = filePath; + } + + /// + /// Gets the available log levels of Serilog, ordered from the most to the least detailed one. + /// + public static IReadOnlyList Levels { get; } = ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"]; + + /// + /// Gets the path of the configuration file. + /// + public string FilePath { get; } + + /// + /// Loads the logging settings from the configuration file. + /// + /// The cancellation token. + /// The loaded settings. + /// When the file couldn't be read. + /// When the file doesn't contain a valid JSON object. + public async ValueTask LoadAsync(CancellationToken cancellationToken = default) + { + using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false); + var root = await this.ReadRootAsync(cancellationToken).ConfigureAwait(false); + var section = root[SectionName] as JsonObject ?? new JsonObject(); + + var settings = new LoggingSettings + { + SectionJson = section.ToJsonString(SerializerOptions), + }; + + switch (section[MinimumLevelKey]) + { + case JsonValue value: + settings.DefaultLevel = NormalizeLevel(value.ToString()); + break; + case JsonObject minimumLevel: + settings.DefaultLevel = NormalizeLevel(minimumLevel[DefaultKey]?.ToString()); + if (minimumLevel[OverrideKey] is JsonObject overrides) + { + settings.Overrides.AddRange(overrides.Select(o => new LogLevelOverride + { + Source = o.Key, + Level = NormalizeLevel(o.Value?.ToString()), + })); + } + + break; + default: + // Nothing configured, so Serilog uses its default. + break; + } + + return settings; + } + + /// + /// Saves the minimum levels into the configuration file, and keeps the rest of it unchanged. + /// + /// The default minimum level. + /// The minimum levels of specific sources. + /// The cancellation token. + /// When a level is unknown, or a source is empty or defined twice. + /// When the file couldn't be read or written. + /// When the file doesn't contain a valid JSON object. + public async ValueTask SaveMinimumLevelsAsync(string defaultLevel, IReadOnlyCollection overrides, CancellationToken cancellationToken = default) + { + if (!Levels.Contains(defaultLevel)) + { + throw new ArgumentException($"Unknown log level '{defaultLevel}'.", nameof(defaultLevel)); + } + + if (overrides.FirstOrDefault(o => string.IsNullOrWhiteSpace(o.Source) || !Levels.Contains(o.Level)) is { } invalid) + { + throw new ArgumentException($"Invalid override '{invalid.Source}' with level '{invalid.Level}'.", nameof(overrides)); + } + + if (overrides.GroupBy(o => o.Source.Trim()).FirstOrDefault(g => g.Count() > 1) is { } duplicate) + { + throw new ArgumentException($"The source '{duplicate.Key}' is defined more than once.", nameof(overrides)); + } + + using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false); + var root = await this.ReadRootAsync(cancellationToken).ConfigureAwait(false); + if (root[SectionName] is not JsonObject section) + { + section = new JsonObject(); + root[SectionName] = section; + } + + if (section[MinimumLevelKey] is JsonValue && overrides.Count == 0) + { + // Serilog watches the value at the place where it was at the start, + // so we keep the short form when it's possible. + section[MinimumLevelKey] = defaultLevel; + } + else + { + if (section[MinimumLevelKey] is not JsonObject minimumLevel) + { + minimumLevel = new JsonObject(); + section[MinimumLevelKey] = minimumLevel; + } + + minimumLevel[DefaultKey] = defaultLevel; + var overridesObject = new JsonObject(); + foreach (var levelOverride in overrides) + { + overridesObject[levelOverride.Source.Trim()] = levelOverride.Level; + } + + minimumLevel[OverrideKey] = overridesObject; + } + + await this.WriteRootAsync(root, cancellationToken).ConfigureAwait(false); + } + + /// + /// Replaces the whole logging section of the configuration file, and keeps the rest of it unchanged. + /// + /// The JSON of the new section. + /// The cancellation token. + /// When or the file don't contain a valid JSON object. + /// When the file couldn't be read or written. + public async ValueTask SaveSectionAsync(string sectionJson, CancellationToken cancellationToken = default) + { + var section = ParseObject(sectionJson); + + using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false); + var root = await this.ReadRootAsync(cancellationToken).ConfigureAwait(false); + root[SectionName] = section; + await this.WriteRootAsync(root, cancellationToken).ConfigureAwait(false); + } + + private static string NormalizeLevel(string? level) + { + return Levels.FirstOrDefault(l => string.Equals(l, level, StringComparison.OrdinalIgnoreCase)) ?? DefaultLevel; + } + + private static JsonObject ParseObject(string json) + { + return JsonNode.Parse(json, documentOptions: DocumentOptions) as JsonObject + ?? throw new JsonException("The JSON is not an object."); + } + + private async ValueTask ReadRootAsync(CancellationToken cancellationToken) + { + var json = await File.ReadAllTextAsync(this.FilePath, cancellationToken).ConfigureAwait(false); + return ParseObject(json); + } + + private async ValueTask WriteRootAsync(JsonObject root, CancellationToken cancellationToken) + { + // We overwrite the file in place instead of replacing it with a new one, + // because it may be a single file which is mounted into a docker container. + var json = root.ToJsonString(SerializerOptions) + Environment.NewLine; + await File.WriteAllTextAsync(this.FilePath, json, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Web/AdminPanel/Services/LoggingSettings.cs b/src/Web/AdminPanel/Services/LoggingSettings.cs new file mode 100644 index 0000000000..2c95e18e88 --- /dev/null +++ b/src/Web/AdminPanel/Services/LoggingSettings.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Services; + +/// +/// The logging settings, as they are stored in the configuration file. +/// +public class LoggingSettings +{ + /// + /// Gets or sets the minimum level of the log entries of all sources without an override. + /// + public string DefaultLevel { get; set; } = LoggingConfigurationService.DefaultLevel; + + /// + /// Gets the minimum levels of specific sources. + /// + public List Overrides { get; } = new(); + + /// + /// Gets or sets the whole logging section of the configuration file, as indented JSON. + /// + public string SectionJson { get; set; } = "{}"; +} diff --git a/src/Web/AdminPanel/WebApplicationExtensions.cs b/src/Web/AdminPanel/WebApplicationExtensions.cs index efc1e2f81f..692ebd4a0c 100644 --- a/src/Web/AdminPanel/WebApplicationExtensions.cs +++ b/src/Web/AdminPanel/WebApplicationExtensions.cs @@ -86,6 +86,7 @@ public static WebApplicationBuilder AddAdminPanel(this WebApplicationBuilder bui services.AddSingleton, AccountDataSource>(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(new LoggingConfigurationService(Path.Combine(Directory.GetCurrentDirectory(), "appsettings.json"))); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/MUnique.OpenMU.Web.Tests/LoggingConfigurationServiceTests.cs b/tests/MUnique.OpenMU.Web.Tests/LoggingConfigurationServiceTests.cs new file mode 100644 index 0000000000..9da81053b1 --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/LoggingConfigurationServiceTests.cs @@ -0,0 +1,148 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests; + +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using MUnique.OpenMU.Web.AdminPanel.Services; + +/// +/// Tests for the . +/// +[TestFixture] +public class LoggingConfigurationServiceTests +{ + private const string ConfigurationJson = """ + { + // A comment, which is allowed in the configuration files of .NET. + "AllowedHosts": "*", + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "information", + "Override": { + "Microsoft": "Fatal", + "MUnique.OpenMU.Network.Connection": "Error" + } + }, + "WriteTo": [ { "Name": "Console", "Args": { "outputTemplate": "{Timestamp:HH:mm:ss zzz} [{Level}] {Message}{NewLine}" } } ] + } + } + """; + + private string _filePath = string.Empty; + + /// + /// Creates the configuration file. + /// + [SetUp] + public void SetUp() + { + this._filePath = Path.GetTempFileName(); + File.WriteAllText(this._filePath, ConfigurationJson); + } + + /// + /// Deletes the configuration file. + /// + [TearDown] + public void TearDown() + { + File.Delete(this._filePath); + } + + /// + /// Tests that the levels are loaded, and their spelling normalized. + /// + [Test] + public async Task LoadsLevelsAsync() + { + var settings = await new LoggingConfigurationService(this._filePath).LoadAsync().ConfigureAwait(false); + + Assert.That(settings.DefaultLevel, Is.EqualTo("Information")); + Assert.That(settings.Overrides.Select(o => (o.Source, o.Level)), Is.EqualTo(new[] { ("Microsoft", "Fatal"), ("MUnique.OpenMU.Network.Connection", "Error") })); + Assert.That(JsonNode.Parse(settings.SectionJson)?["Using"]?[0]?.ToString(), Is.EqualTo("Serilog.Sinks.Console")); + } + + /// + /// Tests that saving the levels replaces them, and keeps everything else of the file. + /// + [Test] + public async Task SavesLevelsAndKeepsTheRestAsync() + { + var service = new LoggingConfigurationService(this._filePath); + var overrides = new[] { new LogLevelOverride { Source = " MUnique.OpenMU.GameLogic ", Level = "Debug" } }; + + await service.SaveMinimumLevelsAsync("Warning", overrides).ConfigureAwait(false); + + var root = JsonNode.Parse(await File.ReadAllTextAsync(this._filePath).ConfigureAwait(false))!; + Assert.That(root["AllowedHosts"]?.ToString(), Is.EqualTo("*")); + Assert.That(root["Serilog"]?["MinimumLevel"]?["Default"]?.ToString(), Is.EqualTo("Warning")); + Assert.That(root["Serilog"]?["MinimumLevel"]?["Override"]?.AsObject().Select(o => (o.Key, o.Value?.ToString())), Is.EqualTo(new[] { ("MUnique.OpenMU.GameLogic", "Debug") })); + Assert.That(root["Serilog"]?["WriteTo"]?[0]?["Args"]?["outputTemplate"]?.ToString(), Is.EqualTo("{Timestamp:HH:mm:ss zzz} [{Level}] {Message}{NewLine}")); + + var settings = await service.LoadAsync().ConfigureAwait(false); + Assert.That(settings.DefaultLevel, Is.EqualTo("Warning")); + } + + /// + /// Tests that the short form of the minimum level is kept, so that Serilog can still reload it. + /// + [Test] + public async Task KeepsShortFormOfMinimumLevelAsync() + { + await File.WriteAllTextAsync(this._filePath, """{ "Serilog": { "MinimumLevel": "Information" } }""").ConfigureAwait(false); + var service = new LoggingConfigurationService(this._filePath); + + await service.SaveMinimumLevelsAsync("Debug", []).ConfigureAwait(false); + + var root = JsonNode.Parse(await File.ReadAllTextAsync(this._filePath).ConfigureAwait(false))!; + Assert.That(root["Serilog"]?["MinimumLevel"]?.ToString(), Is.EqualTo("Debug")); + } + + /// + /// Tests that invalid levels and duplicated sources are rejected without changing the file. + /// + [Test] + public void RejectsInvalidLevels() + { + var service = new LoggingConfigurationService(this._filePath); + + Assert.ThrowsAsync(async () => await service.SaveMinimumLevelsAsync("Loud", []).ConfigureAwait(false)); + Assert.ThrowsAsync(async () => await service.SaveMinimumLevelsAsync("Debug", [new LogLevelOverride { Source = string.Empty }]).ConfigureAwait(false)); + Assert.ThrowsAsync(async () => await service.SaveMinimumLevelsAsync("Debug", [new LogLevelOverride { Source = "A" }, new LogLevelOverride { Source = "A " }]).ConfigureAwait(false)); + Assert.That(File.ReadAllText(this._filePath), Is.EqualTo(ConfigurationJson)); + } + + /// + /// Tests that the whole section can be replaced, and the rest of the file is kept. + /// + [Test] + public async Task SavesSectionAsync() + { + var service = new LoggingConfigurationService(this._filePath); + + await service.SaveSectionAsync("""{ "MinimumLevel": "Verbose" }""").ConfigureAwait(false); + + var root = JsonNode.Parse(await File.ReadAllTextAsync(this._filePath).ConfigureAwait(false))!; + Assert.That(root["AllowedHosts"]?.ToString(), Is.EqualTo("*")); + Assert.That(root["Serilog"]?.ToJsonString(), Is.EqualTo("""{"MinimumLevel":"Verbose"}""")); + } + + /// + /// Tests that an invalid section is rejected without changing the file. + /// + /// The invalid JSON of the section. + [TestCase("""{ "MinimumLevel": """)] + [TestCase("[]")] + public void RejectsInvalidSection(string sectionJson) + { + var service = new LoggingConfigurationService(this._filePath); + + Assert.That(async () => await service.SaveSectionAsync(sectionJson).ConfigureAwait(false), Throws.InstanceOf()); + Assert.That(File.ReadAllText(this._filePath), Is.EqualTo(ConfigurationJson)); + } +} diff --git a/tests/MUnique.OpenMU.Web.Tests/Pages/LoggingConfigurationPageTests.cs b/tests/MUnique.OpenMU.Web.Tests/Pages/LoggingConfigurationPageTests.cs new file mode 100644 index 0000000000..f328b64340 --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/Pages/LoggingConfigurationPageTests.cs @@ -0,0 +1,124 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.Pages; + +using System.Globalization; +using System.IO; +using System.Text.Json.Nodes; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using MUnique.OpenMU.Web.AdminPanel; +using MUnique.OpenMU.Web.AdminPanel.Pages; +using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.AdminPanel.Services; +using MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// Tests for the page. +/// +[TestFixture] +[NonParallelizable] +public class LoggingConfigurationPageTests +{ + private BunitContext _context = null!; + private Mock _toastService = null!; + private CultureInfo _previousUiCulture = null!; + private bool _previousIsHostingEmbedded; + private string _filePath = string.Empty; + + /// + /// Sets up the test context with a configuration file. + /// + [SetUp] + public void SetUp() + { + this._previousUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en"); + this._previousIsHostingEmbedded = AdminPanelEnvironment.IsHostingEmbedded; + AdminPanelEnvironment.IsHostingEmbedded = true; + + this._filePath = Path.GetTempFileName(); + File.WriteAllText(this._filePath, """{ "Serilog": { "MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Fatal" } } } }"""); + + this._toastService = new Mock(); + this._context = new BunitContext(); + this._context.Services.AddSingleton(new LoggingConfigurationService(this._filePath)); + this._context.Services.AddSingleton(this._toastService.Object); + this._context.Services.AddSingleton(Mock.Of>()); + this._context.ComponentFactories.AddStub(); + } + + /// + /// Restores the environment and deletes the configuration file. + /// + [TearDown] + public void TearDown() + { + this._context.Dispose(); + File.Delete(this._filePath); + AdminPanelEnvironment.IsHostingEmbedded = this._previousIsHostingEmbedded; + CultureInfo.CurrentUICulture = this._previousUiCulture; + } + + /// + /// The levels which are changed in the page are saved to the file. + /// + [Test] + public void SavesChangedLevels() + { + var cut = this._context.Render(); + cut.WaitForState(() => cut.FindAll("#defaultLogLevel").Count > 0); + Assert.That(cut.Find("#defaultLogLevel").GetAttribute("value"), Is.EqualTo("Information")); + + cut.Find("#defaultLogLevel").Change("Debug"); + cut.FindAll("button").First(b => b.TextContent.Contains(Resources.AddNew)).Click(); + cut.FindAll("tbody input")[1].Change("MUnique.OpenMU.GameLogic"); + cut.FindAll("tbody select")[1].Change("Verbose"); + cut.FindAll("button").First(b => b.TextContent.Contains(Resources.Save)).Click(); + + cut.WaitForAssertion(() => this._toastService.Verify(t => t.ShowSuccess(Resources.LoggingConfigurationSaved, null))); + var minimumLevel = JsonNode.Parse(File.ReadAllText(this._filePath))!["Serilog"]!["MinimumLevel"]!; + Assert.That(minimumLevel["Default"]?.ToString(), Is.EqualTo("Debug")); + Assert.That(minimumLevel["Override"]?["Microsoft"]?.ToString(), Is.EqualTo("Fatal")); + Assert.That(minimumLevel["Override"]?["MUnique.OpenMU.GameLogic"]?.ToString(), Is.EqualTo("Verbose")); + } + + /// + /// An override without a source is not saved. + /// + [Test] + public void DoesNotSaveOverrideWithoutSource() + { + var content = File.ReadAllText(this._filePath); + var cut = this._context.Render(); + cut.WaitForState(() => cut.FindAll("tbody tr").Count == 1); + + cut.FindAll("button").First(b => b.TextContent.Contains(Resources.AddNew)).Click(); + cut.FindAll("button").First(b => b.TextContent.Contains(Resources.Save)).Click(); + + this._toastService.Verify(t => t.ShowError(Resources.LogLevelOverridesInvalid, null)); + Assert.That(File.ReadAllText(this._filePath), Is.EqualTo(content)); + } + + /// + /// Invalid JSON in the advanced editor is not saved. + /// + [Test] + public void DoesNotSaveInvalidJson() + { + var content = File.ReadAllText(this._filePath); + var cut = this._context.Render(); + cut.WaitForState(() => cut.FindAll("button.nav-link").Count == 2); + + cut.FindAll("button.nav-link")[1].Click(); + cut.Find("textarea").Change("{ \"MinimumLevel\": "); + cut.FindAll("button").First(b => b.TextContent.Contains(Resources.Save)).Click(); + + cut.WaitForAssertion(() => this._toastService.Verify(t => t.ShowError(It.Is(m => m.StartsWith("The JSON is invalid")), null))); + Assert.That(File.ReadAllText(this._filePath), Is.EqualTo(content)); + } +}