diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 9bf927bb95..a10be76e05 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -215,8 +215,11 @@ internal class JellyfinMigrationService logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + var migrationIndex = 0; foreach (var item in migrations) { + // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. + SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length)); var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); try { diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index af0d424aad..12f92efb35 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -133,10 +133,12 @@ namespace Jellyfin.Server } } + SetupServer.ReportActivity(StartupActivity.CheckingStorage); StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger()).BeginGroup($"Storage Check")); StartupHelpers.PerformStaticInitialization(); + SetupServer.ReportActivity(StartupActivity.Initializing); await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false); do @@ -195,6 +197,7 @@ namespace Jellyfin.Server if (!string.IsNullOrWhiteSpace(_restoreFromBackup)) { + SetupServer.ReportActivity(StartupActivity.RestoringBackup); await appHost.ServiceProvider.GetService()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false); _restoreFromBackup = null; _restartOnShutdown = true; @@ -202,9 +205,13 @@ namespace Jellyfin.Server } var jellyfinMigrationService = ActivatorUtilities.CreateInstance(appHost.ServiceProvider); + SetupServer.ReportActivity(StartupActivity.PreparingMigrations); await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); + // "Preparing migrations" carries through the DB read; per-migration progress is reported + // as "Running migration X of Y" from inside the step once the pending set is known. await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + SetupServer.ReportActivity(StartupActivity.InitializingServices); await appHost.InitializeServices(startupConfig).ConfigureAwait(false); _appHost = appHost; diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 37bb1abe71..598de5aa5f 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -14,7 +14,6 @@ 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; @@ -25,9 +24,6 @@ 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; @@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable private readonly ILoggerFactory _loggerFactory; private readonly IConfiguration _startupConfiguration; private readonly ServerConfigurationManager _configurationManager; - private IRenderer? _startupUiRenderer; + private static volatile string _currentActivity = StartupActivity.Starting; + private StartupUiRenderer? _startupUiRenderer; private IHost? _startupServer; private bool _disposed; private bool _isUnhealthy; @@ -76,6 +73,12 @@ public sealed class SetupServer : IDisposable internal static ConcurrentQueue? LogQueue { get; set; } = new(); + /// + /// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the + /// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details. + /// + internal static string CurrentActivity => _currentActivity; + /// /// Gets a value indicating whether Startup server is currently running. /// @@ -87,64 +90,9 @@ public sealed class SetupServer : IDisposable /// A Task. 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( - (Version version, int arg) => - { - // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually. - return version.ToString(arg); - }, - "ToString") - .WithFormatter( - (StartupLogTopic logEntry, IEnumerable children) => - { - if (children.Any()) - { - var maxLevel = logEntry.LogLevel; - var stack = new Stack(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(); + ReportActivity(StartupActivity.Starting); + _startupUiRenderer = await StartupUiRenderer.CreateAsync( + Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); ThrowIfDisposed(); var retryAfterValue = TimeSpan.FromSeconds(5); @@ -257,13 +205,14 @@ public sealed class SetupServer : IDisposable new Dictionary() { { "isInReportingMode", _isUnhealthy }, + { "currentActivity", CurrentActivity }, { "retryValue", retryAfterValue }, { "version", 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)) + context.Response.BodyWriter.AsStream()) .ConfigureAwait(false); }); }); @@ -309,6 +258,16 @@ public sealed class SetupServer : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); } + /// + /// Reports the current startup activity shown to all clients in the startup UI header. + /// Only pass generic, non-identifying text from . + /// + /// A generic description such as . + internal static void ReportActivity(string activity) + { + _currentActivity = activity; + } + internal void SoftStop() { _isUnhealthy = true; diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs new file mode 100644 index 0000000000..888cc617d4 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace Jellyfin.Server.ServerSetupApp; + +/// +/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup. +/// These are shown in the always-visible header of the startup UI to unauthenticated clients, so every +/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.). +/// +public static class StartupActivity +{ + /// The default state before any work has been reported. + public const string Starting = "Starting up"; + + /// Validating that the configured storage locations are usable. + public const string CheckingStorage = "Checking storage"; + + /// Bringing up the migration subsystem and running early startup checks. + public const string Initializing = "Initializing server"; + + /// Preparing the system for migrations (e.g. taking safety backups). + public const string PreparingMigrations = "Preparing migrations"; + + /// Restoring from a backup. + public const string RestoringBackup = "Restoring backup"; + + /// Bringing up core services and plugins. + public const string InitializingServices = "Initializing services"; + + /// Running the final startup tasks. + public const string FinishingStartup = "Finishing startup"; + + /// + /// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed. + /// + /// The 1-based index of the migration currently running. + /// The total number of migrations in this batch. + /// A generic progress description. + public static string Migration(int current, int total) + => string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total); +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs new file mode 100644 index 0000000000..db07b9d8c1 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Morestachio; +using Morestachio.Framework.IO.SingleStream; +using Morestachio.Rendering; + +namespace Jellyfin.Server.ServerSetupApp; + +/// +/// Compiles and renders the startup UI Morestachio template. +/// Shared by the live and the standalone startup UI preview tool so both +/// exercise the exact same template and formatters. +/// +public sealed class StartupUiRenderer +{ + private readonly IRenderer _renderer; + + private StartupUiRenderer(IRenderer renderer) + { + _renderer = renderer; + } + + /// + /// Compiles the startup UI template located at . + /// + /// The full path to the index.mstemplate.html template. + /// A ready to use . + public static async Task CreateAsync(string templatePath) + { + var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false); + var renderer = (await ParserOptionsBuilder.New() + .WithTemplate(fileTemplate) + .WithFormatter( + (Version version, int arg) => + { + // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually. + return version.ToString(arg); + }, + "ToString") + .WithFormatter( + (StartupLogTopic logEntry, IEnumerable children) => + { + if (children.Any()) + { + var maxLevel = logEntry.LogLevel; + var stack = new Stack(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(); + + return new StartupUiRenderer(renderer); + } + + /// + /// Renders the template with the provided model into the target stream. + /// + /// The values made available to the template. + /// The stream the rendered HTML is written to. + /// A Task. + public Task RenderAsync(IDictionary model, Stream output) + { + return _renderer.RenderAsync( + model, + new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions)); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index 5706ce1fac..9c12762c31 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -1,189 +1,469 @@ - + + + + {{#IF isInReportingMode}} ❌ {{/IF}} - Jellyfin Startup + Jellyfin -
-
+
+ - {{^IF isInReportingMode}} -

Jellyfin Server {{version.ToString(2)}} still starting. Please wait.

- {{#ELSE}} -

Jellyfin Server {{version.ToString(2)}} has encountered an error and was not able to start.

- {{/ELSE}} - {{/IF}} - - {{#IF localNetworkRequest}} -

You can download the current log file here.

- {{/IF}} -
+ {{^IF isInReportingMode}} +
+ +

Jellyfin is still starting. Please wait… {{currentActivity}}

+
+ {{#ELSE}} +
+

Jellyfin has encountered an error and was not able to start.

+
+ {{/ELSE}} + {{/IF}} {{#DECLARE LogEntry |--}} {{#LET children = Children}} @@ -192,7 +472,7 @@
{{DateOfCreation}} - {{Content}}
    - {{--| #EACH children.Reverse() |-}} + {{--| #EACH children |-}} {{#IMPORT 'LogEntry'}} {{--| /EACH |-}}
@@ -205,31 +485,175 @@ {{--| /DECLARE}} {{#IF localNetworkRequest}} -
-
    - {{#FOREACH log IN logs.Reverse()}} - {{#IMPORT 'LogEntry' #WITH log}} - {{/FOREACH}} -
+
+
+

Startup log

+ Download logs +
+
+
    + {{#FOREACH log IN logs}} + {{#IMPORT 'LogEntry' #WITH log}} + {{/FOREACH}} +
+
- {{#ELSE}} - {{#IF networkManagerReady}} -

Please visit this page from your local network to view detailed startup logs.

- {{#ELSE}} -

Initializing network settings. Please wait.

- {{/ELSE}} - {{/IF}} - {{/ELSE}} {{/IF}}
+ -{{^IF isInReportingMode}} - -{{/IF}} -