diff options
| author | JPVenson <github@jpb.email> | 2025-06-05 17:59:11 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-05 08:59:11 -0600 |
| commit | 88332e89c458266bc073d3304eafcb23603f15fa (patch) | |
| tree | f067cdc997219a1a15e11356ef7aa31b466d7bba /Jellyfin.Server/ServerSetupApp | |
| parent | a3578caa8c71c84b278e18a07ebc157bcf04c687 (diff) | |
Feature/version check in library migration (#14105)
Diffstat (limited to 'Jellyfin.Server/ServerSetupApp')
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/IStartupLogger.cs | 25 | ||||
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/SetupServer.cs | 174 | ||||
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/StartupLogger.cs | 102 | ||||
| -rw-r--r-- | Jellyfin.Server/ServerSetupApp/index.mstemplate.html | 225 |
4 files changed, 517 insertions, 9 deletions
diff --git a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs new file mode 100644 index 000000000..2c2ef05f8 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs @@ -0,0 +1,25 @@ +using System; +using Morestachio.Helper.Logging; +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> + /// 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); +} diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 7ab5defc8..751cf7f42 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; @@ -10,6 +13,7 @@ using Jellyfin.Networking.Manager; 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 +24,9 @@ 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; namespace Jellyfin.Server.ServerSetupApp; @@ -34,8 +41,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,13 +71,73 @@ public sealed class SetupServer : IDisposable _configurationManager.RegisterConfiguration<NetworkConfigurationFactory>(); } + internal static ConcurrentQueue<StartupLogEntry>? 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("ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); + _startupUiRenderer = (await ParserOptionsBuilder.New() + .WithTemplate(fileTemplate) + .WithFormatter( + (StartupLogEntry logEntry, IEnumerable<StartupLogEntry> children) => + { + if (children.Any()) + { + var maxLevel = logEntry.LogLevel; + var stack = new Stack<StartupLogEntry>(children); + + while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) != 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(); + var retryAfterValue = TimeSpan.FromSeconds(5); _startupServer = Host.CreateDefaultBuilder() .UseConsoleLifetime() .ConfigureServices(serv => @@ -140,7 +209,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 +227,30 @@ 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 }, + { "logs", startupLogEntries }, + { "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 +266,7 @@ public sealed class SetupServer : IDisposable } await _startupServer.StopAsync().ConfigureAwait(false); + IsAlive = false; } /// <inheritdoc/> @@ -203,6 +279,9 @@ public sealed class SetupServer : IDisposable _disposed = true; _startupServer?.Dispose(); + IsAlive = false; + LogQueue?.Clear(); + LogQueue = null; } private void ThrowIfDisposed() @@ -210,11 +289,88 @@ 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 + }); + } + } + + internal class StartupLogEntry + { + public LogLevel LogLevel { get; set; } + + public string? Content { get; set; } + + public DateTimeOffset DateOfCreation { get; set; } + + public List<StartupLogEntry> Children { get; set; } = []; + } } diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs new file mode 100644 index 000000000..2b86dc0c1 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Jellyfin.Server.Migrations.Routines; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <inheritdoc/> +public class StartupLogger : IStartupLogger +{ + private readonly SetupServer.StartupLogEntry? _groupEntry; + + /// <summary> + /// Initializes a new instance of the <see cref="StartupLogger"/> class. + /// </summary> + public StartupLogger() + { + Loggers = []; + } + + /// <summary> + /// Initializes a new instance of the <see cref="StartupLogger"/> class. + /// </summary> + private StartupLogger(SetupServer.StartupLogEntry? groupEntry) : this() + { + _groupEntry = groupEntry; + } + + internal static IStartupLogger Logger { get; } = new StartupLogger(); + + private List<ILogger> Loggers { get; set; } + + /// <inheritdoc/> + public IStartupLogger BeginGroup(FormattableString logEntry) + { + var startupEntry = new SetupServer.StartupLogEntry() + { + Content = logEntry.ToString(CultureInfo.InvariantCulture), + DateOfCreation = DateTimeOffset.Now + }; + + if (_groupEntry is null) + { + SetupServer.LogQueue?.Enqueue(startupEntry); + } + else + { + _groupEntry.Children.Add(startupEntry); + } + + return new StartupLogger(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) + { + foreach (var item in Loggers.Where(e => e.IsEnabled(logLevel))) + { + item.Log(logLevel, eventId, state, exception, formatter); + } + + var startupEntry = new SetupServer.StartupLogEntry() + { + LogLevel = logLevel, + Content = formatter(state, exception), + DateOfCreation = DateTimeOffset.Now + }; + + if (_groupEntry is null) + { + SetupServer.LogQueue?.Enqueue(startupEntry); + } + else + { + _groupEntry.Children.Add(startupEntry); + } + } + + /// <inheritdoc/> + public IStartupLogger With(ILogger logger) + { + return new StartupLogger(_groupEntry) + { + Loggers = [.. Loggers, logger] + }; + } +} diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html new file mode 100644 index 000000000..747835b2a --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -0,0 +1,225 @@ +<!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 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}} + + <div class="flex-col"> + <ol class="action-list"> + {{#FOREACH log IN logs.Reverse()}} + {{#IMPORT 'LogEntry' #WITH log}} + {{/FOREACH}} + </ol> + </div> + </div> +</body> + +{{^IF isInReportingMode}} +<script> + setTimeout(() => { + window.location.reload(); + }, {{ retryValue.TotalMilliseconds }}); +</script> +{{/IF}} + +</html> |
