mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-04-24 22:17:25 -04:00
Merge pull request #2535 from mark-monteiro/logging-migration
Create Logging Configuration Heirarchy
This commit is contained in:
commit
d8d37671ff
8 changed files with 139 additions and 82 deletions
28
Jellyfin.Server/Migrations/IMigrationRoutine.cs
Normal file
28
Jellyfin.Server/Migrations/IMigrationRoutine.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interface that describes a migration routine.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IMigrationRoutine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique id for this migration. This should never be modified after the migration has been created.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the display name of the migration.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execute the migration routine.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="host">Host that hosts current version.</param>
|
||||||
|
/// <param name="logger">Host logger.</param>
|
||||||
|
public void Perform(CoreAppHost host, ILogger logger);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,23 +0,0 @@
|
||||||
using System;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Jellyfin.Server.Migrations
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Interface that descibes a migration routine.
|
|
||||||
/// </summary>
|
|
||||||
internal interface IUpdater
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the name of the migration, must be unique.
|
|
||||||
/// </summary>
|
|
||||||
public abstract string Name { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Execute the migration routine.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="host">Host that hosts current version.</param>
|
|
||||||
/// <param name="logger">Host logger.</param>
|
|
||||||
public abstract void Perform(CoreAppHost host, ILogger logger);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Jellyfin.Server.Migrations
|
namespace Jellyfin.Server.Migrations
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -10,14 +13,12 @@ namespace Jellyfin.Server.Migrations
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public MigrationOptions()
|
public MigrationOptions()
|
||||||
{
|
{
|
||||||
Applied = System.Array.Empty<string>();
|
Applied = new List<(Guid Id, string Name)>();
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma warning disable CA1819 // Properties should not return arrays
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of applied migration routine names.
|
/// Gets the list of applied migration routine names.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string[] Applied { get; set; }
|
public List<(Guid Id, string Name)> Applied { get; }
|
||||||
#pragma warning restore CA1819 // Properties should not return arrays
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,9 +13,10 @@ namespace Jellyfin.Server.Migrations
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of known migrations, in order of applicability.
|
/// The list of known migrations, in order of applicability.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static readonly IUpdater[] Migrations =
|
internal static readonly IMigrationRoutine[] Migrations =
|
||||||
{
|
{
|
||||||
new Routines.DisableTranscodingThrottling()
|
new Routines.DisableTranscodingThrottling(),
|
||||||
|
new Routines.CreateUserLoggingConfigFile()
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -28,47 +29,44 @@ namespace Jellyfin.Server.Migrations
|
||||||
var logger = loggerFactory.CreateLogger<MigrationRunner>();
|
var logger = loggerFactory.CreateLogger<MigrationRunner>();
|
||||||
var migrationOptions = ((IConfigurationManager)host.ServerConfigurationManager).GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey);
|
var migrationOptions = ((IConfigurationManager)host.ServerConfigurationManager).GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey);
|
||||||
|
|
||||||
if (!host.ServerConfigurationManager.Configuration.IsStartupWizardCompleted && migrationOptions.Applied.Length == 0)
|
if (!host.ServerConfigurationManager.Configuration.IsStartupWizardCompleted && migrationOptions.Applied.Count == 0)
|
||||||
{
|
{
|
||||||
// If startup wizard is not finished, this is a fresh install.
|
// If startup wizard is not finished, this is a fresh install.
|
||||||
// Don't run any migrations, just mark all of them as applied.
|
// Don't run any migrations, just mark all of them as applied.
|
||||||
logger.LogInformation("Marking all known migrations as applied because this is fresh install");
|
logger.LogInformation("Marking all known migrations as applied because this is a fresh install");
|
||||||
migrationOptions.Applied = Migrations.Select(m => m.Name).ToArray();
|
migrationOptions.Applied.AddRange(Migrations.Select(m => (m.Id, m.Name)));
|
||||||
host.ServerConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions);
|
host.ServerConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var applied = migrationOptions.Applied.ToList();
|
var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet();
|
||||||
|
|
||||||
for (var i = 0; i < Migrations.Length; i++)
|
for (var i = 0; i < Migrations.Length; i++)
|
||||||
{
|
{
|
||||||
var updater = Migrations[i];
|
var migrationRoutine = Migrations[i];
|
||||||
if (applied.Contains(updater.Name))
|
if (appliedMigrationIds.Contains(migrationRoutine.Id))
|
||||||
{
|
{
|
||||||
logger.LogDebug("Skipping migration '{Name}' since it is already applied", updater.Name);
|
logger.LogDebug("Skipping migration '{Name}' since it is already applied", migrationRoutine.Name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("Applying migration '{Name}'", updater.Name);
|
logger.LogInformation("Applying migration '{Name}'", migrationRoutine.Name);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
updater.Perform(host, logger);
|
migrationRoutine.Perform(host, logger);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Could not apply migration '{Name}'", updater.Name);
|
logger.LogError(ex, "Could not apply migration '{Name}'", migrationRoutine.Name);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("Migration '{Name}' applied successfully", updater.Name);
|
// Mark the migration as completed
|
||||||
applied.Add(updater.Name);
|
logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name);
|
||||||
}
|
migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name));
|
||||||
|
|
||||||
if (applied.Count > migrationOptions.Applied.Length)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Some migrations were run, saving the state");
|
|
||||||
migrationOptions.Applied = applied.ToArray();
|
|
||||||
host.ServerConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions);
|
host.ServerConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions);
|
||||||
|
logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Migrations.Routines
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Migration to initialize the user logging configuration file "logging.user.json".
|
||||||
|
/// If the deprecated logging.json file exists and has a custom config, it will be used as logging.user.json,
|
||||||
|
/// otherwise a blank file will be created.
|
||||||
|
/// </summary>
|
||||||
|
internal class CreateUserLoggingConfigFile : IMigrationRoutine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// File history for logging.json as existed during this migration creation. The contents for each has been minified.
|
||||||
|
/// </summary>
|
||||||
|
private readonly List<string> _defaultConfigHistory = new List<string>
|
||||||
|
{
|
||||||
|
// 9a6c27947353585391e211aa88b925f81e8cd7b9
|
||||||
|
@"{""Serilog"":{""MinimumLevel"":{""Default"":""Information"",""Override"":{""Microsoft"":""Warning"",""System"":""Warning""}},""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}""}}]}}],""Enrich"":[""FromLogContext"",""WithThreadId""]}}",
|
||||||
|
// 71bdcd730705a714ee208eaad7290b7c68df3885
|
||||||
|
@"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}""}}]}}],""Enrich"":[""FromLogContext"",""WithThreadId""]}}",
|
||||||
|
// a44936f97f8afc2817d3491615a7cfe1e31c251c
|
||||||
|
@"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}""}},{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}""}}]}}",
|
||||||
|
// 7af3754a11ad5a4284f107997fb5419a010ce6f3
|
||||||
|
@"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}""}}]}}]}}",
|
||||||
|
// 60691349a11f541958e0b2247c9abc13cb40c9fb
|
||||||
|
@"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}""}}]}}]}}",
|
||||||
|
// 65fe243afbcc4b596cf8726708c1965cd34b5f68
|
||||||
|
@"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] {ThreadId} {SourceContext}: {Message:lj} {NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {ThreadId} {SourceContext}:{Message} {NewLine}{Exception}""}}]}}],""Enrich"":[""FromLogContext"",""WithThreadId""]}}",
|
||||||
|
// 96c9af590494aa8137d5a061aaf1e68feee60b67
|
||||||
|
@"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}:{Message}{NewLine}{Exception}""}}]}}],""Enrich"":[""FromLogContext"",""WithThreadId""]}}",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Guid Id => Guid.Parse("{EF103419-8451-40D8-9F34-D1A8E93A1679}");
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string Name => "CreateLoggingConfigHeirarchy";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Perform(CoreAppHost host, ILogger logger)
|
||||||
|
{
|
||||||
|
var logDirectory = host.Resolve<IApplicationPaths>().ConfigurationDirectoryPath;
|
||||||
|
var existingConfigPath = Path.Combine(logDirectory, "logging.json");
|
||||||
|
|
||||||
|
// If the existing logging.json config file is unmodified, then 'reset' it by moving it to 'logging.old.json'
|
||||||
|
// NOTE: This config file has 'reloadOnChange: true', so this change will take effect immediately even though it has already been loaded
|
||||||
|
if (File.Exists(existingConfigPath) && ExistingConfigUnmodified(existingConfigPath))
|
||||||
|
{
|
||||||
|
File.Move(existingConfigPath, Path.Combine(logDirectory, "logging.old.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if the existing logging.json file has not been modified by the user by comparing it to all the
|
||||||
|
/// versions in our git history. Until now, the file has never been migrated after first creation so users
|
||||||
|
/// could have any version from the git history.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="IOException"><paramref name="oldConfigPath"/> does not exist or could not be read.</exception>
|
||||||
|
private bool ExistingConfigUnmodified(string oldConfigPath)
|
||||||
|
{
|
||||||
|
var existingConfigJson = JToken.Parse(File.ReadAllText(oldConfigPath));
|
||||||
|
return _defaultConfigHistory
|
||||||
|
.Select(historicalConfigText => JToken.Parse(historicalConfigText))
|
||||||
|
.Any(historicalConfigJson => JToken.DeepEquals(existingConfigJson, historicalConfigJson));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,8 +10,11 @@ namespace Jellyfin.Server.Migrations.Routines
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disable transcode throttling for all installations since it is currently broken for certain video formats.
|
/// Disable transcode throttling for all installations since it is currently broken for certain video formats.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class DisableTranscodingThrottling : IUpdater
|
internal class DisableTranscodingThrottling : IMigrationRoutine
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Guid Id => Guid.Parse("{4124C2CD-E939-4FFB-9BE9-9B311C413638}");
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public string Name => "DisableTranscodingThrottling";
|
public string Name => "DisableTranscodingThrottling";
|
||||||
|
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using MediaBrowser.Common.Configuration;
|
|
||||||
using MediaBrowser.Model.Configuration;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Serilog;
|
|
||||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
|
||||||
|
|
||||||
namespace Jellyfin.Server.Migrations.Routines
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Updater that takes care of bringing configuration up to 10.5.0 standards.
|
|
||||||
/// </summary>
|
|
||||||
internal class DisableZealousLogging : IUpdater
|
|
||||||
{
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public string Name => "DisableZealousLogging";
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
// This tones down logging from some components
|
|
||||||
public void Perform(CoreAppHost host, ILogger logger)
|
|
||||||
{
|
|
||||||
string configPath = Path.Combine(host.ServerConfigurationManager.ApplicationPaths.ConfigurationDirectoryPath, Program.LoggingConfigFile);
|
|
||||||
// TODO: fix up the config
|
|
||||||
throw new NotImplementedException("don't know how to fix logging yet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -39,9 +39,14 @@ namespace Jellyfin.Server
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The name of logging configuration file.
|
/// The name of logging configuration file containing application defaults.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly string LoggingConfigFile = "logging.json";
|
public static readonly string LoggingConfigFileDefault = "logging.default.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the logging configuration file containing the system-specific override settings.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string LoggingConfigFileSystem = "logging.json";
|
||||||
|
|
||||||
private static readonly CancellationTokenSource _tokenSource = new CancellationTokenSource();
|
private static readonly CancellationTokenSource _tokenSource = new CancellationTokenSource();
|
||||||
private static readonly ILoggerFactory _loggerFactory = new SerilogLoggerFactory();
|
private static readonly ILoggerFactory _loggerFactory = new SerilogLoggerFactory();
|
||||||
|
@ -443,7 +448,7 @@ namespace Jellyfin.Server
|
||||||
private static async Task<IConfiguration> CreateConfiguration(IApplicationPaths appPaths)
|
private static async Task<IConfiguration> CreateConfiguration(IApplicationPaths appPaths)
|
||||||
{
|
{
|
||||||
const string ResourcePath = "Jellyfin.Server.Resources.Configuration.logging.json";
|
const string ResourcePath = "Jellyfin.Server.Resources.Configuration.logging.json";
|
||||||
string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, LoggingConfigFile);
|
string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, LoggingConfigFileDefault);
|
||||||
|
|
||||||
if (!File.Exists(configPath))
|
if (!File.Exists(configPath))
|
||||||
{
|
{
|
||||||
|
@ -465,7 +470,8 @@ namespace Jellyfin.Server
|
||||||
return new ConfigurationBuilder()
|
return new ConfigurationBuilder()
|
||||||
.SetBasePath(appPaths.ConfigurationDirectoryPath)
|
.SetBasePath(appPaths.ConfigurationDirectoryPath)
|
||||||
.AddInMemoryCollection(ConfigurationOptions.Configuration)
|
.AddInMemoryCollection(ConfigurationOptions.Configuration)
|
||||||
.AddJsonFile(LoggingConfigFile, false, true)
|
.AddJsonFile(LoggingConfigFileDefault, optional: false, reloadOnChange: true)
|
||||||
|
.AddJsonFile(LoggingConfigFileSystem, optional: true, reloadOnChange: true)
|
||||||
.AddEnvironmentVariables("JELLYFIN_")
|
.AddEnvironmentVariables("JELLYFIN_")
|
||||||
.Build();
|
.Build();
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue