diff options
Diffstat (limited to 'Jellyfin.Server/ServerSetupApp')
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/IStartupLogger.cs | 66 | ||||
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/SetupServer.cs | 182 | ||||
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs | 31 | ||||
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/StartupLogger.cs | 124 | ||||
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs | 18 | ||||
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs | 56 | ||||
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/index.mstemplate.html | 235 |
7 files changed, 699 insertions, 13 deletions
diff --git a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs new file mode 100644 index 000000000..e7c193936 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs @@ -0,0 +1,66 @@ +using System; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// Defines the Startup Logger. This logger acts an an aggregate logger that will push though all log messages to both the attached logger as well as the startup UI. +/// </summary> +public interface IStartupLogger : ILogger +{ + /// <summary> + /// Gets the topic this logger is assigned to. + /// </summary> + StartupLogTopic? Topic { get; } + + /// <summary> + /// Adds another logger instance to this logger for combined logging. + /// </summary> + /// <param name="logger">Other logger to rely messages to.</param> + /// <returns>A combined logger.</returns> + IStartupLogger With(ILogger logger); + + /// <summary> + /// Opens a new Group logger within the parent logger. + /// </summary> + /// <param name="logEntry">Defines the log message that introduces the new group.</param> + /// <returns>A new logger that can write to the group.</returns> + IStartupLogger BeginGroup(FormattableString logEntry); + + /// <summary> + /// Adds another logger instance to this logger for combined logging. + /// </summary> + /// <param name="logger">Other logger to rely messages to.</param> + /// <returns>A combined logger.</returns> + /// <typeparam name="TCategory">The logger cateogry.</typeparam> + IStartupLogger<TCategory> With<TCategory>(ILogger logger); + + /// <summary> + /// Opens a new Group logger within the parent logger. + /// </summary> + /// <param name="logEntry">Defines the log message that introduces the new group.</param> + /// <returns>A new logger that can write to the group.</returns> + /// <typeparam name="TCategory">The logger cateogry.</typeparam> + IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry); +} + +/// <summary> +/// Defines a logger that can be injected via DI to get a startup logger initialised with an logger framework connected <see cref="ILogger"/>. +/// </summary> +/// <typeparam name="TCategory">The logger cateogry.</typeparam> +public interface IStartupLogger<TCategory> : IStartupLogger +{ + /// <summary> + /// Adds another logger instance to this logger for combined logging. + /// </summary> + /// <param name="logger">Other logger to rely messages to.</param> + /// <returns>A combined logger.</returns> + new IStartupLogger<TCategory> With(ILogger logger); + + /// <summary> + /// Opens a new Group logger within the parent logger. + /// </summary> + /// <param name="logEntry">Defines the log message that introduces the new group.</param> + /// <returns>A new logger that can write to the group.</returns> + new IStartupLogger<TCategory> BeginGroup(FormattableString logEntry); +} diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 3d4810bd7..4340969a3 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -7,9 +10,11 @@ using System.Threading.Tasks; using Emby.Server.Implementations.Configuration; using Emby.Server.Implementations.Serialization; using Jellyfin.Networking.Manager; +using Jellyfin.Server.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; +using MediaBrowser.Model.IO; using MediaBrowser.Model.System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -20,6 +25,11 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; +using Morestachio; +using Morestachio.Framework.IO.SingleStream; +using Morestachio.Rendering; +using Serilog; +using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Jellyfin.Server.ServerSetupApp; @@ -34,8 +44,10 @@ public sealed class SetupServer : IDisposable private readonly ILoggerFactory _loggerFactory; private readonly IConfiguration _startupConfiguration; private readonly ServerConfigurationManager _configurationManager; + private IRenderer? _startupUiRenderer; private IHost? _startupServer; private bool _disposed; + private bool _isUnhealthy; /// <summary> /// Initializes a new instance of the <see cref="SetupServer"/> class. @@ -62,26 +74,92 @@ public sealed class SetupServer : IDisposable _configurationManager.RegisterConfiguration<NetworkConfigurationFactory>(); } + internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new(); + + /// <summary> + /// Gets a value indicating whether Startup server is currently running. + /// </summary> + public bool IsAlive { get; internal set; } + /// <summary> /// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup. /// </summary> /// <returns>A Task.</returns> public async Task RunAsync() { + var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); + _startupUiRenderer = (await ParserOptionsBuilder.New() + .WithTemplate(fileTemplate) + .WithFormatter( + (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) => + { + if (children.Any()) + { + var maxLevel = logEntry.LogLevel; + var stack = new Stack<StartupLogTopic>(children); + + while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. + { + maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; + foreach (var child in logEntry.Children) + { + stack.Push(child); + } + } + + return maxLevel; + } + + return logEntry.LogLevel; + }, + "FormatLogLevel") + .WithFormatter( + (LogLevel logLevel) => + { + switch (logLevel) + { + case LogLevel.Trace: + case LogLevel.Debug: + case LogLevel.None: + return "success"; + case LogLevel.Information: + return "info"; + case LogLevel.Warning: + return "warn"; + case LogLevel.Error: + return "danger"; + case LogLevel.Critical: + return "danger-strong"; + } + + return string.Empty; + }, + "ToString") + .BuildAndParseAsync() + .ConfigureAwait(false)) + .CreateCompiledRenderer(); + ThrowIfDisposed(); - _startupServer = Host.CreateDefaultBuilder() + var retryAfterValue = TimeSpan.FromSeconds(5); + var config = _configurationManager.GetNetworkConfiguration()!; + _startupServer = Host.CreateDefaultBuilder(["hostBuilder:reloadConfigOnChange=false"]) .UseConsoleLifetime() + .UseSerilog() .ConfigureServices(serv => { + serv.AddSingleton(this); serv.AddHealthChecks() .AddCheck<SetupHealthcheck>("StartupCheck"); + serv.Configure<ForwardedHeadersOptions>(options => + { + ApiServiceCollectionExtensions.ConfigureForwardHeaders(config, options); + }); }) .ConfigureWebHostDefaults(webHostBuilder => { webHostBuilder .UseKestrel((builderContext, options) => { - var config = _configurationManager.GetNetworkConfiguration()!; var knownBindInterfaces = NetworkManager.GetInterfacesCore(_loggerFactory.CreateLogger<SetupServer>(), config.EnableIPv4, config.EnableIPv6); knownBindInterfaces = NetworkManager.FilterBindSettings(config, knownBindInterfaces.ToList(), config.EnableIPv4, config.EnableIPv6); var bindInterfaces = NetworkManager.GetAllBindInterfaces(false, _configurationManager, knownBindInterfaces, config.EnableIPv4, config.EnableIPv6); @@ -99,7 +177,7 @@ public sealed class SetupServer : IDisposable .Configure(app => { app.UseHealthChecks("/health"); - + app.UseForwardedHeaders(); app.Map("/startup/logger", loggerRoute => { loggerRoute.Run(async context => @@ -113,7 +191,7 @@ public sealed class SetupServer : IDisposable var logFilePath = new DirectoryInfo(_applicationPaths.LogDirectoryPath) .EnumerateFiles() - .OrderBy(f => f.CreationTimeUtc) + .OrderByDescending(f => f.CreationTimeUtc) .FirstOrDefault() ?.FullName; if (logFilePath is not null) @@ -140,7 +218,7 @@ public sealed class SetupServer : IDisposable if (jfApplicationHost is null) { context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; - context.Response.Headers.RetryAfter = new StringValues("5"); + context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture)); return; } @@ -158,24 +236,32 @@ public sealed class SetupServer : IDisposable }); }); - app.Run((context) => + app.Run(async (context) => { context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; - context.Response.Headers.RetryAfter = new StringValues("5"); + context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture)); context.Response.Headers.ContentType = new StringValues("text/html"); - context.Response.WriteAsync("<p>Jellyfin Server still starting. Please wait.</p>"); var networkManager = _networkManagerFactory(); - if (networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress)) - { - context.Response.WriteAsync("<p>You can download the current logfiles <a href='/startup/logger'>here</a>.</p>"); - } - return Task.CompletedTask; + var startupLogEntries = LogQueue?.ToArray() ?? []; + await _startupUiRenderer.RenderAsync( + new Dictionary<string, object>() + { + { "isInReportingMode", _isUnhealthy }, + { "retryValue", retryAfterValue }, + { "version", typeof(Emby.Server.Implementations.ApplicationHost).Assembly.GetName().Version! }, + { "logs", startupLogEntries }, + { "networkManagerReady", networkManager is not null }, + { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) } + }, + new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions)) + .ConfigureAwait(false); }); }); }) .Build(); await _startupServer.StartAsync().ConfigureAwait(false); + IsAlive = true; } /// <summary> @@ -191,6 +277,7 @@ public sealed class SetupServer : IDisposable } await _startupServer.StopAsync().ConfigureAwait(false); + IsAlive = false; } /// <inheritdoc/> @@ -203,6 +290,9 @@ public sealed class SetupServer : IDisposable _disposed = true; _startupServer?.Dispose(); + IsAlive = false; + LogQueue?.Clear(); + LogQueue = null; } private void ThrowIfDisposed() @@ -210,11 +300,77 @@ public sealed class SetupServer : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); } + internal void SoftStop() + { + _isUnhealthy = true; + } + private class SetupHealthcheck : IHealthCheck { + private readonly SetupServer _startupServer; + + public SetupHealthcheck(SetupServer startupServer) + { + _startupServer = startupServer; + } + public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { + if (_startupServer._isUnhealthy) + { + return Task.FromResult(HealthCheckResult.Unhealthy("Server is could not complete startup. Check logs.")); + } + return Task.FromResult(HealthCheckResult.Degraded("Server is still starting up.")); } } + + internal sealed class SetupLoggerFactory : ILoggerProvider, IDisposable + { + private bool _disposed; + + public ILogger CreateLogger(string categoryName) + { + return new CatchingSetupServerLogger(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + } + } + + internal sealed class CatchingSetupServerLogger : ILogger + { + public IDisposable? BeginScope<TState>(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel is LogLevel.Error or LogLevel.Critical; + } + + public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + LogQueue?.Enqueue(new() + { + LogLevel = logLevel, + Content = formatter(state, exception), + DateOfCreation = DateTimeOffset.Now + }); + } + } } diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs new file mode 100644 index 000000000..cd440a9b5 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// Defines a topic for the Startup UI. +/// </summary> +public class StartupLogTopic +{ + /// <summary> + /// Gets or Sets the LogLevel. + /// </summary> + public LogLevel LogLevel { get; set; } + + /// <summary> + /// Gets or Sets the descriptor for the topic. + /// </summary> + public string? Content { get; set; } + + /// <summary> + /// Gets or sets the time the topic was created. + /// </summary> + public DateTimeOffset DateOfCreation { get; set; } + + /// <summary> + /// Gets the child items of this topic. + /// </summary> + public Collection<StartupLogTopic> Children { get; } = []; +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs new file mode 100644 index 000000000..0121854ce --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs @@ -0,0 +1,124 @@ +using System; +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <inheritdoc/> +public class StartupLogger : IStartupLogger +{ + private readonly StartupLogTopic? _topic; + + /// <summary> + /// Initializes a new instance of the <see cref="StartupLogger"/> class. + /// </summary> + /// <param name="logger">The underlying base logger.</param> + public StartupLogger(ILogger logger) + { + BaseLogger = logger; + } + + /// <summary> + /// Initializes a new instance of the <see cref="StartupLogger"/> class. + /// </summary> + /// <param name="logger">The underlying base logger.</param> + /// <param name="topic">The group for this logger.</param> + internal StartupLogger(ILogger logger, StartupLogTopic? topic) : this(logger) + { + _topic = topic; + } + + internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance); + + /// <inheritdoc/> + public StartupLogTopic? Topic => _topic; + + /// <summary> + /// Gets or Sets the underlying base logger. + /// </summary> + protected ILogger BaseLogger { get; set; } + + /// <inheritdoc/> + public IStartupLogger BeginGroup(FormattableString logEntry) + { + return new StartupLogger(BaseLogger, AddToTopic(logEntry)); + } + + /// <inheritdoc/> + public IStartupLogger With(ILogger logger) + { + return new StartupLogger(logger, Topic); + } + + /// <inheritdoc/> + public IStartupLogger<TCategory> With<TCategory>(ILogger logger) + { + return new StartupLogger<TCategory>(logger, Topic); + } + + /// <inheritdoc/> + public IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry) + { + return new StartupLogger<TCategory>(BaseLogger, AddToTopic(logEntry)); + } + + private StartupLogTopic AddToTopic(FormattableString logEntry) + { + var startupEntry = new StartupLogTopic() + { + Content = logEntry.ToString(CultureInfo.InvariantCulture), + DateOfCreation = DateTimeOffset.Now + }; + + if (Topic is null) + { + SetupServer.LogQueue?.Enqueue(startupEntry); + } + else + { + Topic.Children.Add(startupEntry); + } + + return startupEntry; + } + + /// <inheritdoc/> + public IDisposable? BeginScope<TState>(TState state) + where TState : notnull + { + return null; + } + + /// <inheritdoc/> + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + /// <inheritdoc/> + public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) + { + if (BaseLogger.IsEnabled(logLevel)) + { + // if enabled allow the base logger also to receive the message + BaseLogger.Log(logLevel, eventId, state, exception, formatter); + } + + var startupEntry = new StartupLogTopic() + { + LogLevel = logLevel, + Content = formatter(state, exception), + DateOfCreation = DateTimeOffset.Now + }; + + if (Topic is null) + { + SetupServer.LogQueue?.Enqueue(startupEntry); + } + else + { + Topic.Children.Add(startupEntry); + } + } +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs new file mode 100644 index 000000000..ada4b56a7 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs @@ -0,0 +1,18 @@ +using System; +using System.Globalization; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Jellyfin.Server.ServerSetupApp; + +internal static class StartupLoggerExtensions +{ + public static IServiceCollection RegisterStartupLogger(this IServiceCollection services) + { + return services + .AddTransient<IStartupLogger, StartupLogger<Startup>>() + .AddTransient(typeof(IStartupLogger<>), typeof(StartupLogger<>)); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs new file mode 100644 index 000000000..64da0ce88 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs @@ -0,0 +1,56 @@ +using System; +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// Startup logger for usage with DI that utilises an underlying logger from the DI. +/// </summary> +/// <typeparam name="TCategory">The category of the underlying logger.</typeparam> +#pragma warning disable SA1649 // File name should match first type name +public class StartupLogger<TCategory> : StartupLogger, IStartupLogger<TCategory> +#pragma warning restore SA1649 // File name should match first type name +{ + /// <summary> + /// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class. + /// </summary> + /// <param name="logger">The injected base logger.</param> + public StartupLogger(ILogger<TCategory> logger) : base(logger) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class. + /// </summary> + /// <param name="logger">The underlying base logger.</param> + /// <param name="groupEntry">The group for this logger.</param> + internal StartupLogger(ILogger logger, StartupLogTopic? groupEntry) : base(logger, groupEntry) + { + } + + IStartupLogger<TCategory> IStartupLogger<TCategory>.BeginGroup(FormattableString logEntry) + { + var startupEntry = new StartupLogTopic() + { + Content = logEntry.ToString(CultureInfo.InvariantCulture), + DateOfCreation = DateTimeOffset.Now + }; + + if (Topic is null) + { + SetupServer.LogQueue?.Enqueue(startupEntry); + } + else + { + Topic.Children.Add(startupEntry); + } + + return new StartupLogger<TCategory>(BaseLogger, startupEntry); + } + + IStartupLogger<TCategory> IStartupLogger<TCategory>.With(ILogger logger) + { + return new StartupLogger<TCategory>(logger, Topic); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html new file mode 100644 index 000000000..890a77619 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -0,0 +1,235 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8" /> + <title> + {{#IF isInReportingMode}} + ❌ + {{/IF}} + Jellyfin Startup + </title> + <style> + * { + font-family: sans-serif; + } + + .flex-row { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + align-content: normal; + } + + .flex-col { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + align-content: normal; + } + + header { + height: 5rem; + width: 100%; + } + + header svg { + height: 3rem; + width: 9rem; + margin-right: 1rem; + } + + /* ol.action-list { + list-style-type: none; + position: relative; + } */ + + ol.action-list * { + font-family: monospace; + font-weight: 300; + font-size: clamp(18px, 100vw / var(--width), 20px); + font-feature-settings: 'onum', 'pnum'; + line-height: 1.8; + -webkit-text-size-adjust: none; + } + + /* + ol.action-list li { + padding-top: .5rem; + } + + ol.action-list li::before { + position: absolute; + left: -0.8em; + font-size: 1.1em; + } */ + + /* Attribution as heavily inspired by: https://iamkate.com/code/tree-views/ */ + .action-list { + --spacing: 1.4rem; + --radius: 14px; + } + + .action-list li { + display: block; + position: relative; + padding-left: calc(2 * var(--spacing) - var(--radius) - 1px); + } + + .action-list ul { + margin-left: calc(var(--radius) - var(--spacing)); + padding-left: 0; + } + + .action-list ul li { + border-left: 2px solid #ddd; + } + + .action-list ul li:last-child { + border-color: transparent; + } + + .action-list ul li::before { + content: ''; + display: block; + position: absolute; + top: calc(var(--spacing) / -2); + left: -2px; + width: calc(var(--spacing) + 2px); + height: calc(var(--spacing) + 1px); + border: solid #ddd; + border-width: 0 0 2px 2px; + } + + .action-list summary { + display: block; + cursor: pointer; + } + + .action-list summary::marker, + .action-list summary::-webkit-details-marker { + display: none; + } + + .action-list summary:focus { + outline: none; + } + + .action-list summary:focus-visible { + outline: 1px dotted #000; + } + + .action-list li::after, + .action-list summary::before { + content: ''; + display: block; + position: absolute; + top: calc(var(--spacing) / 2 - var(--radius) + 4px); + left: calc(var(--spacing) - var(--radius) - -5px); + } + + .action-list summary::before { + z-index: 1; + /* background: #696 url('expand-collapse.svg') 0 0; */ + } + + .action-list details[open]>summary::before { + background-position: calc(-2 * var(--radius)) 0; + } + + .action-list li.danger-item::after, + .action-list li.danger-strong-item::after { + content: '❌'; + } + + ol.action-list li span.danger-strong-item { + text-decoration-style: solid; + text-decoration-color: red; + text-decoration-line: underline; + } + + ol.action-list li.warn-item::after { + content: '⚠️'; + } + + ol.action-list li.success-item::after { + content: '✅'; + } + + ol.action-list li.info-item::after { + content: '🔹'; + } + + /* End Attribution */ + </style> +</head> + +<body> + <div> + <header class="flex-row"> + + {{^IF isInReportingMode}} + <p>Jellyfin Server {{version}} still starting. Please wait.</p> + {{#ELSE}} + <p>Jellyfin Server has encountered an error and was not able to start.</p> + {{/ELSE}} + {{/IF}} + + {{#IF localNetworkRequest}} + <p style="margin-left: 1rem;">You can download the current log file <a href='/startup/logger' + target="_blank">here</a>.</p> + {{/IF}} + </header> + + {{#DECLARE LogEntry |--}} + {{#LET children = Children}} + <li class="{{FormatLogLevel(children).ToString()}}-item"> + {{--| #IF children.Count > 0}} + <details open> + <summary>{{DateOfCreation}} - {{Content}}</summary> + <ul class="action-list"> + {{--| #EACH children.Reverse() |-}} + {{#IMPORT 'LogEntry'}} + {{--| /EACH |-}} + </ul> + </details> + {{--| #ELSE |-}} + <span class="{{FormatLogLevel(children).ToString()}}-item">{{DateOfCreation}} - {{Content}}</span> + {{--| /ELSE |--}} + {{--| /IF |-}} + </li> + {{--| /DECLARE}} + + {{#IF localNetworkRequest}} + <div class="flex-col"> + <ol class="action-list"> + {{#FOREACH log IN logs.Reverse()}} + {{#IMPORT 'LogEntry' #WITH log}} + {{/FOREACH}} + </ol> + </div> + {{#ELSE}} + {{#IF networkManagerReady}} + <p>Please visit this page from your local network to view detailed startup logs.</p> + {{#ELSE}} + <p>Initializing network settings. Please wait.</p> + {{/ELSE}} + {{/IF}} + {{/ELSE}} + {{/IF}} + </div> +</body> + +{{^IF isInReportingMode}} +<script> + setTimeout(() => { + window.location.reload(); + }, {{ retryValue.TotalMilliseconds }}); +</script> +{{/IF}} + +</html> |
