From 0046adda29b4d99cbdf6b215d14539c08e96ab3e Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Sun, 21 Jun 2026 23:09:55 -0400 Subject: [PATCH 1/5] Restyle the startup UI and add a generic startup activity line Restyle the startup/migration holding page to match the Jellyfin dark theme, with the inline wordmark logo, a gradient spinner and a recolored startup log tree, and move the Morestachio template rendering into a reusable StartupUiRenderer. Add a curated, non-identifying "current activity" line to the always-visible header (for example "Initializing server" or "Running migration X of Y"), reported from the startup flow and the migration service so it never leaks server details to unauthenticated clients. Move the log download into a "Download logs" link in the log panel header, and show only the header, with no log hints, to non-local clients. --- .../Migrations/JellyfinMigrationService.cs | 3 + Jellyfin.Server/Program.cs | 6 + Jellyfin.Server/ServerSetupApp/SetupServer.cs | 87 ++----- .../ServerSetupApp/StartupActivity.cs | 44 ++++ .../ServerSetupApp/StartupUiRenderer.cs | 109 ++++++++ .../ServerSetupApp/index.mstemplate.html | 244 +++++++++++++----- 6 files changed, 366 insertions(+), 127 deletions(-) create mode 100644 Jellyfin.Server/ServerSetupApp/StartupActivity.cs create mode 100644 Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs 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..2b20ee4314 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,12 @@ namespace Jellyfin.Server } var jellyfinMigrationService = ActivatorUtilities.CreateInstance(appHost.ServiceProvider); + SetupServer.ReportActivity(StartupActivity.PreparingMigrations); await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); + SetupServer.ReportActivity(StartupActivity.ApplyingMigrations); 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..893272590e 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..5baaf1d40a --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -0,0 +1,44 @@ +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"; + + /// Applying database/system migrations without a known count. + public const string ApplyingMigrations = "Applying 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..38cb5cea9e 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -1,8 +1,10 @@ - + + + {{#IF isInReportingMode}} ❌ @@ -10,8 +12,36 @@ Jellyfin Startup -
-
-
+ - {{^IF isInReportingMode}} -
- -

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

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

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

-
- {{/ELSE}} - {{/IF}} -
+

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}} @@ -350,7 +431,7 @@
    @@ -422,7 +503,7 @@ var doc = new DOMParser().parseFromString(html, 'text/html'); // Startup failed and the page switched to the error view -> reload to render it. - if (doc.querySelector('.status-card.is-error')) { + if (doc.querySelector('.status.is-error')) { window.location.reload(); return; } From 31070e8208e973728b4bfe470cbbd6ca1d14c048 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Thu, 25 Jun 2026 00:42:31 -0400 Subject: [PATCH 5/5] Add a cancelable redirect handoff and drop the transitional migration status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the server finishes starting, show "Jellyfin started successfully" with a 5-second "Redirecting in N…" countdown and a Cancel button instead of reloading immediately. Cancel stops the countdown and the background refresh so the startup output can be reviewed, and offers a "Continue to Jellyfin" button to reload manually. The buttons use the web client's emby-button styling. Also drop the transitional "Applying migrations" activity: it only showed briefly while the pending migration set was read, or for the whole step when nothing was pending, so startup now goes from "Preparing migrations" straight into "Running migration X of Y". --- Jellyfin.Server/Program.cs | 3 +- Jellyfin.Server/ServerSetupApp/SetupServer.cs | 2 +- .../ServerSetupApp/StartupActivity.cs | 3 - .../ServerSetupApp/index.mstemplate.html | 127 +++++++++++++++++- 4 files changed, 127 insertions(+), 8 deletions(-) diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 2b20ee4314..12f92efb35 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -207,7 +207,8 @@ namespace Jellyfin.Server var jellyfinMigrationService = ActivatorUtilities.CreateInstance(appHost.ServiceProvider); SetupServer.ReportActivity(StartupActivity.PreparingMigrations); await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); - SetupServer.ReportActivity(StartupActivity.ApplyingMigrations); + // "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); diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 893272590e..598de5aa5f 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -262,7 +262,7 @@ public sealed class SetupServer : IDisposable /// 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 . + /// A generic description such as . internal static void ReportActivity(string activity) { _currentActivity = activity; diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs index 5baaf1d40a..888cc617d4 100644 --- a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -21,9 +21,6 @@ public static class StartupActivity /// Preparing the system for migrations (e.g. taking safety backups). public const string PreparingMigrations = "Preparing migrations"; - /// Applying database/system migrations without a known count. - public const string ApplyingMigrations = "Applying migrations"; - /// Restoring from a backup. public const string RestoringBackup = "Restoring backup"; diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index cc37a8b4dd..9c12762c31 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -126,6 +126,63 @@ color: var(--jf-error); } + /* Buttons — matching the web client's emby-button styles. */ + .jf-button { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + margin: 0; + padding: 0.9em 1em; + border: 0; + border-radius: 0.2em; + font-family: inherit; + font-size: inherit; + font-weight: 600; + line-height: 1.35; + cursor: pointer; + outline: none; + text-decoration: none; + transition: 0.2s; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + } + + .jf-button-primary { + background: var(--jf-primary); + color: rgba(0, 0, 0, 0.87); + } + + .jf-button-primary:hover, + .jf-button-primary:focus { + background: var(--jf-primary-dark); + } + + .jf-button-secondary { + background: #424242; + color: var(--jf-text-secondary); + } + + .jf-button-secondary:hover, + .jf-button-secondary:focus { + background: #616161; + } + + /* Redirect countdown shown once the server is ready. */ + .redirect-bar { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 1em; + margin-bottom: 1.5em; + text-align: center; + } + + .redirect-countdown { + color: var(--jf-text-secondary); + } + /* Material (MDL) spinner — the same one the web client uses while loading. */ .mdl-spinner { position: relative; @@ -491,8 +548,8 @@ function poll() { fetch(window.location.href, { cache: 'no-store' }).then(function (resp) { if (resp.ok) { - // The real server is now answering (HTTP 200) -> load the actual app. - window.location.reload(); + // The real server is now answering (HTTP 200) -> offer to continue to the app. + onServerReady(); return null; } return resp.text(); @@ -530,7 +587,71 @@ }); } - setInterval(poll, intervalMs); + // The server finished starting. Stop polling and present a cancelable countdown so the + // user can either ride the redirect into the app or stay to review the startup output. + function onServerReady() { + clearInterval(pollTimer); + + var status = document.querySelector('.status'); + var statusText = document.querySelector('.status-text'); + var spinner = document.querySelector('.mdl-spinner'); + if (spinner) { + spinner.style.display = 'none'; + } + if (status) { + status.classList.add('is-success'); + } + if (statusText) { + statusText.textContent = 'Jellyfin started successfully.'; + } + if (!status) { + window.location.reload(); + return; + } + + var bar = document.createElement('div'); + bar.className = 'redirect-bar'; + var countdownText = document.createElement('span'); + countdownText.className = 'redirect-countdown'; + var cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.className = 'jf-button jf-button-secondary'; + cancelButton.textContent = 'Cancel'; + bar.appendChild(countdownText); + bar.appendChild(cancelButton); + status.insertAdjacentElement('afterend', bar); + + var remaining = 5; + function renderCountdown() { + countdownText.textContent = 'Redirecting in ' + remaining + '…'; + } + renderCountdown(); + var countdown = setInterval(function () { + remaining -= 1; + if (remaining <= 0) { + clearInterval(countdown); + window.location.reload(); + return; + } + renderCountdown(); + }, 1000); + + // Cancel stops both the redirect and the refreshing, and offers a manual continue. + cancelButton.addEventListener('click', function () { + clearInterval(countdown); + bar.innerHTML = ''; + var continueButton = document.createElement('button'); + continueButton.type = 'button'; + continueButton.className = 'jf-button jf-button-primary'; + continueButton.textContent = 'Continue to Jellyfin'; + continueButton.addEventListener('click', function () { + window.location.reload(); + }); + bar.appendChild(continueButton); + }); + } + + var pollTimer = setInterval(poll, intervalMs); })();