├── dashboard.png ├── progress.png ├── hangfire.console.png ├── src └── Hangfire.Console │ ├── InternalsVisibleTo.cs │ ├── Storage │ ├── ConsoleIdExtensions.cs │ ├── ConsoleExpirationTransaction.cs │ ├── IConsoleStorage.cs │ └── ConsoleStorage.cs │ ├── Monitoring │ ├── LineType.cs │ ├── TextLineDto.cs │ ├── IConsoleApi.cs │ ├── LineDto.cs │ ├── ProgressBarDto.cs │ └── ConsoleApi.cs │ ├── Progress │ ├── IProgressBar.cs │ ├── NoOpProgressBar.cs │ ├── DefaultProgressBar.cs │ └── ProgressEnumerable.cs │ ├── JobStorageExtensions.cs │ ├── Support │ ├── HtmlHelperExtensions.cs │ ├── CompositeDispatcher.cs │ ├── EmbeddedResourceDispatcher.cs │ └── RouteCollectionExtensions.cs │ ├── Dashboard │ ├── DynamicJsDispatcher.cs │ ├── ConsoleDispatcher.cs │ ├── DynamicCssDispatcher.cs │ ├── ProcessingStateRenderer.cs │ ├── JobProgressDispatcher.cs │ └── ConsoleRenderer.cs │ ├── Serialization │ ├── ConsoleLine.cs │ └── ConsoleId.cs │ ├── ConsoleOptions.cs │ ├── States │ └── ConsoleApplyStateFilter.cs │ ├── Server │ ├── ConsoleServerFilter.cs │ └── ConsoleContext.cs │ ├── GlobalConfigurationExtensions.cs │ ├── ConsoleTextColor.cs │ ├── Resources │ ├── resize.min.js │ ├── style.css │ └── script.js │ ├── EnumerableExtensions.cs │ ├── Hangfire.Console.csproj │ └── ConsoleExtensions.cs ├── tests └── Hangfire.Console.Tests │ ├── Support │ └── It2.cs │ ├── Dashboard │ ├── JobProgressDispatcherFacts.cs │ └── ConsoleRendererFacts.cs │ ├── Hangfire.Console.Tests.csproj │ ├── Serialization │ └── ConsoleIdFacts.cs │ ├── Storage │ ├── ConsoleExpirationTransactionFacts.cs │ └── ConsoleStorageFacts.cs │ ├── ConsoleExtensionsFacts.cs │ ├── Server │ ├── ConsoleContextFacts.cs │ └── ConsoleServerFilterFacts.cs │ └── States │ └── ConsoleApplyStateFilterFacts.cs ├── .github └── workflows │ └── dotnet.yml ├── Directory.Build.props ├── LICENSE.md ├── Hangfire.Console.sln ├── .gitignore └── README.md /dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityStream/Hangfire.Console/HEAD/dashboard.png -------------------------------------------------------------------------------- /progress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityStream/Hangfire.Console/HEAD/progress.png -------------------------------------------------------------------------------- /hangfire.console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IdentityStream/Hangfire.Console/HEAD/hangfire.console.png -------------------------------------------------------------------------------- /src/Hangfire.Console/InternalsVisibleTo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("Hangfire.Console.Tests")] 4 | [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] 5 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Storage/ConsoleIdExtensions.cs: -------------------------------------------------------------------------------- 1 | using Hangfire.Console.Serialization; 2 | 3 | namespace Hangfire.Console.Storage; 4 | 5 | internal static class ConsoleIdExtensions 6 | { 7 | public static string GetSetKey(this ConsoleId consoleId) => $"console:{consoleId}"; 8 | 9 | public static string GetHashKey(this ConsoleId consoleId) => $"console:refs:{consoleId}"; 10 | 11 | public static string GetOldConsoleKey(this ConsoleId consoleId) => consoleId.ToString(); 12 | } 13 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Monitoring/LineType.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace Hangfire.Console.Monitoring; 4 | 5 | /// 6 | /// Console line type 7 | /// 8 | [PublicAPI] 9 | public enum LineType 10 | { 11 | /// 12 | /// Any type (only for filtering) 13 | /// 14 | Any, 15 | 16 | /// 17 | /// Textual line 18 | /// 19 | Text, 20 | 21 | /// 22 | /// Progress bar 23 | /// 24 | ProgressBar 25 | } 26 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Progress/IProgressBar.cs: -------------------------------------------------------------------------------- 1 | namespace Hangfire.Console.Progress; 2 | 3 | /// 4 | /// Progress bar line inside console. 5 | /// 6 | public interface IProgressBar 7 | { 8 | /// 9 | /// Updates a value of a progress bar. 10 | /// 11 | /// New value 12 | void SetValue(int value); 13 | 14 | /// 15 | /// Updates a value of a progress bar. 16 | /// 17 | /// New value 18 | void SetValue(double value); 19 | } 20 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/Support/It2.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Linq.Expressions; 5 | using Moq; 6 | 7 | // ReSharper disable once CheckNamespace 8 | namespace Hangfire.Console.Tests; 9 | 10 | public static class It2 11 | { 12 | public static IEnumerable AnyIs(Expression> match) 13 | { 14 | var predicate = match.Compile(); 15 | 16 | return Match.Create( 17 | values => values.Any(predicate), 18 | () => AnyIs(match)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Progress/NoOpProgressBar.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Hangfire.Console.Progress; 4 | 5 | /// 6 | /// No-op progress bar. 7 | /// 8 | internal class NoOpProgressBar : IProgressBar 9 | { 10 | public void SetValue(int value) 11 | { 12 | SetValue((double)value); 13 | } 14 | 15 | public void SetValue(double value) 16 | { 17 | value = Math.Round(value, 1); 18 | 19 | if (value < 0 || value > 100) 20 | { 21 | throw new ArgumentOutOfRangeException(nameof(value), "Value should be in range 0..100"); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | name: .NET 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v3 18 | with: 19 | dotnet-version: 7.0.x 20 | 21 | - name: Restore dependencies 22 | run: dotnet restore 23 | 24 | - name: Build 25 | run: dotnet build --no-restore 26 | 27 | - name: Test 28 | run: dotnet test --no-build --verbosity normal 29 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Monitoring/TextLineDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Console.Serialization; 3 | using JetBrains.Annotations; 4 | 5 | namespace Hangfire.Console.Monitoring; 6 | 7 | /// 8 | /// Text console line 9 | /// 10 | [PublicAPI] 11 | public class TextLineDto : LineDto 12 | { 13 | internal TextLineDto(ConsoleLine line, DateTime referenceTimestamp) : base(line, referenceTimestamp) 14 | { 15 | Text = line.Message; 16 | } 17 | 18 | /// 19 | public override LineType Type => LineType.Text; 20 | 21 | /// 22 | /// Returns text for the console line 23 | /// 24 | public string Text { get; } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/Dashboard/JobProgressDispatcherFacts.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Hangfire.Console.Dashboard; 3 | using Newtonsoft.Json; 4 | using Xunit; 5 | 6 | namespace Hangfire.Console.Tests.Dashboard; 7 | 8 | public class JobProgressDispatcherFacts 9 | { 10 | [Fact] 11 | public void JsonSettings_PreservesDictionaryKeyCase() 12 | { 13 | var result = new Dictionary 14 | { 15 | ["AAA"] = 1.0, 16 | ["Bbb"] = 2.0, 17 | ["ccc"] = 3.0 18 | }; 19 | 20 | var json = JsonConvert.SerializeObject(result, JobProgressDispatcher.JsonSettings); 21 | 22 | Assert.Equal("{\"AAA\":1.0,\"Bbb\":2.0,\"ccc\":3.0}", json); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 11 5 | enable 6 | 7 | 8 | 9 | 10 | all 11 | runtime; build; native; contentfiles; analyzers; buildtransitive 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Hangfire.Console/JobStorageExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Console.Monitoring; 3 | using JetBrains.Annotations; 4 | 5 | namespace Hangfire.Console; 6 | 7 | /// 8 | /// Provides extension methods for . 9 | /// 10 | [PublicAPI] 11 | public static class JobStorageExtensions 12 | { 13 | /// 14 | /// Returns an instance of . 15 | /// 16 | /// Job storage instance 17 | /// Console API instance 18 | public static IConsoleApi GetConsoleApi(this JobStorage storage) 19 | { 20 | if (storage == null) 21 | { 22 | throw new ArgumentNullException(nameof(storage)); 23 | } 24 | 25 | return new ConsoleApi(storage.GetConnection()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Monitoring/IConsoleApi.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Hangfire.Annotations; 4 | using Hangfire.Storage.Monitoring; 5 | 6 | namespace Hangfire.Console.Monitoring; 7 | 8 | /// 9 | /// Console monitoring API interface 10 | /// 11 | [PublicAPI] 12 | public interface IConsoleApi : IDisposable 13 | { 14 | /// 15 | /// Returns lines for the console session 16 | /// 17 | /// Job identifier 18 | /// Time the processing was started (like, ) 19 | /// Type of lines to return 20 | /// List of console lines 21 | IList GetLines(string jobId, DateTime timestamp, LineType type = LineType.Any); 22 | } 23 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Monitoring/LineDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Console.Serialization; 3 | using JetBrains.Annotations; 4 | 5 | namespace Hangfire.Console.Monitoring; 6 | 7 | /// 8 | /// Base class for console lines 9 | /// 10 | [PublicAPI] 11 | public abstract class LineDto 12 | { 13 | internal LineDto(ConsoleLine line, DateTime referenceTimestamp) 14 | { 15 | Timestamp = referenceTimestamp.AddSeconds(line.TimeOffset); 16 | Color = line.TextColor; 17 | } 18 | 19 | /// 20 | /// Returns type of this line 21 | /// 22 | public abstract LineType Type { get; } 23 | 24 | /// 25 | /// Returns timestamp for the console line 26 | /// 27 | public DateTime Timestamp { get; } 28 | 29 | /// 30 | /// Returns HTML color for the console line 31 | /// 32 | public string? Color { get; internal set; } 33 | } 34 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Support/HtmlHelperExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | 4 | // ReSharper disable once CheckNamespace 5 | namespace Hangfire.Dashboard.Extensions; 6 | 7 | /// 8 | /// Provides extension methods for . 9 | /// 10 | internal static class HtmlHelperExtensions 11 | { 12 | // ReSharper disable once InconsistentNaming 13 | private static readonly FieldInfo _page = typeof(HtmlHelper).GetTypeInfo().GetDeclaredField(nameof(_page)); 14 | 15 | /// 16 | /// Returs a associated with . 17 | /// 18 | /// Helper 19 | public static RazorPage GetPage(this HtmlHelper helper) 20 | { 21 | if (helper == null) 22 | { 23 | throw new ArgumentNullException(nameof(helper)); 24 | } 25 | 26 | return (RazorPage)_page.GetValue(helper); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/Hangfire.Console.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net7.0 4 | false 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Alexey Skalozub 4 | Copyright (c) 2023 IdentityStream AS 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Monitoring/ProgressBarDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Hangfire.Console.Serialization; 4 | using JetBrains.Annotations; 5 | 6 | namespace Hangfire.Console.Monitoring; 7 | 8 | /// 9 | /// Progress bar line 10 | /// 11 | [PublicAPI] 12 | public class ProgressBarDto : LineDto 13 | { 14 | internal ProgressBarDto(ConsoleLine line, DateTime referenceTimestamp) : base(line, referenceTimestamp) 15 | { 16 | Id = int.Parse(line.Message, CultureInfo.InvariantCulture); 17 | Name = line.ProgressName; 18 | Progress = line.ProgressValue!.Value; 19 | } 20 | 21 | /// 22 | public override LineType Type => LineType.ProgressBar; 23 | 24 | /// 25 | /// Returns identifier for a progress bar 26 | /// 27 | public int Id { get; } 28 | 29 | /// 30 | /// Returns optional name for a progress bar 31 | /// 32 | public string? Name { get; } 33 | 34 | /// 35 | /// Returns progress value for a progress bar 36 | /// 37 | public double Progress { get; internal set; } 38 | } 39 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Dashboard/DynamicJsDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | using Hangfire.Dashboard; 5 | 6 | namespace Hangfire.Console.Dashboard; 7 | 8 | /// 9 | /// Dispatcher for configured script 10 | /// 11 | internal class DynamicJsDispatcher : IDashboardDispatcher 12 | { 13 | private readonly ConsoleOptions _options; 14 | 15 | public DynamicJsDispatcher(ConsoleOptions? options) 16 | { 17 | _options = options ?? throw new ArgumentNullException(nameof(options)); 18 | } 19 | 20 | public Task Dispatch(DashboardContext context) 21 | { 22 | var builder = new StringBuilder(); 23 | 24 | builder.Append("(function (hangfire) {") 25 | .Append("hangfire.config = hangfire.config || {};") 26 | .AppendFormat("hangfire.config.consolePollInterval = {0};", _options.PollInterval) 27 | .AppendFormat("hangfire.config.consolePollUrl = '{0}/console/';", context.Request.PathBase) 28 | .Append("})(window.Hangfire = window.Hangfire || {});") 29 | .AppendLine(); 30 | 31 | return context.Response.WriteAsync(builder.ToString()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Dashboard/ConsoleDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | using Hangfire.Console.Serialization; 5 | using Hangfire.Console.Storage; 6 | using Hangfire.Dashboard; 7 | 8 | namespace Hangfire.Console.Dashboard; 9 | 10 | /// 11 | /// Provides incremental updates for a console. 12 | /// 13 | internal class ConsoleDispatcher : IDashboardDispatcher 14 | { 15 | public Task Dispatch(DashboardContext context) 16 | { 17 | if (context == null) 18 | { 19 | throw new ArgumentNullException(nameof(context)); 20 | } 21 | 22 | var consoleId = ConsoleId.Parse(context.UriMatch.Groups[1].Value); 23 | 24 | var startArg = context.Request.GetQuery("start"); 25 | 26 | // try to parse offset at which we should start returning requests 27 | if (string.IsNullOrEmpty(startArg) || !int.TryParse(startArg, out var start)) 28 | { 29 | // if not provided or invalid, fetch records from the very start 30 | start = 0; 31 | } 32 | 33 | var buffer = new StringBuilder(); 34 | using (var storage = new ConsoleStorage(context.Storage.GetConnection())) 35 | { 36 | ConsoleRenderer.RenderLineBuffer(buffer, storage, consoleId, start); 37 | } 38 | 39 | context.Response.ContentType = "text/html"; 40 | return context.Response.WriteAsync(buffer.ToString()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Serialization/ConsoleLine.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Hangfire.Console.Serialization; 4 | 5 | internal class ConsoleLine 6 | { 7 | /// 8 | /// Time offset since console timestamp in fractional seconds 9 | /// 10 | [JsonProperty("t", Required = Required.Always)] 11 | public double TimeOffset { get; set; } 12 | 13 | /// 14 | /// True if is a Hash reference. 15 | /// 16 | [JsonProperty("r", DefaultValueHandling = DefaultValueHandling.Ignore)] 17 | public bool IsReference { get; set; } 18 | 19 | /// 20 | /// Message text, or message reference, or progress bar id 21 | /// 22 | [JsonProperty("s", Required = Required.Always)] 23 | public required string Message { get; set; } 24 | 25 | /// 26 | /// Text color for this message 27 | /// 28 | [JsonProperty("c", DefaultValueHandling = DefaultValueHandling.Ignore)] 29 | public string? TextColor { get; set; } 30 | 31 | /// 32 | /// Value update for a progress bar 33 | /// 34 | [JsonProperty("p", DefaultValueHandling = DefaultValueHandling.Ignore)] 35 | public double? ProgressValue { get; set; } 36 | 37 | /// 38 | /// Optional name for a progress bar 39 | /// 40 | [JsonProperty("n", DefaultValueHandling = DefaultValueHandling.Ignore)] 41 | public string? ProgressName { get; set; } 42 | } 43 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Support/CompositeDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | // ReSharper disable once CheckNamespace 6 | namespace Hangfire.Dashboard.Extensions; 7 | 8 | /// 9 | /// Dispatcher that combines output from several other dispatchers. 10 | /// Used internally by . 11 | /// 12 | internal class CompositeDispatcher : IDashboardDispatcher 13 | { 14 | private readonly List _dispatchers; 15 | 16 | public CompositeDispatcher(params IDashboardDispatcher[] dispatchers) 17 | { 18 | _dispatchers = new List(dispatchers); 19 | } 20 | 21 | public async Task Dispatch(DashboardContext context) 22 | { 23 | if (context == null) 24 | { 25 | throw new ArgumentNullException(nameof(context)); 26 | } 27 | 28 | if (_dispatchers.Count == 0) 29 | { 30 | throw new InvalidOperationException("CompositeDispatcher should contain at least one dispatcher"); 31 | } 32 | 33 | foreach (var dispatcher in _dispatchers) 34 | { 35 | await dispatcher.Dispatch(context); 36 | } 37 | } 38 | 39 | public void AddDispatcher(IDashboardDispatcher dispatcher) 40 | { 41 | if (dispatcher == null) 42 | { 43 | throw new ArgumentNullException(nameof(dispatcher)); 44 | } 45 | 46 | _dispatchers.Add(dispatcher); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Dashboard/DynamicCssDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | using Hangfire.Dashboard; 5 | 6 | namespace Hangfire.Console.Dashboard; 7 | 8 | /// 9 | /// Dispatcher for configured styles 10 | /// 11 | internal class DynamicCssDispatcher : IDashboardDispatcher 12 | { 13 | private readonly ConsoleOptions _options; 14 | 15 | public DynamicCssDispatcher(ConsoleOptions? options) 16 | { 17 | _options = options ?? throw new ArgumentNullException(nameof(options)); 18 | } 19 | 20 | public Task Dispatch(DashboardContext context) 21 | { 22 | var builder = new StringBuilder(); 23 | 24 | builder.AppendLine(".console, .console .line-buffer {") 25 | .Append(" background-color: ").Append(_options.BackgroundColor).AppendLine(";") 26 | .Append(" color: ").Append(_options.TextColor).AppendLine(";") 27 | .AppendLine("}"); 28 | 29 | builder.AppendLine(".console .line > span[data-moment-title] {") 30 | .Append(" color: ").Append(_options.TimestampColor).AppendLine(";") 31 | .AppendLine("}"); 32 | 33 | builder.AppendLine(".console .line > a, .console.line > a:visited, .console.line > a:hover {") 34 | .Append(" color: ").Append(_options.TextColor).AppendLine(";") 35 | .AppendLine("}"); 36 | 37 | builder.AppendLine(".console .line.pb > .pv:before {") 38 | .Append(" color: ").Append(_options.BackgroundColor).AppendLine(";") 39 | .AppendLine("}"); 40 | 41 | return context.Response.WriteAsync(builder.ToString()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Hangfire.Console/ConsoleOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using JetBrains.Annotations; 3 | 4 | namespace Hangfire.Console; 5 | 6 | /// 7 | /// Configuration options for console. 8 | /// 9 | [PublicAPI] 10 | public class ConsoleOptions 11 | { 12 | /// 13 | /// Gets or sets expiration time for console messages. 14 | /// 15 | public TimeSpan ExpireIn { get; set; } = TimeSpan.FromDays(1); 16 | 17 | /// 18 | /// Gets or sets if console messages should follow the same retention policy as the parent job. 19 | /// When set to true, parameter is ignored. 20 | /// 21 | public bool FollowJobRetentionPolicy { get; set; } = true; 22 | 23 | /// 24 | /// Gets or sets console poll interval (in ms). 25 | /// 26 | public int PollInterval { get; set; } = 1000; 27 | 28 | /// 29 | /// Gets or sets background color for console. 30 | /// 31 | public string BackgroundColor { get; set; } = "#0d3163"; 32 | 33 | /// 34 | /// Gets or sets text color for console. 35 | /// 36 | public string TextColor { get; set; } = "#ffffff"; 37 | 38 | /// 39 | /// Gets or sets timestamp color for console. 40 | /// 41 | public string TimestampColor { get; set; } = "#00aad7"; 42 | 43 | internal void Validate(string paramName) 44 | { 45 | if (ExpireIn < TimeSpan.FromMinutes(1)) 46 | { 47 | throw new ArgumentException("ExpireIn shouldn't be less than 1 minute", paramName); 48 | } 49 | 50 | if (PollInterval < 100) 51 | { 52 | throw new ArgumentException("PollInterval shouldn't be less than 100 ms", paramName); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Storage/ConsoleExpirationTransaction.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Console.Serialization; 3 | using Hangfire.Storage; 4 | 5 | namespace Hangfire.Console.Storage; 6 | 7 | internal class ConsoleExpirationTransaction : IDisposable 8 | { 9 | private readonly JobStorageTransaction _transaction; 10 | 11 | public ConsoleExpirationTransaction(JobStorageTransaction transaction) 12 | { 13 | _transaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); 14 | } 15 | 16 | public void Dispose() 17 | { 18 | _transaction.Dispose(); 19 | } 20 | 21 | public void Expire(ConsoleId consoleId, TimeSpan expireIn) 22 | { 23 | if (consoleId == null) 24 | { 25 | throw new ArgumentNullException(nameof(consoleId)); 26 | } 27 | 28 | _transaction.ExpireSet(consoleId.GetSetKey(), expireIn); 29 | _transaction.ExpireHash(consoleId.GetHashKey(), expireIn); 30 | 31 | // After upgrading to Hangfire.Console version with new keys, 32 | // there may be existing background jobs with console attached 33 | // to the previous keys. We should expire them also. 34 | _transaction.ExpireSet(consoleId.GetOldConsoleKey(), expireIn); 35 | _transaction.ExpireHash(consoleId.GetOldConsoleKey(), expireIn); 36 | } 37 | 38 | public void Persist(ConsoleId consoleId) 39 | { 40 | if (consoleId == null) 41 | { 42 | throw new ArgumentNullException(nameof(consoleId)); 43 | } 44 | 45 | _transaction.PersistSet(consoleId.GetSetKey()); 46 | _transaction.PersistHash(consoleId.GetHashKey()); 47 | 48 | // After upgrading to Hangfire.Console version with new keys, 49 | // there may be existing background jobs with console attached 50 | // to the previous keys. We should persist them also. 51 | _transaction.PersistSet(consoleId.GetOldConsoleKey()); 52 | _transaction.PersistHash(consoleId.GetOldConsoleKey()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Progress/DefaultProgressBar.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using Hangfire.Console.Serialization; 4 | using Hangfire.Console.Server; 5 | 6 | namespace Hangfire.Console.Progress; 7 | 8 | /// 9 | /// Default progress bar. 10 | /// 11 | internal class DefaultProgressBar : IProgressBar 12 | { 13 | private readonly ConsoleContext _context; 14 | 15 | private readonly string _progressBarId; 16 | 17 | private readonly int _digits; 18 | 19 | private string? _color; 20 | 21 | private string? _name; 22 | 23 | private double _value; 24 | 25 | internal DefaultProgressBar(ConsoleContext context, string progressBarId, string? name, string? color, int digits) 26 | { 27 | if (string.IsNullOrEmpty(progressBarId)) 28 | { 29 | throw new ArgumentNullException(nameof(progressBarId)); 30 | } 31 | 32 | _context = context ?? throw new ArgumentNullException(nameof(context)); 33 | _progressBarId = progressBarId; 34 | _digits = digits; 35 | _name = string.IsNullOrEmpty(name) ? null : name; 36 | _color = string.IsNullOrEmpty(color) ? null : color; 37 | _value = -1; 38 | } 39 | 40 | public void SetValue(int value) 41 | { 42 | SetValue((double)value); 43 | } 44 | 45 | public void SetValue(double value) 46 | { 47 | value = Math.Round(value, _digits); 48 | 49 | if (value < 0 || value > 100) 50 | { 51 | throw new ArgumentOutOfRangeException(nameof(value), "Value should be in range 0..100"); 52 | } 53 | 54 | // ReSharper disable once CompareOfFloatsByEqualityOperator 55 | if (Interlocked.Exchange(ref _value, value) == value) 56 | { 57 | return; 58 | } 59 | 60 | _context.AddLine(new ConsoleLine { Message = _progressBarId, ProgressName = _name, ProgressValue = value, TextColor = _color }); 61 | 62 | _name = null; // write name only once 63 | _color = null; // write color only once 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Hangfire.Console/States/ConsoleApplyStateFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Hangfire.Common; 4 | using Hangfire.Console.Serialization; 5 | using Hangfire.Console.Storage; 6 | using Hangfire.States; 7 | using Hangfire.Storage; 8 | 9 | namespace Hangfire.Console.States; 10 | 11 | internal class ConsoleApplyStateFilter : IApplyStateFilter 12 | { 13 | private readonly ConsoleOptions _options; 14 | 15 | public ConsoleApplyStateFilter(ConsoleOptions? options) 16 | { 17 | _options = options ?? throw new ArgumentNullException(nameof(options)); 18 | } 19 | 20 | public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction transaction) 21 | { 22 | if (!_options.FollowJobRetentionPolicy) 23 | { 24 | // Console sessions use their own expiration timeout. 25 | // Do not expire here, will be expired by ConsoleServerFilter. 26 | return; 27 | } 28 | 29 | var jobDetails = context.Storage.GetMonitoringApi().JobDetails(context.BackgroundJob.Id); 30 | if (jobDetails == null || jobDetails.History == null) 31 | { 32 | // WTF?! 33 | return; 34 | } 35 | 36 | var expiration = new ConsoleExpirationTransaction((JobStorageTransaction)transaction); 37 | 38 | foreach (var state in jobDetails.History.Where(x => x.StateName == ProcessingState.StateName)) 39 | { 40 | var consoleId = new ConsoleId(context.BackgroundJob.Id, JobHelper.DeserializeDateTime(state.Data["StartedAt"])); 41 | 42 | if (context.NewState.IsFinal) 43 | { 44 | // Job in final state is a subject for expiration. 45 | // To keep storage clean, its console sessions should also be expired. 46 | expiration.Expire(consoleId, context.JobExpirationTimeout); 47 | } 48 | else 49 | { 50 | // Job will be persisted, so should its console sessions. 51 | expiration.Persist(consoleId); 52 | } 53 | } 54 | } 55 | 56 | public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction transaction) { } 57 | } 58 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Storage/IConsoleStorage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Hangfire.Console.Serialization; 4 | using Hangfire.Storage; 5 | 6 | namespace Hangfire.Console.Storage; 7 | 8 | /// 9 | /// Abstraction over Hangfire's storage API 10 | /// 11 | internal interface IConsoleStorage : IDisposable 12 | { 13 | /// 14 | /// Returns number of lines for console. 15 | /// 16 | /// Console identifier 17 | int GetLineCount(ConsoleId consoleId); 18 | 19 | /// 20 | /// Returns range of lines for console. 21 | /// 22 | /// Console identifier 23 | /// Start index (inclusive) 24 | /// End index (inclusive) 25 | IEnumerable GetLines(ConsoleId consoleId, int start, int end); 26 | 27 | /// 28 | /// Initializes console. 29 | /// 30 | /// Console identifier 31 | void InitConsole(ConsoleId consoleId); 32 | 33 | /// 34 | /// Adds line to console. 35 | /// 36 | /// Console identifier 37 | /// Line to add 38 | void AddLine(ConsoleId consoleId, ConsoleLine line); 39 | 40 | /// 41 | /// Returns current expiration TTL for console. 42 | /// If console is not expired, returns negative . 43 | /// 44 | /// Console identifier 45 | TimeSpan GetConsoleTtl(ConsoleId consoleId); 46 | 47 | /// 48 | /// Expire data for console. 49 | /// 50 | /// Console identifier 51 | /// Expiration time 52 | void Expire(ConsoleId consoleId, TimeSpan expireIn); 53 | 54 | /// 55 | /// Returns last (current) state of the console's parent job. 56 | /// 57 | /// Console identifier 58 | StateData? GetState(ConsoleId consoleId); 59 | } 60 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Support/EmbeddedResourceDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using System.Threading.Tasks; 4 | 5 | // ReSharper disable once CheckNamespace 6 | namespace Hangfire.Dashboard.Extensions; 7 | 8 | /// 9 | /// Alternative to built-in EmbeddedResourceDispatcher, which (for some reasons) is not public. 10 | /// 11 | internal class EmbeddedResourceDispatcher : IDashboardDispatcher 12 | { 13 | private readonly Assembly _assembly; 14 | 15 | private readonly string? _contentType; 16 | 17 | private readonly string _resourceName; 18 | 19 | public EmbeddedResourceDispatcher(Assembly assembly, string resourceName, string? contentType = null) 20 | { 21 | if (string.IsNullOrEmpty(resourceName)) 22 | { 23 | throw new ArgumentNullException(nameof(resourceName)); 24 | } 25 | 26 | _assembly = assembly ?? throw new ArgumentNullException(nameof(assembly)); 27 | _resourceName = resourceName; 28 | _contentType = contentType; 29 | } 30 | 31 | public Task Dispatch(DashboardContext context) 32 | { 33 | if (string.IsNullOrEmpty(_contentType)) 34 | { 35 | return WriteResourceAsync(context.Response, _assembly, _resourceName); 36 | } 37 | 38 | var contentType = context.Response.ContentType; 39 | 40 | if (string.IsNullOrEmpty(contentType)) 41 | { 42 | // content type not yet set 43 | context.Response.ContentType = _contentType; 44 | } 45 | else if (contentType != _contentType) 46 | { 47 | // content type already set, but doesn't match ours 48 | throw new InvalidOperationException($"ContentType '{_contentType}' conflicts with '{context.Response.ContentType}'"); 49 | } 50 | 51 | return WriteResourceAsync(context.Response, _assembly, _resourceName); 52 | } 53 | 54 | private static async Task WriteResourceAsync(DashboardResponse response, Assembly assembly, string resourceName) 55 | { 56 | using var stream = assembly.GetManifestResourceStream(resourceName); 57 | 58 | if (stream == null) 59 | { 60 | throw new ArgumentException($@"Resource '{resourceName}' not found in assembly {assembly}."); 61 | } 62 | 63 | await stream.CopyToAsync(response.Body); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Hangfire.Console.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26430.12 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{436BABB0-DBF7-4301-BC7F-CBB9D09FAD36}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{657DF223-42EC-4765-990B-334C62F602EA}" 9 | ProjectSection(SolutionItems) = preProject 10 | dashboard.png = dashboard.png 11 | README.md = README.md 12 | Directory.Build.props = Directory.Build.props 13 | hangfire.console.png = hangfire.console.png 14 | LICENSE.md = LICENSE.md 15 | EndProjectSection 16 | EndProject 17 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CBD2EEC6-826C-4477-B8F7-EADE2D944B74}" 18 | EndProject 19 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hangfire.Console", "src\Hangfire.Console\Hangfire.Console.csproj", "{C18CBFCC-955B-4B21-B698-851CC56364AF}" 20 | EndProject 21 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hangfire.Console.Tests", "tests\Hangfire.Console.Tests\Hangfire.Console.Tests.csproj", "{D5068E09-A43C-4B05-8068-C50E9497EB25}" 22 | EndProject 23 | Global 24 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 25 | Debug|Any CPU = Debug|Any CPU 26 | Release|Any CPU = Release|Any CPU 27 | EndGlobalSection 28 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 29 | {C18CBFCC-955B-4B21-B698-851CC56364AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {C18CBFCC-955B-4B21-B698-851CC56364AF}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {C18CBFCC-955B-4B21-B698-851CC56364AF}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {C18CBFCC-955B-4B21-B698-851CC56364AF}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {D5068E09-A43C-4B05-8068-C50E9497EB25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {D5068E09-A43C-4B05-8068-C50E9497EB25}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {D5068E09-A43C-4B05-8068-C50E9497EB25}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {D5068E09-A43C-4B05-8068-C50E9497EB25}.Release|Any CPU.Build.0 = Release|Any CPU 37 | EndGlobalSection 38 | GlobalSection(SolutionProperties) = preSolution 39 | HideSolutionNode = FALSE 40 | EndGlobalSection 41 | GlobalSection(NestedProjects) = preSolution 42 | {C18CBFCC-955B-4B21-B698-851CC56364AF} = {436BABB0-DBF7-4301-BC7F-CBB9D09FAD36} 43 | {D5068E09-A43C-4B05-8068-C50E9497EB25} = {CBD2EEC6-826C-4477-B8F7-EADE2D944B74} 44 | EndGlobalSection 45 | EndGlobal 46 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/Serialization/ConsoleIdFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Console.Serialization; 3 | using Xunit; 4 | 5 | namespace Hangfire.Console.Tests.Serialization; 6 | 7 | public class ConsoleIdFacts 8 | { 9 | private static readonly DateTime UnixEpoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); 10 | 11 | [Fact] 12 | public void Ctor_ThrowsAnException_WhenJobIdIsNull() 13 | { 14 | Assert.Throws("jobId", () => new ConsoleId(null!, DateTime.UtcNow)); 15 | } 16 | 17 | [Fact] 18 | public void Ctor_ThrowsAnException_WhenJobIdIsEmpty() 19 | { 20 | Assert.Throws("jobId", () => new ConsoleId("", DateTime.UtcNow)); 21 | } 22 | 23 | [Fact] 24 | public void Ctor_ThrowsAnException_WhenTimestampBeforeEpoch() 25 | { 26 | Assert.Throws("timestamp", 27 | () => new ConsoleId("123", UnixEpoch)); 28 | } 29 | 30 | [Fact] 31 | public void Ctor_ThrowsAnException_WhenTimestampAfterEpochPlusMaxInt() 32 | { 33 | Assert.Throws("timestamp", 34 | () => new ConsoleId("123", UnixEpoch.AddSeconds(int.MaxValue).AddSeconds(1))); 35 | } 36 | 37 | [Fact] 38 | public void SerializesCorrectly() 39 | { 40 | var x = new ConsoleId("123", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 41 | var s = x.ToString(); 42 | Assert.Equal("00cdb7af151123", s); 43 | } 44 | 45 | [Fact] 46 | public void IgnoresFractionalMilliseconds() 47 | { 48 | var x = new ConsoleId("123", UnixEpoch.AddMilliseconds(3.0)); 49 | var y = new ConsoleId("123", UnixEpoch.AddMilliseconds(3.0215)); 50 | Assert.Equal(x, y); 51 | } 52 | 53 | [Fact] 54 | public void Parse_ThrowsAnException_WhenValueIsNull() 55 | { 56 | Assert.Throws("value", () => ConsoleId.Parse(null!)); 57 | } 58 | 59 | [Fact] 60 | public void Parse_ThrowsAnException_WhenValueIsTooShort() 61 | { 62 | Assert.Throws("value", () => ConsoleId.Parse("00cdb7af1")); 63 | } 64 | 65 | [Fact] 66 | public void Parse_ThrowsAnException_WhenValueIsInvalid() 67 | { 68 | Assert.Throws("value", () => ConsoleId.Parse("00x00y00z001")); 69 | } 70 | 71 | [Fact] 72 | public void DeserializesCorrectly() 73 | { 74 | var x = ConsoleId.Parse("00cdb7af151123"); 75 | Assert.Equal("123", x.JobId); 76 | Assert.Equal(new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc), x.DateValue); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Server/ConsoleServerFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Common; 3 | using Hangfire.Console.Serialization; 4 | using Hangfire.Console.Storage; 5 | using Hangfire.Server; 6 | using Hangfire.States; 7 | 8 | namespace Hangfire.Console.Server; 9 | 10 | /// 11 | /// Server filter to initialize and cleanup console environment. 12 | /// 13 | internal class ConsoleServerFilter : IServerFilter 14 | { 15 | private readonly ConsoleOptions _options; 16 | 17 | public ConsoleServerFilter(ConsoleOptions? options) 18 | { 19 | _options = options ?? throw new ArgumentNullException(nameof(options)); 20 | } 21 | 22 | public void OnPerforming(PerformingContext filterContext) 23 | { 24 | var state = filterContext.Connection.GetStateData(filterContext.BackgroundJob.Id); 25 | 26 | if (state == null) 27 | { 28 | // State for job not found? 29 | return; 30 | } 31 | 32 | if (!string.Equals(state.Name, ProcessingState.StateName, StringComparison.OrdinalIgnoreCase)) 33 | { 34 | // Not in Processing state? Something is really off... 35 | return; 36 | } 37 | 38 | var startedAt = JobHelper.DeserializeDateTime(state.Data["StartedAt"]); 39 | 40 | filterContext.Items["ConsoleContext"] = new ConsoleContext( 41 | new ConsoleId(filterContext.BackgroundJob.Id, startedAt), 42 | new ConsoleStorage(filterContext.Connection)); 43 | } 44 | 45 | public void OnPerformed(PerformedContext filterContext) 46 | { 47 | if (_options.FollowJobRetentionPolicy) 48 | { 49 | // Console sessions follow parent job expiration. 50 | // Normally, ConsoleApplyStateFilter will update expiration for all consoles, no extra work needed. 51 | 52 | // If the running job is deleted from the Dashboard, ConsoleApplyStateFilter will be called immediately, 53 | // but the job will continue running, unless IJobCancellationToken.ThrowIfCancellationRequested() is met. 54 | // If anything is written to console after the job was deleted, it won't get a correct expiration assigned. 55 | 56 | // Need to re-apply expiration to prevent those records from becoming eternal garbage. 57 | ConsoleContext.FromPerformContext(filterContext)?.FixExpiration(); 58 | } 59 | else 60 | { 61 | ConsoleContext.FromPerformContext(filterContext)?.Expire(_options.ExpireIn); 62 | } 63 | 64 | // remove console context to prevent further writes from filters 65 | filterContext.Items.Remove("ConsoleContext"); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Monitoring/ConsoleApi.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Hangfire.Console.Serialization; 4 | using Hangfire.Console.Storage; 5 | using Hangfire.Storage; 6 | 7 | namespace Hangfire.Console.Monitoring; 8 | 9 | internal class ConsoleApi : IConsoleApi 10 | { 11 | private readonly IConsoleStorage _storage; 12 | 13 | public ConsoleApi(IStorageConnection connection) 14 | { 15 | if (connection == null) 16 | { 17 | throw new ArgumentNullException(nameof(connection)); 18 | } 19 | 20 | _storage = new ConsoleStorage(connection); 21 | } 22 | 23 | public void Dispose() 24 | { 25 | _storage.Dispose(); 26 | } 27 | 28 | public IList GetLines(string jobId, DateTime timestamp, LineType type = LineType.Any) 29 | { 30 | var consoleId = new ConsoleId(jobId, timestamp); 31 | 32 | var count = _storage.GetLineCount(consoleId); 33 | var result = new List(count); 34 | 35 | if (count > 0) 36 | { 37 | Dictionary? progressBars = null; 38 | 39 | foreach (var entry in _storage.GetLines(consoleId, 0, count)) 40 | { 41 | if (entry.ProgressValue.HasValue) 42 | { 43 | if (type == LineType.Text) 44 | { 45 | continue; 46 | } 47 | 48 | // aggregate progress value updates into single record 49 | 50 | if (progressBars != null) 51 | { 52 | if (progressBars.TryGetValue(entry.Message, out var prev)) 53 | { 54 | prev.Progress = entry.ProgressValue.Value; 55 | prev.Color = entry.TextColor; 56 | continue; 57 | } 58 | } 59 | else 60 | { 61 | progressBars = new Dictionary(); 62 | } 63 | 64 | var line = new ProgressBarDto(entry, timestamp); 65 | 66 | progressBars.Add(entry.Message, line); 67 | result.Add(line); 68 | } 69 | else 70 | { 71 | if (type == LineType.ProgressBar) 72 | { 73 | continue; 74 | } 75 | 76 | result.Add(new TextLineDto(entry, timestamp)); 77 | } 78 | } 79 | } 80 | 81 | return result; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Dashboard/ProcessingStateRenderer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Hangfire.Common; 5 | using Hangfire.Console.Serialization; 6 | using Hangfire.Console.Storage; 7 | using Hangfire.Dashboard; 8 | using Hangfire.Dashboard.Extensions; 9 | 10 | namespace Hangfire.Console.Dashboard; 11 | 12 | /// 13 | /// Replacement renderer for Processing state. 14 | /// 15 | internal class ProcessingStateRenderer 16 | { 17 | // ReSharper disable once NotAccessedField.Local 18 | private readonly ConsoleOptions _options; 19 | 20 | public ProcessingStateRenderer(ConsoleOptions? options) 21 | { 22 | _options = options ?? throw new ArgumentNullException(nameof(options)); 23 | } 24 | 25 | public NonEscapedString Render(HtmlHelper helper, IDictionary stateData) 26 | { 27 | var builder = new StringBuilder(); 28 | 29 | builder.Append("
"); 30 | 31 | if (stateData.TryGetValue("ServerId", out var serverId) || stateData.TryGetValue("ServerName", out serverId)) 32 | { 33 | builder.Append("
Server:
"); 34 | builder.Append($"
{helper.ServerId(serverId)}
"); 35 | } 36 | 37 | if (stateData.TryGetValue("WorkerId", out var workerId)) 38 | { 39 | builder.Append("
Worker:
"); 40 | builder.Append($"
{workerId.Substring(0, 8)}
"); 41 | } 42 | else if (stateData.TryGetValue("WorkerNumber", out var workerNumber)) 43 | { 44 | builder.Append("
Worker:
"); 45 | builder.Append($"
#{workerNumber}
"); 46 | } 47 | 48 | builder.Append("
"); 49 | 50 | var page = helper.GetPage(); 51 | if (!page.RequestPath.StartsWith("/jobs/details/")) 52 | { 53 | return new NonEscapedString(builder.ToString()); 54 | } 55 | 56 | // We cannot cast page to an internal type JobDetailsPage to get jobId :( 57 | var jobId = page.RequestPath.Substring("/jobs/details/".Length); 58 | 59 | var startedAt = JobHelper.DeserializeDateTime(stateData["StartedAt"]); 60 | var consoleId = new ConsoleId(jobId, startedAt); 61 | 62 | builder.Append("
"); 63 | builder.AppendFormat("
", consoleId); 64 | 65 | using (var storage = new ConsoleStorage(page.Storage.GetConnection())) 66 | { 67 | ConsoleRenderer.RenderLineBuffer(builder, storage, consoleId, 0); 68 | } 69 | 70 | builder.Append("
"); 71 | builder.Append("
"); 72 | 73 | return new NonEscapedString(builder.ToString()); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Dashboard/JobProgressDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Threading.Tasks; 5 | using Hangfire.Common; 6 | using Hangfire.Console.Serialization; 7 | using Hangfire.Console.Storage; 8 | using Hangfire.Dashboard; 9 | using Hangfire.States; 10 | using Newtonsoft.Json; 11 | using Newtonsoft.Json.Serialization; 12 | 13 | namespace Hangfire.Console.Dashboard; 14 | 15 | /// 16 | /// Provides progress for jobs. 17 | /// 18 | internal class JobProgressDispatcher : IDashboardDispatcher 19 | { 20 | internal static readonly JsonSerializerSettings JsonSettings = new() 21 | { 22 | ContractResolver = new DefaultContractResolver() 23 | }; 24 | 25 | // ReSharper disable once NotAccessedField.Local 26 | private readonly ConsoleOptions _options; 27 | 28 | public JobProgressDispatcher(ConsoleOptions? options) 29 | { 30 | _options = options ?? throw new ArgumentNullException(nameof(options)); 31 | } 32 | 33 | public async Task Dispatch(DashboardContext context) 34 | { 35 | if (!"POST".Equals(context.Request.Method, StringComparison.OrdinalIgnoreCase)) 36 | { 37 | context.Response.StatusCode = (int)HttpStatusCode.MethodNotAllowed; 38 | return; 39 | } 40 | 41 | var result = new Dictionary(); 42 | 43 | var jobIds = await context.Request.GetFormValuesAsync("jobs[]"); 44 | if (jobIds.Count > 0) 45 | { 46 | // there are some jobs to process 47 | 48 | using var connection = context.Storage.GetConnection(); 49 | using var storage = new ConsoleStorage(connection); 50 | 51 | foreach (var jobId in jobIds) 52 | { 53 | var state = connection.GetStateData(jobId); 54 | if (state != null && string.Equals(state.Name, ProcessingState.StateName, StringComparison.OrdinalIgnoreCase)) 55 | { 56 | var consoleId = new ConsoleId(jobId, JobHelper.DeserializeDateTime(state.Data["StartedAt"])); 57 | 58 | var progress = storage.GetProgress(consoleId); 59 | if (progress.HasValue) 60 | { 61 | result[jobId] = progress.Value; 62 | } 63 | } 64 | else 65 | { 66 | // return -1 to indicate the job is not in Processing state 67 | result[jobId] = -1; 68 | } 69 | } 70 | } 71 | 72 | var serialized = JsonConvert.SerializeObject(result, JsonSettings); 73 | 74 | context.Response.ContentType = "application/json"; 75 | await context.Response.WriteAsync(serialized); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/Storage/ConsoleExpirationTransactionFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Console.Serialization; 3 | using Hangfire.Console.Storage; 4 | using Hangfire.Storage; 5 | using Moq; 6 | using Xunit; 7 | 8 | namespace Hangfire.Console.Tests.Storage; 9 | 10 | public class ConsoleExpirationTransactionFacts 11 | { 12 | private readonly ConsoleId _consoleId = new("1", DateTime.UtcNow); 13 | 14 | private readonly Mock _transaction = new(); 15 | 16 | [Fact] 17 | public void Ctor_ThrowsException_IfTransactionIsNull() 18 | { 19 | Assert.Throws("transaction", () => new ConsoleExpirationTransaction(null!)); 20 | } 21 | 22 | [Fact] 23 | public void Dispose_ReallyDisposesTransaction() 24 | { 25 | var expiration = new ConsoleExpirationTransaction(_transaction.Object); 26 | 27 | expiration.Dispose(); 28 | 29 | _transaction.Verify(x => x.Dispose()); 30 | } 31 | 32 | [Fact] 33 | public void Expire_ThrowsException_IfConsoleIdIsNull() 34 | { 35 | var expiration = new ConsoleExpirationTransaction(_transaction.Object); 36 | 37 | Assert.Throws("consoleId", () => expiration.Expire(null!, TimeSpan.FromHours(1))); 38 | } 39 | 40 | [Fact] 41 | public void Expire_ExpiresSetAndHash() 42 | { 43 | var expiration = new ConsoleExpirationTransaction(_transaction.Object); 44 | 45 | expiration.Expire(_consoleId, TimeSpan.FromHours(1)); 46 | 47 | _transaction.Verify(x => x.ExpireSet(_consoleId.GetSetKey(), It.IsAny())); 48 | _transaction.Verify(x => x.ExpireHash(_consoleId.GetHashKey(), It.IsAny())); 49 | 50 | // backward compatibility: 51 | _transaction.Verify(x => x.ExpireSet(_consoleId.GetOldConsoleKey(), It.IsAny())); 52 | _transaction.Verify(x => x.ExpireHash(_consoleId.GetOldConsoleKey(), It.IsAny())); 53 | } 54 | 55 | [Fact] 56 | public void Persist_ThrowsException_IfConsoleIdIsNull() 57 | { 58 | var expiration = new ConsoleExpirationTransaction(_transaction.Object); 59 | 60 | Assert.Throws("consoleId", () => expiration.Persist(null!)); 61 | } 62 | 63 | [Fact] 64 | public void Persist_PersistsSetAndHash() 65 | { 66 | var expiration = new ConsoleExpirationTransaction(_transaction.Object); 67 | 68 | expiration.Persist(_consoleId); 69 | 70 | _transaction.Verify(x => x.PersistSet(_consoleId.GetSetKey())); 71 | _transaction.Verify(x => x.PersistHash(_consoleId.GetHashKey())); 72 | 73 | // backward compatibility: 74 | _transaction.Verify(x => x.PersistSet(_consoleId.GetOldConsoleKey())); 75 | _transaction.Verify(x => x.PersistHash(_consoleId.GetOldConsoleKey())); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Hangfire.Console/GlobalConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using Hangfire.Console.Dashboard; 4 | using Hangfire.Console.Server; 5 | using Hangfire.Console.States; 6 | using Hangfire.Dashboard; 7 | using Hangfire.Dashboard.Extensions; 8 | using Hangfire.States; 9 | using JetBrains.Annotations; 10 | 11 | namespace Hangfire.Console; 12 | 13 | /// 14 | /// Provides extension methods to setup Hangfire.Console. 15 | /// 16 | [PublicAPI] 17 | public static class GlobalConfigurationExtensions 18 | { 19 | /// 20 | /// Configures Hangfire to use Console. 21 | /// 22 | /// Global configuration 23 | /// Options for console 24 | public static IGlobalConfiguration UseConsole(this IGlobalConfiguration configuration, ConsoleOptions? options = null) 25 | { 26 | if (configuration == null) 27 | { 28 | throw new ArgumentNullException(nameof(configuration)); 29 | } 30 | 31 | options ??= new ConsoleOptions(); 32 | 33 | options.Validate(nameof(options)); 34 | 35 | if (DashboardRoutes.Routes.Contains("/console/([0-9a-f]{11}.+)")) 36 | { 37 | return configuration; 38 | } 39 | 40 | // register server filter for jobs 41 | GlobalJobFilters.Filters.Add(new ConsoleServerFilter(options)); 42 | 43 | // register apply state filter for jobs 44 | // (context may be altered by other state filters, so make it the very last filter in chain to use final context values) 45 | GlobalJobFilters.Filters.Add(new ConsoleApplyStateFilter(options), int.MaxValue); 46 | 47 | // replace renderer for Processing state 48 | JobHistoryRenderer.Register(ProcessingState.StateName, new ProcessingStateRenderer(options).Render); 49 | 50 | // register dispatchers to serve console data 51 | DashboardRoutes.Routes.Add("/console/progress", new JobProgressDispatcher(options)); 52 | DashboardRoutes.Routes.Add("/console/([0-9a-f]{11}.+)", new ConsoleDispatcher()); 53 | 54 | // register additional dispatchers for CSS and JS 55 | var assembly = typeof(ConsoleRenderer).GetTypeInfo().Assembly; 56 | 57 | var jsPath = DashboardRoutes.Routes.Contains("/js[0-9]+") ? "/js[0-9]+" : "/js[0-9]{3}"; 58 | DashboardRoutes.Routes.Append(jsPath, new EmbeddedResourceDispatcher(assembly, "Hangfire.Console.Resources.resize.min.js")); 59 | DashboardRoutes.Routes.Append(jsPath, new DynamicJsDispatcher(options)); 60 | DashboardRoutes.Routes.Append(jsPath, new EmbeddedResourceDispatcher(assembly, "Hangfire.Console.Resources.script.js")); 61 | 62 | var cssPath = DashboardRoutes.Routes.Contains("/css[0-9]+") ? "/css[0-9]+" : "/css[0-9]{3}"; 63 | DashboardRoutes.Routes.Append(cssPath, new EmbeddedResourceDispatcher(assembly, "Hangfire.Console.Resources.style.css")); 64 | DashboardRoutes.Routes.Append(cssPath, new DynamicCssDispatcher(options)); 65 | 66 | return configuration; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## IDEA Rider 3 | ################# 4 | .idea 5 | .idea.* 6 | 7 | ################# 8 | ## Visual Studio 9 | ################# 10 | 11 | ## Ignore Visual Studio temporary files, build results, and 12 | ## files generated by popular Visual Studio add-ons. 13 | 14 | # User-specific files 15 | *.suo 16 | *.user 17 | *.sln.docstates 18 | 19 | packages/ 20 | 21 | # Build results 22 | 23 | [Bb]uild/ 24 | [Dd]ebug/ 25 | [Rr]elease/ 26 | x64/ 27 | [Bb]in/ 28 | [Oo]bj/ 29 | 30 | # Visual Studio 2015 cache/options directory 31 | .vs/ 32 | 33 | # dotnet 34 | *.lock.json 35 | artifacts/ 36 | 37 | # MSTest test Results 38 | [Tt]est[Rr]esult*/ 39 | [Bb]uild[Ll]og.* 40 | 41 | coverage.xml 42 | 43 | *_i.c 44 | *_p.c 45 | *.ilk 46 | *.meta 47 | *.obj 48 | *.pch 49 | *.pdb 50 | *.pgc 51 | *.pgd 52 | *.rsp 53 | *.sbr 54 | *.tlb 55 | *.tli 56 | *.tlh 57 | *.tmp 58 | *.tmp_proj 59 | *.log 60 | *.vspscc 61 | *.vssscc 62 | .builds 63 | *.pidb 64 | *.log 65 | *.scc 66 | 67 | # Visual C++ cache files 68 | ipch/ 69 | *.aps 70 | *.ncb 71 | *.opensdf 72 | *.sdf 73 | *.cachefile 74 | 75 | # Visual Studio profiler 76 | *.psess 77 | *.vsp 78 | *.vspx 79 | 80 | # Guidance Automation Toolkit 81 | *.gpState 82 | 83 | # ReSharper is a .NET coding add-in 84 | _ReSharper*/ 85 | *.[Rr]e[Ss]harper 86 | 87 | # TeamCity is a build add-in 88 | _TeamCity* 89 | 90 | # DotCover is a Code Coverage Tool 91 | *.dotCover 92 | 93 | # NCrunch 94 | *.ncrunch* 95 | .*crunch*.local.xml 96 | 97 | # Installshield output folder 98 | [Ee]xpress/ 99 | 100 | # DocProject is a documentation generator add-in 101 | DocProject/buildhelp/ 102 | DocProject/Help/*.HxT 103 | DocProject/Help/*.HxC 104 | DocProject/Help/*.hhc 105 | DocProject/Help/*.hhk 106 | DocProject/Help/*.hhp 107 | DocProject/Help/Html2 108 | DocProject/Help/html 109 | 110 | # Click-Once directory 111 | publish/ 112 | 113 | # Publish Web Output 114 | *.Publish.xml 115 | *.pubxml 116 | 117 | # NuGet Packages Directory 118 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 119 | #packages/ 120 | 121 | # Windows Azure Build Output 122 | csx 123 | *.build.csdef 124 | 125 | # Windows Store app package directory 126 | AppPackages/ 127 | 128 | # Others 129 | sql/ 130 | *.Cache 131 | ClientBin/ 132 | [Ss]tyle[Cc]op.* 133 | ~$* 134 | *~ 135 | *.dbmdl 136 | *.[Pp]ublish.xml 137 | *.pfx 138 | *.publishsettings 139 | 140 | # RIA/Silverlight projects 141 | Generated_Code/ 142 | 143 | # Backup & report files from converting an old project file to a newer 144 | # Visual Studio version. Backup files are not needed, because we have git ;-) 145 | _UpgradeReport_Files/ 146 | Backup*/ 147 | UpgradeLog*.XML 148 | UpgradeLog*.htm 149 | 150 | # SQL Server files 151 | App_Data/*.mdf 152 | App_Data/*.ldf 153 | 154 | ############# 155 | ## Windows detritus 156 | ############# 157 | 158 | # Windows image file caches 159 | Thumbs.db 160 | ehthumbs.db 161 | 162 | # Folder config file 163 | Desktop.ini 164 | 165 | # Recycle Bin used on file shares 166 | $RECYCLE.BIN/ 167 | 168 | # Mac crap 169 | .DS_Store 170 | -------------------------------------------------------------------------------- /src/Hangfire.Console/ConsoleTextColor.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace Hangfire.Console; 4 | 5 | /// 6 | /// Text color values 7 | /// 8 | [PublicAPI] 9 | public class ConsoleTextColor 10 | { 11 | /// 12 | /// The color black. 13 | /// 14 | public static readonly ConsoleTextColor Black = new("#000000"); 15 | 16 | /// 17 | /// The color dark blue. 18 | /// 19 | public static readonly ConsoleTextColor DarkBlue = new("#000080"); 20 | 21 | /// 22 | /// The color dark green. 23 | /// 24 | public static readonly ConsoleTextColor DarkGreen = new("#008000"); 25 | 26 | /// 27 | /// The color dark cyan (dark blue-green). 28 | /// 29 | public static readonly ConsoleTextColor DarkCyan = new("#008080"); 30 | 31 | /// 32 | /// The color dark red. 33 | /// 34 | public static readonly ConsoleTextColor DarkRed = new("#800000"); 35 | 36 | /// 37 | /// The color dark magenta (dark purplish-red). 38 | /// 39 | public static readonly ConsoleTextColor DarkMagenta = new("#800080"); 40 | 41 | /// 42 | /// The color dark yellow (ochre). 43 | /// 44 | public static readonly ConsoleTextColor DarkYellow = new("#808000"); 45 | 46 | /// 47 | /// The color gray. 48 | /// 49 | public static readonly ConsoleTextColor Gray = new("#c0c0c0"); 50 | 51 | /// 52 | /// The color dark gray. 53 | /// 54 | public static readonly ConsoleTextColor DarkGray = new("#808080"); 55 | 56 | /// 57 | /// The color blue. 58 | /// 59 | public static readonly ConsoleTextColor Blue = new("#0000ff"); 60 | 61 | /// 62 | /// The color green. 63 | /// 64 | public static readonly ConsoleTextColor Green = new("#00ff00"); 65 | 66 | /// 67 | /// The color cyan (blue-green). 68 | /// 69 | public static readonly ConsoleTextColor Cyan = new("#00ffff"); 70 | 71 | /// 72 | /// The color red. 73 | /// 74 | public static readonly ConsoleTextColor Red = new("#ff0000"); 75 | 76 | /// 77 | /// The color magenta (purplish-red). 78 | /// 79 | public static readonly ConsoleTextColor Magenta = new("#ff00ff"); 80 | 81 | /// 82 | /// The color yellow. 83 | /// 84 | public static readonly ConsoleTextColor Yellow = new("#ffff00"); 85 | 86 | /// 87 | /// The color white. 88 | /// 89 | public static readonly ConsoleTextColor White = new("#ffffff"); 90 | 91 | private readonly string _color; 92 | 93 | private ConsoleTextColor(string color) 94 | { 95 | _color = color; 96 | } 97 | 98 | /// 99 | public override string ToString() => _color; 100 | 101 | /// 102 | /// Implicitly converts to . 103 | /// 104 | public static implicit operator string?(ConsoleTextColor? color) => color?._color; 105 | } 106 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Server/ConsoleContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.Threading; 4 | using Hangfire.Console.Progress; 5 | using Hangfire.Console.Serialization; 6 | using Hangfire.Console.Storage; 7 | using Hangfire.Server; 8 | 9 | namespace Hangfire.Console.Server; 10 | 11 | internal class ConsoleContext 12 | { 13 | private readonly ConsoleId _consoleId; 14 | 15 | private readonly IConsoleStorage _storage; 16 | 17 | private double _lastTimeOffset; 18 | 19 | private int _nextProgressBarId; 20 | 21 | public ConsoleContext(ConsoleId consoleId, IConsoleStorage storage) 22 | { 23 | _consoleId = consoleId ?? throw new ArgumentNullException(nameof(consoleId)); 24 | _storage = storage ?? throw new ArgumentNullException(nameof(storage)); 25 | 26 | _lastTimeOffset = 0; 27 | _nextProgressBarId = 0; 28 | 29 | _storage.InitConsole(_consoleId); 30 | } 31 | 32 | public ConsoleTextColor? TextColor { get; set; } 33 | 34 | public static ConsoleContext? FromPerformContext(PerformContext? context) 35 | { 36 | if (context is null) 37 | { 38 | // PerformContext might be null because of refactoring, or during tests 39 | return null; 40 | } 41 | 42 | if (!context.Items.ContainsKey("ConsoleContext")) 43 | { 44 | // Absence of ConsoleContext means ConsoleServerFilter was not properly added 45 | return null; 46 | } 47 | 48 | return (ConsoleContext)context.Items["ConsoleContext"]; 49 | } 50 | 51 | public void AddLine(ConsoleLine line) 52 | { 53 | if (line == null) 54 | { 55 | throw new ArgumentNullException(nameof(line)); 56 | } 57 | 58 | lock (this) 59 | { 60 | line.TimeOffset = Math.Round((DateTime.UtcNow - _consoleId.DateValue).TotalSeconds, 3); 61 | 62 | if (_lastTimeOffset >= line.TimeOffset) 63 | { 64 | // prevent duplicate lines collapsing 65 | line.TimeOffset = _lastTimeOffset + 0.0001; 66 | } 67 | 68 | _lastTimeOffset = line.TimeOffset; 69 | 70 | _storage.AddLine(_consoleId, line); 71 | } 72 | } 73 | 74 | public void WriteLine(string? value, ConsoleTextColor? color) 75 | { 76 | AddLine(new ConsoleLine { Message = value ?? "", TextColor = color ?? TextColor }); 77 | } 78 | 79 | public IProgressBar WriteProgressBar(string? name, double value, ConsoleTextColor? color) => WriteProgressBar(name, value, color, precision: 0); 80 | 81 | public IProgressBar WriteProgressBar(string? name, double value, ConsoleTextColor? color, int precision) 82 | { 83 | var progressBarId = Interlocked.Increment(ref _nextProgressBarId); 84 | 85 | var progressBar = new DefaultProgressBar(this, progressBarId.ToString(CultureInfo.InvariantCulture), name, color, precision); 86 | 87 | // set initial value 88 | progressBar.SetValue(value); 89 | 90 | return progressBar; 91 | } 92 | 93 | public void Expire(TimeSpan expireIn) 94 | { 95 | _storage.Expire(_consoleId, expireIn); 96 | } 97 | 98 | public void FixExpiration() 99 | { 100 | var ttl = _storage.GetConsoleTtl(_consoleId); 101 | if (ttl <= TimeSpan.Zero) 102 | { 103 | // ConsoleApplyStateFilter not called yet, or current job state is not final. 104 | // Either way, there's no need to expire console here. 105 | return; 106 | } 107 | 108 | _storage.Expire(_consoleId, ttl); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/ConsoleExtensionsFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Common; 3 | using Hangfire.Console.Progress; 4 | using Hangfire.Console.Serialization; 5 | using Hangfire.Console.Server; 6 | using Hangfire.Console.Storage; 7 | using Hangfire.Server; 8 | using Hangfire.Storage; 9 | using Moq; 10 | using Xunit; 11 | 12 | namespace Hangfire.Console.Tests; 13 | 14 | public class ConsoleExtensionsFacts 15 | { 16 | private readonly Mock _cancellationToken; 17 | 18 | private readonly Mock _connection; 19 | 20 | private readonly Mock _jobStorage; 21 | 22 | private readonly Mock _transaction; 23 | 24 | public ConsoleExtensionsFacts() 25 | { 26 | _cancellationToken = new Mock(); 27 | _connection = new Mock(); 28 | _transaction = new Mock(); 29 | _jobStorage = new Mock(); 30 | 31 | _connection.Setup(x => x.CreateWriteTransaction()) 32 | .Returns(_transaction.Object); 33 | } 34 | 35 | [Fact] 36 | public void WriteLine_DoesNotFail_IfContextIsNull() 37 | { 38 | ConsoleExtensions.WriteLine(null!, ""); 39 | 40 | _transaction.Verify(x => x.Commit(), Times.Never); 41 | } 42 | 43 | [Fact] 44 | public void WriteLine_Writes_IfConsoleCreated() 45 | { 46 | var context = CreatePerformContext(); 47 | context.Items["ConsoleContext"] = CreateConsoleContext(context); 48 | 49 | context.WriteLine(""); 50 | 51 | _transaction.Verify(x => x.Commit()); 52 | } 53 | 54 | [Fact] 55 | public void WriteLine_DoesNotFail_IfConsoleNotCreated() 56 | { 57 | var context = CreatePerformContext(); 58 | 59 | context.WriteLine(""); 60 | 61 | _transaction.Verify(x => x.Commit(), Times.Never); 62 | } 63 | 64 | [Fact] 65 | public void WriteProgressBar_ReturnsNoOp_IfContextIsNull() 66 | { 67 | var progressBar = ConsoleExtensions.WriteProgressBar(null!); 68 | 69 | Assert.IsType(progressBar); 70 | _transaction.Verify(x => x.Commit(), Times.Never); 71 | } 72 | 73 | [Fact] 74 | public void WriteProgressBar_ReturnsProgressBar_IfConsoleCreated() 75 | { 76 | var context = CreatePerformContext(); 77 | context.Items["ConsoleContext"] = CreateConsoleContext(context); 78 | 79 | var progressBar = context.WriteProgressBar(); 80 | 81 | Assert.IsType(progressBar); 82 | _transaction.Verify(x => x.Commit()); 83 | } 84 | 85 | [Fact] 86 | public void WriteProgressBar_ReturnsNoOp_IfConsoleNotCreated() 87 | { 88 | var context = CreatePerformContext(); 89 | 90 | var progressBar = context.WriteProgressBar(); 91 | 92 | Assert.IsType(progressBar); 93 | _transaction.Verify(x => x.Commit(), Times.Never); 94 | } 95 | 96 | // ReSharper disable once RedundantDisableWarningComment 97 | #pragma warning disable xUnit1013 98 | 99 | // ReSharper disable once MemberCanBePrivate.Global 100 | public static void JobMethod() 101 | { 102 | #pragma warning restore xUnit1013 103 | } 104 | 105 | private PerformContext CreatePerformContext() 106 | { 107 | return new PerformContext( 108 | _jobStorage.Object, 109 | _connection.Object, 110 | new BackgroundJob("1", Job.FromExpression(() => JobMethod()), DateTime.UtcNow), 111 | _cancellationToken.Object); 112 | } 113 | 114 | private ConsoleContext CreateConsoleContext(PerformContext context) => new( 115 | new ConsoleId(context.BackgroundJob.Id, DateTime.UtcNow), 116 | new ConsoleStorage(context.Connection)); 117 | } 118 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Resources/resize.min.js: -------------------------------------------------------------------------------- 1 | // https://github.com/sdecima/javascript-detect-element-resize/ 2 | !function(){function resetTriggers(element){var triggers=element.__resizeTriggers__,expand=triggers.firstElementChild,contract=triggers.lastElementChild,expandChild=expand.firstElementChild;contract.scrollLeft=contract.scrollWidth,contract.scrollTop=contract.scrollHeight,expandChild.style.width=expand.offsetWidth+1+"px",expandChild.style.height=expand.offsetHeight+1+"px",expand.scrollLeft=expand.scrollWidth,expand.scrollTop=expand.scrollHeight}function checkTriggers(element){return element.offsetWidth!=element.__resizeLast__.width||element.offsetHeight!=element.__resizeLast__.height}function scrollListener(e){var element=this;resetTriggers(this),this.__resizeRAF__&&cancelFrame(this.__resizeRAF__),this.__resizeRAF__=requestFrame(function(){checkTriggers(element)&&(element.__resizeLast__.width=element.offsetWidth,element.__resizeLast__.height=element.offsetHeight,element.__resizeListeners__.forEach(function(fn){fn.call(element,e)}))})}function createStyles(){if(!stylesCreated){var css=(animationKeyframes?animationKeyframes:"")+".resize-triggers { "+(animationStyle?animationStyle:"")+'visibility: hidden; opacity: 0; } .resize-triggers, .resize-triggers > div, .contract-trigger:before { content: " "; display: block; position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden; } .resize-triggers > div { background: #eee; overflow: auto; } .contract-trigger:before { width: 200%; height: 200%; }',head=document.head||document.getElementsByTagName("head")[0],style=document.createElement("style");style.type="text/css",style.styleSheet?style.styleSheet.cssText=css:style.appendChild(document.createTextNode(css)),head.appendChild(style),stylesCreated=!0}}var attachEvent=document.attachEvent,stylesCreated=!1;if(!attachEvent){var requestFrame=function(){var raf=window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||function(fn){return window.setTimeout(fn,20)};return function(fn){return raf(fn)}}(),cancelFrame=function(){var cancel=window.cancelAnimationFrame||window.mozCancelAnimationFrame||window.webkitCancelAnimationFrame||window.clearTimeout;return function(id){return cancel(id)}}(),animation=!1,animationstring="animation",keyframeprefix="",animationstartevent="animationstart",domPrefixes="Webkit Moz O ms".split(" "),startEvents="webkitAnimationStart animationstart oAnimationStart MSAnimationStart".split(" "),pfx="",elm=document.createElement("fakeelement");if(void 0!==elm.style.animationName&&(animation=!0),animation===!1)for(var i=0;i
',element.appendChild(element.__resizeTriggers__),resetTriggers(element),element.addEventListener("scroll",scrollListener,!0),animationstartevent&&element.__resizeTriggers__.addEventListener(animationstartevent,function(e){e.animationName==animationName&&resetTriggers(element)})),element.__resizeListeners__.push(fn))},window.removeResizeListener=function(element,fn){attachEvent?element.detachEvent("onresize",fn):(element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn),1),element.__resizeListeners__.length||(element.removeEventListener("scroll",scrollListener),element.__resizeTriggers__=!element.removeChild(element.__resizeTriggers__)))}}(); -------------------------------------------------------------------------------- /src/Hangfire.Console/Serialization/ConsoleId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Hangfire.Console.Serialization; 4 | 5 | /// 6 | /// Console identifier 7 | /// 8 | internal class ConsoleId : IEquatable 9 | { 10 | private static readonly DateTime UnixEpoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); 11 | 12 | private string? _cachedString; 13 | 14 | /// 15 | /// Initializes an instance of 16 | /// 17 | /// Job identifier 18 | /// Timestamp 19 | public ConsoleId(string jobId, DateTime timestamp) 20 | { 21 | if (string.IsNullOrEmpty(jobId)) 22 | { 23 | throw new ArgumentNullException(nameof(jobId)); 24 | } 25 | 26 | JobId = jobId; 27 | Timestamp = (long)(timestamp - UnixEpoch).TotalMilliseconds; 28 | 29 | if (Timestamp <= 0 || Timestamp > int.MaxValue * 1000L) 30 | { 31 | throw new ArgumentOutOfRangeException(nameof(timestamp)); 32 | } 33 | } 34 | 35 | /// 36 | /// Initializes an instance of . 37 | /// 38 | /// Job identifier 39 | /// Timestamp 40 | private ConsoleId(string jobId, long timestamp) 41 | { 42 | JobId = jobId; 43 | Timestamp = timestamp; 44 | } 45 | 46 | /// 47 | /// Job identifier 48 | /// 49 | public string JobId { get; } 50 | 51 | /// 52 | /// Timestamp 53 | /// 54 | public long Timestamp { get; } 55 | 56 | /// 57 | /// value as . 58 | /// 59 | public DateTime DateValue => UnixEpoch.AddMilliseconds(Timestamp); 60 | 61 | /// 62 | public bool Equals(ConsoleId? other) 63 | { 64 | if (ReferenceEquals(other, null)) 65 | { 66 | return false; 67 | } 68 | 69 | if (ReferenceEquals(other, this)) 70 | { 71 | return true; 72 | } 73 | 74 | return other.Timestamp == Timestamp 75 | && other.JobId == JobId; 76 | } 77 | 78 | /// 79 | /// Creates an instance of from string representation. 80 | /// 81 | /// String 82 | public static ConsoleId Parse(string value) 83 | { 84 | if (value == null) 85 | { 86 | throw new ArgumentNullException(nameof(value)); 87 | } 88 | 89 | if (value.Length < 12) 90 | { 91 | throw new ArgumentException("Invalid value", nameof(value)); 92 | } 93 | 94 | // Timestamp is serialized in reverse order for better randomness! 95 | 96 | long timestamp = 0; 97 | for (var i = 10; i >= 0; i--) 98 | { 99 | var c = value[i] | 0x20; 100 | 101 | var x = c is >= '0' and <= '9' ? c - '0' : c is >= 'a' and <= 'f' ? c - 'a' + 10 : -1; 102 | if (x == -1) 103 | { 104 | throw new ArgumentException("Invalid value", nameof(value)); 105 | } 106 | 107 | timestamp = (timestamp << 4) + x; 108 | } 109 | 110 | return new ConsoleId(value.Substring(11), timestamp) { _cachedString = value }; 111 | } 112 | 113 | /// 114 | public override string ToString() 115 | { 116 | if (_cachedString == null) 117 | { 118 | var buffer = new char[11 + JobId.Length]; 119 | 120 | var timestamp = Timestamp; 121 | for (var i = 0; i < 11; i++, timestamp >>= 4) 122 | { 123 | var c = timestamp & 0x0F; 124 | buffer[i] = c < 10 ? (char)(c + '0') : (char)(c - 10 + 'a'); 125 | } 126 | 127 | JobId.CopyTo(0, buffer, 11, JobId.Length); 128 | 129 | _cachedString = new string(buffer); 130 | } 131 | 132 | return _cachedString; 133 | } 134 | 135 | /// 136 | public override bool Equals(object? obj) => Equals(obj as ConsoleId); 137 | 138 | /// 139 | public override int GetHashCode() => (JobId.GetHashCode() * 17) ^ Timestamp.GetHashCode(); 140 | } 141 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Progress/ProgressEnumerable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | 5 | namespace Hangfire.Console.Progress; 6 | 7 | /// 8 | /// Non-generic version of wrapper. 9 | /// 10 | internal class ProgressEnumerable : IEnumerable 11 | { 12 | private readonly int _count; 13 | 14 | private readonly IEnumerable _enumerable; 15 | 16 | private readonly IProgressBar _progressBar; 17 | 18 | public ProgressEnumerable(IEnumerable enumerable, IProgressBar progressBar, int count) 19 | { 20 | if (count < 0) 21 | { 22 | throw new ArgumentOutOfRangeException(nameof(count)); 23 | } 24 | 25 | _enumerable = enumerable ?? throw new ArgumentNullException(nameof(enumerable)); 26 | _progressBar = progressBar ?? throw new ArgumentNullException(nameof(progressBar)); 27 | _count = count; 28 | } 29 | 30 | public IEnumerator GetEnumerator() => new Enumerator(_enumerable.GetEnumerator(), _progressBar, _count); 31 | 32 | private class Enumerator : IEnumerator, IDisposable 33 | { 34 | private readonly IEnumerator _enumerator; 35 | 36 | private readonly IProgressBar _progressBar; 37 | 38 | private int _count, _index; 39 | 40 | public Enumerator(IEnumerator enumerator, IProgressBar progressBar, int count) 41 | { 42 | _enumerator = enumerator; 43 | _progressBar = progressBar; 44 | _count = count; 45 | _index = -1; 46 | } 47 | 48 | public void Dispose() 49 | { 50 | try 51 | { 52 | (_enumerator as IDisposable)?.Dispose(); 53 | } 54 | finally 55 | { 56 | _progressBar.SetValue(100); 57 | } 58 | } 59 | 60 | public object? Current => _enumerator.Current; 61 | 62 | public bool MoveNext() 63 | { 64 | var r = _enumerator.MoveNext(); 65 | if (r) 66 | { 67 | _index++; 68 | 69 | if (_index >= _count) 70 | { 71 | // adjust maxCount if overrunned 72 | _count = _index + 1; 73 | } 74 | 75 | _progressBar.SetValue(_index * 100.0 / _count); 76 | } 77 | 78 | return r; 79 | } 80 | 81 | public void Reset() 82 | { 83 | _enumerator.Reset(); 84 | _index = -1; 85 | } 86 | } 87 | } 88 | 89 | /// 90 | /// Generic version of wrapper. 91 | /// 92 | /// 93 | internal class ProgressEnumerable : IEnumerable 94 | { 95 | private readonly int _count; 96 | 97 | private readonly IEnumerable _enumerable; 98 | 99 | private readonly IProgressBar _progressBar; 100 | 101 | public ProgressEnumerable(IEnumerable enumerable, IProgressBar progressBar, int count) 102 | { 103 | if (count < 0) 104 | { 105 | throw new ArgumentOutOfRangeException(nameof(count)); 106 | } 107 | 108 | _enumerable = enumerable ?? throw new ArgumentNullException(nameof(enumerable)); 109 | _progressBar = progressBar ?? throw new ArgumentNullException(nameof(progressBar)); 110 | _count = count; 111 | } 112 | 113 | public IEnumerator GetEnumerator() => new Enumerator(_enumerable.GetEnumerator(), _progressBar, _count); 114 | 115 | IEnumerator IEnumerable.GetEnumerator() => new Enumerator(_enumerable.GetEnumerator(), _progressBar, _count); 116 | 117 | private class Enumerator : IEnumerator 118 | { 119 | private readonly IEnumerator _enumerator; 120 | 121 | private readonly IProgressBar _progressBar; 122 | 123 | private int _count, _index; 124 | 125 | public Enumerator(IEnumerator enumerator, IProgressBar progressBar, int count) 126 | { 127 | _enumerator = enumerator; 128 | _progressBar = progressBar; 129 | _count = count; 130 | _index = -1; 131 | } 132 | 133 | public T Current => _enumerator.Current; 134 | 135 | object? IEnumerator.Current => ((IEnumerator)_enumerator).Current; 136 | 137 | public void Dispose() 138 | { 139 | try 140 | { 141 | _enumerator.Dispose(); 142 | } 143 | finally 144 | { 145 | _progressBar.SetValue(100); 146 | } 147 | } 148 | 149 | public bool MoveNext() 150 | { 151 | var r = _enumerator.MoveNext(); 152 | if (r) 153 | { 154 | _index++; 155 | 156 | if (_index >= _count) 157 | { 158 | // adjust maxCount if overrunned 159 | _count = _index + 1; 160 | } 161 | 162 | _progressBar.SetValue(_index * 100.0 / _count); 163 | } 164 | 165 | return r; 166 | } 167 | 168 | public void Reset() 169 | { 170 | _enumerator.Reset(); 171 | _index = -1; 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/Hangfire.Console/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using Hangfire.Console.Progress; 5 | using Hangfire.Server; 6 | using JetBrains.Annotations; 7 | 8 | namespace Hangfire.Console; 9 | 10 | /// 11 | /// Provides a set of extension methods to enumerate collections with progress. 12 | /// 13 | [PublicAPI] 14 | public static class EnumerableExtensions 15 | { 16 | /// 17 | /// Returns an reporting enumeration progress. 18 | /// 19 | /// Item type 20 | /// Source enumerable 21 | /// Progress bar 22 | /// Item count 23 | public static IEnumerable WithProgress(this IEnumerable enumerable, IProgressBar progressBar, int count = -1) 24 | { 25 | if (enumerable is ICollection collection) 26 | { 27 | count = collection.Count; 28 | } 29 | else if (enumerable is IReadOnlyCollection readOnlyCollection) 30 | { 31 | count = readOnlyCollection.Count; 32 | } 33 | else if (count < 0) 34 | { 35 | throw new ArgumentException("Count is required when enumerable is not a collection", nameof(count)); 36 | } 37 | 38 | return new ProgressEnumerable(enumerable, progressBar, count); 39 | } 40 | 41 | /// 42 | /// Returns an reporting enumeration progress. 43 | /// 44 | /// Source enumerable 45 | /// Progress bar 46 | /// Item count 47 | public static IEnumerable WithProgress(this IEnumerable enumerable, IProgressBar progressBar, int count = -1) 48 | { 49 | if (enumerable is ICollection collection) 50 | { 51 | count = collection.Count; 52 | } 53 | else if (count < 0) 54 | { 55 | throw new ArgumentException("Count is required when enumerable is not a collection", nameof(count)); 56 | } 57 | 58 | return new ProgressEnumerable(enumerable, progressBar, count); 59 | } 60 | 61 | /// 62 | /// Returns an reporting enumeration progress. 63 | /// 64 | /// Item type 65 | /// Source enumerable 66 | /// Perform context 67 | /// Progress bar color 68 | /// Item count 69 | /// The number of fractional digits or decimal places to use for the progress bar 70 | public static IEnumerable WithProgress(this IEnumerable enumerable, PerformContext context, ConsoleTextColor? color = null, int count = -1, int digits = 0) => WithProgress(enumerable, context.WriteProgressBar(0, color, digits), count); 71 | 72 | /// 73 | /// Returns ab reporting enumeration progress. 74 | /// 75 | /// Source enumerable 76 | /// Perform context 77 | /// Progress bar color 78 | /// Item count 79 | /// The number of fractional digits or decimal places to use for the progress bar 80 | public static IEnumerable WithProgress(this IEnumerable enumerable, PerformContext context, ConsoleTextColor? color = null, int count = -1, int digits = 0) => WithProgress(enumerable, context.WriteProgressBar(0, color, digits), count); 81 | 82 | /// 83 | /// Returns an reporting enumeration progress. 84 | /// 85 | /// Item type 86 | /// Source enumerable 87 | /// Perform context 88 | /// Progress bar name 89 | /// Progress bar color 90 | /// Item count 91 | /// The number of fractional digits or decimal places to use for the progress bar 92 | public static IEnumerable WithProgress(this IEnumerable enumerable, PerformContext context, string name, ConsoleTextColor? color = null, int count = -1, int digits = 0) => WithProgress(enumerable, context.WriteProgressBar(name, 0, color, digits), count); 93 | 94 | /// 95 | /// Returns ab reporting enumeration progress. 96 | /// 97 | /// Source enumerable 98 | /// Perform context 99 | /// Progress bar name 100 | /// Progress bar color 101 | /// Item count 102 | /// The number of fractional digits or decimal places to use for the progress bar 103 | public static IEnumerable WithProgress(this IEnumerable enumerable, PerformContext context, string name, ConsoleTextColor? color = null, int count = -1, int digits = 0) => WithProgress(enumerable, context.WriteProgressBar(name, 0, color, digits), count); 104 | } 105 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Hangfire.Console.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Job console for Hangfire 4 | IdentityStream.Hangfire.Console 5 | Alexey Skalozub;IdentityStream AS 6 | netstandard2.0 7 | true 8 | IdentityStream.Hangfire.Console 9 | IdentityStream.Hangfire.Console 10 | hangfire;console;logging 11 | true 12 | snupkg 13 | true 14 | beta.0 15 | v 16 | true 17 | History: 18 | v2.0.0: 19 | • Changed target framework to .NET Standard 2.0 20 | • Bumped Hangfire.Core dependency to latest version 21 | • Calling UseConsole more than once will no longer throw 22 | • Fixed number formatting/parsing bug causing console to poll forever 23 | • Added digits argument to progress bar, defaulting to 0 decimal digits 24 | - This prevents huge amounts of progress bar updates with fractional digit changes 25 | 26 | v1.4.2: 27 | • Added StringFormatMethod attributes on WriteLine methods (for better intellisense) 28 | 29 | v1.4.1: 30 | • Fix job progress style 31 | • Use explicit json serializer settings 32 | • Remove ConsoleContext from Items in OnPerformed 33 | 34 | v1.4.0: 35 | • Show job progress at Processing Jobs page 36 | 37 | v1.3.10: 38 | • Fix expiration issues (#47) 39 | 40 | v1.3.9: 41 | • Relax Newtonsoft.Json dependency version for .NET 4.5 42 | 43 | v1.3.8: 44 | • Fix WriteLine thread-safety issues 45 | 46 | v1.3.7: 47 | • Prevent calling UseConsole() twice 48 | • Collapse outdated consoles 49 | 50 | v1.3.6: 51 | • Make progress bars' SetValue thread-safe 52 | • Add support for named progress bars 53 | 54 | v1.3.5: 55 | • Add more overloads for WriteLine and WithProgress extension methods 56 | 57 | v1.3.4: 58 | • Fixed hyperlink detection for urls with query string parameters (#37) 59 | • Fixed loading dots indicator position on tiny screens 60 | 61 | v1.3.3: 62 | • Eliminated unnecessary state filter executions 63 | 64 | v1.3.2: 65 | • Fixed console expiration for some storages (e.g. Hangfire.Redis.StackExchange) 66 | 67 | v1.3.1: 68 | • Fixed compatibility with Hangfire 1.6.11+ 69 | 70 | v1.3.0: 71 | • Consoles are now expired along with parent job by default! 72 | • Added **FollowJobRetentionPolicy** option to switch between old/new expiration modes 73 | 74 | v1.2.1: 75 | • Added Monitoring API 76 | 77 | v1.2.0: 78 | • Added hyperlink detection 79 | 80 | v1.1.7: 81 | • Fixed line ordering issue 82 | 83 | v1.1.6: 84 | • Changed key format to support single-keyspace storages, like Hangfire.Redis 85 | 86 | v1.1.5: 87 | • Allow WriteLine/WriteProgressBar calls with a null PerformContext 88 | 89 | v1.1.4: 90 | • Added support of fractional progress values 91 | • Added WithProgress() extension methods for tracking enumeration progress in for-each loops 92 | 93 | v1.1.3: 94 | • Fixed ugly font on OS X 95 | • Fixed animation lags on all major browsers 96 | 97 | v1.1.2: 98 | • Added support for long messages 99 | • Refactor for better testability 100 | 101 | v1.1.1: 102 | • Don't show current console while there's no lines 103 | 104 | v1.1.0: 105 | • Added progress bars 106 | 107 | v1.0.2: 108 | • Added some more configuration options 109 | • Fixed occasional duplicate lines collapsing 110 | 111 | v1.0.1: 112 | • Fixed compatibility issues with storages losing DateTime precision (like MongoDB) 113 | • Improved client-side experience 114 | 115 | v1.0.0: 116 | • Initial release 117 | 118 | hangfire.console.png 119 | README.md 120 | MIT 121 | git 122 | https://github.com/IdentityStream/Hangfire.Console 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | all 139 | runtime; build; native; contentfiles; analyzers 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Support/RouteCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | 6 | // ReSharper disable once CheckNamespace 7 | namespace Hangfire.Dashboard.Extensions; 8 | 9 | /// 10 | /// Provides extension methods for . 11 | /// 12 | internal static class RouteCollectionExtensions 13 | { 14 | // ReSharper disable once InconsistentNaming 15 | private static readonly FieldInfo _dispatchers = typeof(RouteCollection).GetTypeInfo().GetDeclaredField(nameof(_dispatchers)); 16 | 17 | /// 18 | /// Returns a private list of registered routes. 19 | /// 20 | /// Route collection 21 | private static List> GetDispatchers(this RouteCollection routes) 22 | { 23 | if (routes == null) 24 | { 25 | throw new ArgumentNullException(nameof(routes)); 26 | } 27 | 28 | return (List>)_dispatchers.GetValue(routes); 29 | } 30 | 31 | /// 32 | /// Checks if there's a dispatcher registered for given . 33 | /// 34 | /// Route collection 35 | /// Path template 36 | public static bool Contains(this RouteCollection routes, string pathTemplate) 37 | { 38 | if (routes == null) 39 | { 40 | throw new ArgumentNullException(nameof(routes)); 41 | } 42 | 43 | if (pathTemplate == null) 44 | { 45 | throw new ArgumentNullException(nameof(pathTemplate)); 46 | } 47 | 48 | return routes.GetDispatchers().Any(x => x.Item1 == pathTemplate); 49 | } 50 | 51 | /// 52 | /// Combines exising dispatcher for with . 53 | /// If there's no dispatcher for the specified path, adds a new one. 54 | /// 55 | /// Route collection 56 | /// Path template 57 | /// Dispatcher to add or append for specified path 58 | public static void Append(this RouteCollection routes, string pathTemplate, IDashboardDispatcher dispatcher) 59 | { 60 | if (routes == null) 61 | { 62 | throw new ArgumentNullException(nameof(routes)); 63 | } 64 | 65 | if (pathTemplate == null) 66 | { 67 | throw new ArgumentNullException(nameof(pathTemplate)); 68 | } 69 | 70 | if (dispatcher == null) 71 | { 72 | throw new ArgumentNullException(nameof(dispatcher)); 73 | } 74 | 75 | var list = routes.GetDispatchers(); 76 | 77 | for (var i = 0; i < list.Count; i++) 78 | { 79 | var pair = list[i]; 80 | if (pair.Item1 == pathTemplate) 81 | { 82 | if (!(pair.Item2 is CompositeDispatcher composite)) 83 | { 84 | // replace original dispatcher with a composite one 85 | composite = new CompositeDispatcher(pair.Item2); 86 | list[i] = new Tuple(pair.Item1, composite); 87 | } 88 | 89 | composite.AddDispatcher(dispatcher); 90 | return; 91 | } 92 | } 93 | 94 | routes.Add(pathTemplate, dispatcher); 95 | } 96 | 97 | /// 98 | /// Replaces exising dispatcher for with . 99 | /// If there's no dispatcher for the specified path, adds a new one. 100 | /// 101 | /// Route collection 102 | /// Path template 103 | /// Dispatcher to set for specified path 104 | public static void Replace(this RouteCollection routes, string pathTemplate, IDashboardDispatcher dispatcher) 105 | { 106 | if (routes == null) 107 | { 108 | throw new ArgumentNullException(nameof(routes)); 109 | } 110 | 111 | if (pathTemplate == null) 112 | { 113 | throw new ArgumentNullException(nameof(pathTemplate)); 114 | } 115 | 116 | if (dispatcher == null) 117 | { 118 | throw new ArgumentNullException(nameof(dispatcher)); 119 | } 120 | 121 | var list = routes.GetDispatchers(); 122 | 123 | for (var i = 0; i < list.Count; i++) 124 | { 125 | var pair = list[i]; 126 | if (pair.Item1 == pathTemplate) 127 | { 128 | list[i] = new Tuple(pair.Item1, dispatcher); 129 | return; 130 | } 131 | } 132 | 133 | routes.Add(pathTemplate, dispatcher); 134 | } 135 | 136 | /// 137 | /// Removes dispatcher for . 138 | /// 139 | /// Route collection 140 | /// Path template 141 | public static void Remove(this RouteCollection routes, string pathTemplate) 142 | { 143 | if (routes == null) 144 | { 145 | throw new ArgumentNullException(nameof(routes)); 146 | } 147 | 148 | if (pathTemplate == null) 149 | { 150 | throw new ArgumentNullException(nameof(pathTemplate)); 151 | } 152 | 153 | var list = routes.GetDispatchers(); 154 | 155 | for (var i = 0; i < list.Count; i++) 156 | { 157 | var pair = list[i]; 158 | if (pair.Item1 == pathTemplate) 159 | { 160 | list.RemoveAt(i); 161 | return; 162 | } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IdentityStream.Hangfire.Console 2 | 3 | [![Build Status](https://github.com/IdentityStream/Hangfire.Console/actions/workflows/dotnet.yml/badge.svg)](https://github.com/IdentityStream/Hangfire.Console/actions/workflows/dotnet.yml) 4 | [![NuGet](https://img.shields.io/nuget/v/IdentityStream.Hangfire.Console.svg)](https://www.nuget.org/packages/IdentityStream.Hangfire.Console/) 5 | ![MIT License](https://img.shields.io/badge/license-MIT-orange.svg) 6 | 7 | Inspired by AppVeyor, Hangfire.Console provides a console-like logging experience for your jobs. 8 | 9 | > [!IMPORTANT] 10 | > As [Hangfire.Console](https://github.com/pieceofsummer/Hangfire.Console) seems to be abandoned, this is a fork with some crucial bugfixes. 11 | > If, at some point in the future, the main project comes back to life, these fixes should probably be merged back upstream. 12 | 13 | ![dashboard](dashboard.png) 14 | 15 | ## Features 16 | 17 | - **Provider-agnostic**: (allegedly) works with any job storage provider (currently tested with SqlServer and MongoDB). 18 | - **100% Safe**: no Hangfire-managed data (e.g. jobs, states) is ever updated, hence there's no risk to corrupt it. 19 | - **With Live Updates**: new messages will appear as they're logged, as if you're looking at real console. 20 | - (blah-blah-blah) 21 | 22 | ## Setup 23 | 24 | In .NET Core's Startup.cs: 25 | 26 | ```c# 27 | public void ConfigureServices(IServiceCollection services) 28 | { 29 | services.AddHangfire(config => 30 | { 31 | config.UseSqlServerStorage("connectionSting"); 32 | config.UseConsole(); 33 | }); 34 | } 35 | ``` 36 | 37 | Otherwise, 38 | 39 | ```c# 40 | GlobalConfiguration.Configuration 41 | .UseSqlServerStorage("connectionSting") 42 | .UseConsole(); 43 | ``` 44 | 45 | **NOTE**: If you have Dashboard and Server running separately, 46 | you'll need to call `UseConsole()` on both. 47 | 48 | ### Additional options 49 | 50 | As usual, you may provide additional options for `UseConsole()` method. 51 | 52 | Here's what you can configure: 53 | 54 | - **ExpireIn** – time to keep console sessions (default: 24 hours) 55 | - **FollowJobRetentionPolicy** – expire all console sessions along with parent job (default: true) 56 | - **PollInterval** – poll interval for live updates, ms (default: 1000) 57 | - **BackgroundColor** – console background color (default: #0d3163) 58 | - **TextColor** – console default text color (default: #ffffff) 59 | - **TimestampColor** – timestamp text color (default: #00aad7) 60 | 61 | **NOTE**: After you initially add Hangfire.Console (or change the options above) you may need to clear browser cache, as 62 | generated CSS/JS can be cached by browser. 63 | 64 | ## Log 65 | 66 | Hangfire.Console provides extension methods on `PerformContext` object, 67 | hence you'll need to add it as a job argument. 68 | 69 | **NOTE**: Like `IJobCancellationToken`, `PerformContext` is a special argument type which Hangfire will substitute 70 | automatically. You should pass `null` when enqueuing a job. 71 | 72 | Now you can write to console: 73 | 74 | ```c# 75 | public void TaskMethod(PerformContext context) 76 | { 77 | context.WriteLine("Hello, world!"); 78 | } 79 | ``` 80 | 81 | Like with `System.Console`, you can specify text color for your messages: 82 | 83 | ```c# 84 | public void TaskMethod(PerformContext context) 85 | { 86 | context.SetTextColor(ConsoleTextColor.Red); 87 | context.WriteLine("Error!"); 88 | context.ResetTextColor(); 89 | } 90 | ``` 91 | 92 | ## Progress bars 93 | 94 | Version 1.1.0 adds support for inline progress bars: 95 | 96 | ![progress](progress.png) 97 | 98 | ```c# 99 | public void TaskMethod(PerformContext context) 100 | { 101 | // create progress bar 102 | var progress = context.WriteProgressBar(); 103 | 104 | // update value for previously created progress bar 105 | progress.SetValue(100); 106 | } 107 | ``` 108 | 109 | You can create multiple progress bars and update them separately. 110 | 111 | By default, progress bar is initialized with value `0`. You can specify initial value and progress bar color as optional 112 | arguments for `WriteProgressBar()`. 113 | 114 | ### Enumeration progress 115 | 116 | To easily track progress of enumeration over a collection in a for-each loop, library adds an extension 117 | method `WithProgress`: 118 | 119 | ```c# 120 | public void TaskMethod(PerformContext context) 121 | { 122 | var bar = context.WriteProgressBar(); 123 | 124 | foreach (var item in collection.WithProgress(bar)) 125 | { 126 | // do work 127 | } 128 | } 129 | ``` 130 | 131 | It will automatically update progress bar during enumeration, and will set progress to 100% if for-each loop was 132 | interrupted with a `break` instruction. 133 | 134 | **NOTE**: If the number of items in the collection cannot be determined automatically (e.g. collection doesn't 135 | implement `ICollection`/`ICollection`/`IReadOnlyCollection`, you'll need to pass additional argument `count` to 136 | the extension method). 137 | 138 | ## License 139 | 140 | Copyright (c) 2016 Alexey Skalozub 141 | Copyright (c) 2023 IdentityStream AS 142 | 143 | Permission is hereby granted, free of charge, to any person obtaining a copy 144 | of this software and associated documentation files (the "Software"), to deal 145 | in the Software without restriction, including without limitation the rights 146 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 147 | copies of the Software, and to permit persons to whom the Software is 148 | furnished to do so, subject to the following conditions: 149 | 150 | The above copyright notice and this permission notice shall be included in all 151 | copies or substantial portions of the Software. 152 | 153 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 154 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 155 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 156 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 157 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 158 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 159 | SOFTWARE. 160 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Resources/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | -webkit-font-smoothing: subpixel-antialiased; 3 | } 4 | 5 | .console-area { 6 | padding: 0; 7 | margin: 5px -10px 0 -10px; 8 | } 9 | 10 | .console { 11 | margin: 0; 12 | border: none; 13 | padding: 0; 14 | 15 | font-family: Menlo, Monaco, Consolas, "Courier New", monospace; 16 | background-color: #0d3163; 17 | color: #ffffff; 18 | 19 | overflow-x: hidden; 20 | overflow-y: hidden; /* may be changed to 'auto' for fixed-height console */ 21 | } 22 | 23 | .console.collapsed { 24 | font-family: unset !important; 25 | background-color: unset !important; 26 | pointer-events: none; 27 | } 28 | 29 | .console.collapsed:before { 30 | content: "Show outdated console"; 31 | margin-left: 10px; 32 | color: #337ab7 !important; 33 | cursor: pointer; 34 | pointer-events: all; 35 | } 36 | 37 | @media only screen and (min-width: 768px) { 38 | .console.collapsed:before { 39 | margin-left: 190px; 40 | } 41 | } 42 | 43 | .console.collapsed:hover:before { 44 | text-decoration: underline; 45 | } 46 | 47 | .console .line-buffer { 48 | background-color: #0d3163; 49 | color: #ffffff; 50 | 51 | margin: 0; 52 | border: none; 53 | padding: 10px; 54 | } 55 | 56 | .console.collapsed .line-buffer { 57 | display: none; 58 | } 59 | 60 | .console .line-buffer:empty { 61 | display: none; 62 | } 63 | 64 | .console .line { 65 | margin: 0; 66 | line-height: 1.4em; 67 | min-height: 1.4em; 68 | font-size: 0.85em; 69 | word-break: break-word; 70 | overflow-wrap: break-word; 71 | white-space: pre-wrap; 72 | vertical-align: top; 73 | } 74 | 75 | .console .line > span[data-moment-title] { 76 | display: none; /* timestamp is hidden in compact view */ 77 | color: #00aad7; 78 | } 79 | 80 | .console .line.pb { 81 | border: 1px solid currentColor; 82 | border-radius: 2px; 83 | margin-top: 3px; 84 | margin-bottom: 3px; 85 | } 86 | 87 | .console .line.pb > .pv { 88 | background-color: currentColor; 89 | display: inline-block; 90 | min-width: 1.6em; 91 | text-align: center; 92 | -webkit-transition: width .6s ease; 93 | -moz-transition: width .6s ease; 94 | -o-transition: width .6s ease; 95 | transition: width .6s ease; 96 | text-overflow: clip; 97 | white-space: nowrap; 98 | } 99 | 100 | .console .line.pb > .pv:before { 101 | content: attr(data-value) '%'; 102 | color: #0d3163; 103 | } 104 | 105 | @media (min-width: 768px) { 106 | 107 | .console .line { 108 | margin-left: 180px; /* same as dd */ 109 | } 110 | 111 | .console .line > span[data-moment-title] { 112 | display: inline-block; 113 | width: 160px; /* same as dt */ 114 | margin-left: -180px; 115 | margin-right: 20px; 116 | overflow: hidden; 117 | text-align: right; 118 | text-overflow: ellipsis; 119 | vertical-align: top; 120 | white-space: pre; 121 | } 122 | 123 | .console .line.pb > span[data-moment-title] { 124 | padding-right: 1px; 125 | } 126 | 127 | } 128 | 129 | /* Optional CSS3 bells and whistles */ 130 | 131 | .console.active { 132 | -webkit-transition: height .3s ease-out; 133 | -moz-transition: height .3s ease-out; 134 | -o-transition: height .3s ease-out; 135 | transition: height .3s ease-out; 136 | } 137 | 138 | .console.active .line.new { 139 | opacity: 0; 140 | animation: fadeIn 0.4s ease-out forwards; 141 | 142 | /* IE9 knows opacity (but not animation), and hides the line. 143 | As a workaround, override this with IE-specific attribute. */ 144 | filter: alpha(opacity=100); 145 | } 146 | 147 | @keyframes fadeIn { 148 | to { 149 | opacity: 1; 150 | } 151 | } 152 | 153 | /* !!! The following two blocks are important for smooth text animation !!! */ 154 | 155 | .console.active .line-buffer { 156 | -webkit-transform: translateZ(0); 157 | -webkit-font-smoothing: subpixel-antialiased; 158 | } 159 | 160 | .console.active .line.new, 161 | .console.active .line.new > span[data-moment-title] { 162 | background-color: inherit; 163 | } 164 | 165 | @media only screen { 166 | 167 | .console.waiting:after { 168 | content: '...'; 169 | display: block; 170 | position: absolute; 171 | font-size: 0.85em; 172 | line-height: 1.4em; 173 | text-overflow: clip; 174 | overflow: hidden; 175 | 176 | width: 0; 177 | margin-left: 10px; 178 | animation: loadingDots 1s infinite steps(4); 179 | transform: translate(-1px, -1.4em); 180 | } 181 | 182 | } 183 | 184 | @media only screen and (min-width: 768px) { 185 | 186 | .console.waiting:after { 187 | margin-left: 190px; 188 | } 189 | 190 | } 191 | 192 | @keyframes loadingDots { 193 | to { 194 | width: 27px; 195 | } 196 | } 197 | 198 | .progress-circle { 199 | position: relative; 200 | float: left; 201 | margin-left: -20px; 202 | margin-top: -2px; 203 | width: 24px; 204 | height: 24px; 205 | color: #337ab7; 206 | 207 | pointer-events: none; 208 | -moz-user-select: none; 209 | -webkit-user-select: none; 210 | user-select: none; 211 | } 212 | 213 | .progress-circle:before { 214 | content: attr(data-value); 215 | display: block; 216 | position: absolute; 217 | z-index: 1; 218 | width: 100%; 219 | text-align: center; 220 | line-height: 24px; 221 | font-size: 9px; 222 | font-family: Menlo, Monaco, Consolas, "Courier New", monospace; 223 | letter-spacing: -0.5px; 224 | margin-left: 0.25px; 225 | } 226 | 227 | .progress-circle svg { 228 | transform-origin: center; 229 | transform: rotate(-90deg); 230 | } 231 | 232 | .progress-circle circle { 233 | -webkit-transition: stroke-dashoffset .6s ease; 234 | -moz-transition: stroke-dashoffset .6s ease; 235 | -o-transition: stroke-dashoffset .6s ease; 236 | transition: stroke-dashoffset .6s ease; 237 | stroke: #ddd; 238 | fill: transparent; 239 | stroke-width: 2px; 240 | } 241 | 242 | .progress-circle .bar { 243 | stroke: currentColor; 244 | } 245 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/Server/ConsoleContextFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Console.Serialization; 3 | using Hangfire.Console.Server; 4 | using Hangfire.Console.Storage; 5 | using Moq; 6 | using Xunit; 7 | 8 | namespace Hangfire.Console.Tests.Server; 9 | 10 | public class ConsoleContextFacts 11 | { 12 | private readonly Mock _storage = new(); 13 | 14 | [Fact] 15 | public void Ctor_ThrowsException_IfConsoleIdIsNull() 16 | { 17 | Assert.Throws("consoleId", () => new ConsoleContext(null!, _storage.Object)); 18 | } 19 | 20 | [Fact] 21 | public void Ctor_ThrowsException_IfStorageIsNull() 22 | { 23 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 24 | 25 | Assert.Throws("storage", () => new ConsoleContext(consoleId, null!)); 26 | } 27 | 28 | [Fact] 29 | public void Ctor_InitializesConsole() 30 | { 31 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 32 | _ = new ConsoleContext(consoleId, _storage.Object); 33 | 34 | _storage.Verify(x => x.InitConsole(consoleId)); 35 | } 36 | 37 | [Fact] 38 | public void AddLine_ThrowsException_IfLineIsNull() 39 | { 40 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 41 | var context = new ConsoleContext(consoleId, _storage.Object); 42 | 43 | Assert.Throws("line", () => context.AddLine(null!)); 44 | } 45 | 46 | [Fact] 47 | public void AddLine_ReallyAddsLine() 48 | { 49 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 50 | var context = new ConsoleContext(consoleId, _storage.Object); 51 | 52 | context.AddLine(new ConsoleLine { TimeOffset = 0, Message = "line" }); 53 | 54 | _storage.Verify(x => x.AddLine(It.IsAny(), It.IsAny())); 55 | } 56 | 57 | [Fact] 58 | public void AddLine_CorrectsTimeOffset() 59 | { 60 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 61 | var context = new ConsoleContext(consoleId, _storage.Object); 62 | 63 | var line1 = new ConsoleLine { TimeOffset = 0, Message = "line" }; 64 | var line2 = new ConsoleLine { TimeOffset = 0, Message = "line" }; 65 | 66 | context.AddLine(line1); 67 | context.AddLine(line2); 68 | 69 | _storage.Verify(x => x.AddLine(It.IsAny(), It.IsAny()), Times.Exactly(2)); 70 | Assert.NotEqual(line1.TimeOffset, line2.TimeOffset, 4); 71 | } 72 | 73 | [Fact] 74 | public void WriteLine_ReallyAddsLine() 75 | { 76 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 77 | var context = new ConsoleContext(consoleId, _storage.Object); 78 | 79 | context.WriteLine("line", null); 80 | 81 | _storage.Verify(x => x.AddLine(It.IsAny(), It.Is(l => l.Message == "line" && l.TextColor == null))); 82 | } 83 | 84 | [Fact] 85 | public void WriteLine_ReallyAddsLineWithColor() 86 | { 87 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 88 | var context = new ConsoleContext(consoleId, _storage.Object); 89 | 90 | context.WriteLine("line", ConsoleTextColor.Red); 91 | 92 | _storage.Verify(x => x.AddLine(It.IsAny(), It.Is(l => l.Message == "line" && l.TextColor == ConsoleTextColor.Red))); 93 | } 94 | 95 | [Fact] 96 | public void WriteProgressBar_WritesDefaults_AndReturnsNonNull() 97 | { 98 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 99 | var context = new ConsoleContext(consoleId, _storage.Object); 100 | 101 | var progressBar = context.WriteProgressBar(null, 0, null); 102 | 103 | _storage.Verify(x => x.AddLine(It.IsAny(), It.IsAny())); 104 | Assert.NotNull(progressBar); 105 | } 106 | 107 | [Fact] 108 | public void WriteProgressBar_WritesName_AndReturnsNonNull() 109 | { 110 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 111 | var context = new ConsoleContext(consoleId, _storage.Object); 112 | 113 | var progressBar = context.WriteProgressBar("test", 0, null); 114 | 115 | _storage.Verify(x => x.AddLine(It.IsAny(), It.Is(l => l.ProgressName == "test"))); 116 | Assert.NotNull(progressBar); 117 | } 118 | 119 | [Fact] 120 | public void WriteProgressBar_WritesInitialValue_AndReturnsNonNull() 121 | { 122 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 123 | var context = new ConsoleContext(consoleId, _storage.Object); 124 | 125 | var progressBar = context.WriteProgressBar(null, 5, null); 126 | 127 | _storage.Verify(x => x.AddLine(It.IsAny(), It.Is(l => 128 | l.ProgressValue.HasValue && Math.Abs(l.ProgressValue.Value - 5.0) < double.Epsilon))); 129 | Assert.NotNull(progressBar); 130 | } 131 | 132 | [Fact] 133 | public void WriteProgressBar_WritesProgressBarColor_AndReturnsNonNull() 134 | { 135 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 136 | var context = new ConsoleContext(consoleId, _storage.Object); 137 | 138 | var progressBar = context.WriteProgressBar(null, 0, ConsoleTextColor.Red); 139 | 140 | _storage.Verify(x => x.AddLine(It.IsAny(), It.Is(l => l.TextColor == ConsoleTextColor.Red))); 141 | Assert.NotNull(progressBar); 142 | } 143 | 144 | [Fact] 145 | public void Expire_ReallyExpiresLines() 146 | { 147 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 148 | var context = new ConsoleContext(consoleId, _storage.Object); 149 | 150 | context.Expire(TimeSpan.FromHours(1)); 151 | 152 | _storage.Verify(x => x.Expire(It.IsAny(), It.IsAny())); 153 | } 154 | 155 | [Fact] 156 | public void FixExpiration_RequestsConsoleTtl_IgnoresIfNegative() 157 | { 158 | _storage.Setup(x => x.GetConsoleTtl(It.IsAny())) 159 | .Returns(TimeSpan.FromSeconds(-1)); 160 | 161 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 162 | var context = new ConsoleContext(consoleId, _storage.Object); 163 | 164 | context.FixExpiration(); 165 | 166 | _storage.Verify(x => x.GetConsoleTtl(It.IsAny())); 167 | _storage.Verify(x => x.Expire(It.IsAny(), It.IsAny()), Times.Never); 168 | } 169 | 170 | [Fact] 171 | public void FixExpiration_RequestsConsoleTtl_ExpiresIfPositive() 172 | { 173 | _storage.Setup(x => x.GetConsoleTtl(It.IsAny())) 174 | .Returns(TimeSpan.FromHours(1)); 175 | 176 | var consoleId = new ConsoleId("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 177 | var context = new ConsoleContext(consoleId, _storage.Object); 178 | 179 | context.FixExpiration(); 180 | 181 | _storage.Verify(x => x.GetConsoleTtl(It.IsAny())); 182 | _storage.Verify(x => x.Expire(It.IsAny(), It.IsAny())); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/States/ConsoleApplyStateFilterFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Hangfire.Common; 4 | using Hangfire.Console.States; 5 | using Hangfire.States; 6 | using Hangfire.Storage; 7 | using Hangfire.Storage.Monitoring; 8 | using Moq; 9 | using Xunit; 10 | 11 | namespace Hangfire.Console.Tests.States; 12 | 13 | public class ConsoleApplyStateFilterFacts 14 | { 15 | private readonly Mock _connection; 16 | 17 | private readonly Mock _monitoring; 18 | 19 | private readonly Mock _otherFilter; 20 | 21 | private readonly Mock _storage; 22 | 23 | private readonly Mock _transaction; 24 | 25 | public ConsoleApplyStateFilterFacts() 26 | { 27 | _otherFilter = new Mock(); 28 | _storage = new Mock(); 29 | _connection = new Mock(); 30 | _transaction = new Mock(); 31 | _monitoring = new Mock(); 32 | 33 | _storage.Setup(x => x.GetConnection()) 34 | .Returns(_connection.Object); 35 | _storage.Setup(x => x.GetMonitoringApi()) 36 | .Returns(_monitoring.Object); 37 | 38 | _connection.Setup(x => x.CreateWriteTransaction()) 39 | .Returns(_transaction.Object); 40 | } 41 | 42 | [Fact] 43 | public void UsesFinalJobExpirationTimeoutValue() 44 | { 45 | _otherFilter.Setup(x => x.OnStateApplied(It.IsAny(), It.IsAny())) 46 | .Callback((c, _) => c.JobExpirationTimeout = TimeSpan.FromSeconds(123)); 47 | _connection.Setup(x => x.GetJobData("1")) 48 | .Returns(CreateJobData(ProcessingState.StateName)); 49 | _monitoring.Setup(x => x.JobDetails("1")) 50 | .Returns(CreateJobDetails()); 51 | 52 | var stateChanger = new BackgroundJobStateChanger(CreateJobFilterProvider()); 53 | var context = CreateStateChangeContext(new MockSucceededState()); 54 | 55 | stateChanger.ChangeState(context); 56 | 57 | _transaction.Verify(x => x.ExpireJob(It.IsAny(), TimeSpan.FromSeconds(123))); 58 | _transaction.Verify(x => x.ExpireSet(It.IsAny(), TimeSpan.FromSeconds(123))); 59 | } 60 | 61 | [Fact] 62 | public void DoesNotExpire_IfNotFollowsJobRetention() 63 | { 64 | _connection.Setup(x => x.GetJobData("1")) 65 | .Returns(CreateJobData(ProcessingState.StateName)); 66 | _monitoring.Setup(x => x.JobDetails("1")) 67 | .Returns(CreateJobDetails()); 68 | 69 | var stateChanger = new BackgroundJobStateChanger(CreateJobFilterProvider(false)); 70 | var context = CreateStateChangeContext(new MockSucceededState()); 71 | 72 | stateChanger.ChangeState(context); 73 | 74 | _transaction.Verify(x => x.ExpireSet(It.IsAny(), It.IsAny()), Times.Never); 75 | _transaction.Verify(x => x.ExpireHash(It.IsAny(), It.IsAny()), Times.Never); 76 | } 77 | 78 | [Fact] 79 | public void Expires_IfStateIsFinal() 80 | { 81 | _connection.Setup(x => x.GetJobData("1")) 82 | .Returns(CreateJobData(ProcessingState.StateName)); 83 | _monitoring.Setup(x => x.JobDetails("1")) 84 | .Returns(CreateJobDetails()); 85 | 86 | var stateChanger = new BackgroundJobStateChanger(CreateJobFilterProvider()); 87 | var context = CreateStateChangeContext(new MockSucceededState()); 88 | 89 | stateChanger.ChangeState(context); 90 | 91 | _transaction.Verify(x => x.ExpireSet(It.IsAny(), It.IsAny())); 92 | _transaction.Verify(x => x.ExpireHash(It.IsAny(), It.IsAny())); 93 | } 94 | 95 | [Fact] 96 | public void Persists_IfStateIsNotFinal() 97 | { 98 | _connection.Setup(x => x.GetJobData("1")) 99 | .Returns(CreateJobData(ProcessingState.StateName)); 100 | _monitoring.Setup(x => x.JobDetails("1")) 101 | .Returns(CreateJobDetails()); 102 | 103 | var stateChanger = new BackgroundJobStateChanger(CreateJobFilterProvider()); 104 | var context = CreateStateChangeContext(new MockFailedState()); 105 | 106 | stateChanger.ChangeState(context); 107 | 108 | _transaction.Verify(x => x.PersistSet(It.IsAny())); 109 | _transaction.Verify(x => x.PersistHash(It.IsAny())); 110 | } 111 | 112 | private IJobFilterProvider CreateJobFilterProvider(bool followJobRetention = true) 113 | { 114 | var filters = new JobFilterCollection(); 115 | filters.Add(new ConsoleApplyStateFilter(new ConsoleOptions { FollowJobRetentionPolicy = followJobRetention }), int.MaxValue); 116 | filters.Add(_otherFilter.Object); 117 | return new JobFilterProviderCollection(filters); 118 | } 119 | 120 | private StateChangeContext CreateStateChangeContext(IState state) => new(_storage.Object, _connection.Object, "1", state); 121 | 122 | // ReSharper disable once RedundantDisableWarningComment 123 | #pragma warning disable xUnit1013 124 | public static void JobMethod() 125 | #pragma warning restore xUnit1013 126 | { } 127 | 128 | private JobDetailsDto CreateJobDetails() 129 | { 130 | var date = DateTime.UtcNow.AddHours(-1); 131 | var history = new List(); 132 | 133 | history.Add(new StateHistoryDto 134 | { 135 | StateName = EnqueuedState.StateName, 136 | CreatedAt = date, 137 | Data = new Dictionary 138 | { 139 | ["EnqueuedAt"] = JobHelper.SerializeDateTime(date), 140 | ["Queue"] = EnqueuedState.DefaultQueue 141 | } 142 | }); 143 | 144 | history.Add(new StateHistoryDto 145 | { 146 | StateName = ProcessingState.StateName, 147 | CreatedAt = date.AddSeconds(2), 148 | Data = new Dictionary 149 | { 150 | ["StartedAt"] = JobHelper.SerializeDateTime(date.AddSeconds(2)), 151 | ["ServerId"] = "SERVER-1", 152 | ["WorkerId"] = "WORKER-1" 153 | } 154 | }); 155 | 156 | history.Reverse(); 157 | 158 | return new JobDetailsDto 159 | { 160 | CreatedAt = history[0].CreatedAt, 161 | Job = Job.FromExpression(() => JobMethod()), 162 | History = history 163 | }; 164 | } 165 | 166 | private JobData CreateJobData(string state) 167 | { 168 | return new JobData 169 | { 170 | CreatedAt = DateTime.UtcNow.AddHours(-1), 171 | Job = Job.FromExpression(() => JobMethod()), 172 | State = state 173 | }; 174 | } 175 | 176 | public class MockSucceededState : IState 177 | { 178 | public string Name => SucceededState.StateName; 179 | 180 | public string? Reason => null; 181 | 182 | public bool IsFinal => true; 183 | 184 | public bool IgnoreJobLoadException => false; 185 | 186 | public Dictionary SerializeData() => new(); 187 | } 188 | 189 | public class MockFailedState : IState 190 | { 191 | public string Name => FailedState.StateName; 192 | 193 | public string? Reason => null; 194 | 195 | public bool IsFinal => false; 196 | 197 | public bool IgnoreJobLoadException => false; 198 | 199 | public Dictionary SerializeData() => new(); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Storage/ConsoleStorage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using Hangfire.Common; 5 | using Hangfire.Console.Serialization; 6 | using Hangfire.Storage; 7 | 8 | namespace Hangfire.Console.Storage; 9 | 10 | internal class ConsoleStorage : IConsoleStorage 11 | { 12 | private const int ValueFieldLimit = 256; 13 | 14 | private readonly JobStorageConnection _connection; 15 | 16 | public ConsoleStorage(IStorageConnection connection) 17 | { 18 | if (connection == null) 19 | { 20 | throw new ArgumentNullException(nameof(connection)); 21 | } 22 | 23 | if (!(connection is JobStorageConnection jobStorageConnection)) 24 | { 25 | throw new NotSupportedException("Storage connections must implement JobStorageConnection"); 26 | } 27 | 28 | _connection = jobStorageConnection; 29 | } 30 | 31 | public void Dispose() 32 | { 33 | _connection.Dispose(); 34 | } 35 | 36 | public void InitConsole(ConsoleId consoleId) 37 | { 38 | if (consoleId == null) 39 | { 40 | throw new ArgumentNullException(nameof(consoleId)); 41 | } 42 | 43 | // We add an extra "jobId" record into Hash for console, 44 | // to correctly track TTL even if console contains no lines 45 | 46 | using var transaction = _connection.CreateWriteTransaction(); 47 | 48 | if (transaction is not JobStorageTransaction) 49 | { 50 | throw new NotSupportedException("Storage tranactions must implement JobStorageTransaction"); 51 | } 52 | 53 | transaction.SetRangeInHash(consoleId.GetHashKey(), new[] { new KeyValuePair("jobId", consoleId.JobId) }); 54 | 55 | transaction.Commit(); 56 | } 57 | 58 | public void AddLine(ConsoleId consoleId, ConsoleLine line) 59 | { 60 | if (consoleId == null) 61 | { 62 | throw new ArgumentNullException(nameof(consoleId)); 63 | } 64 | 65 | if (line == null) 66 | { 67 | throw new ArgumentNullException(nameof(line)); 68 | } 69 | 70 | if (line.IsReference) 71 | { 72 | throw new ArgumentException("Cannot add reference directly", nameof(line)); 73 | } 74 | 75 | using var tran = _connection.CreateWriteTransaction(); 76 | 77 | // check if encoded message fits into Set's Value field 78 | 79 | string? value; 80 | 81 | if (line.Message.Length > ValueFieldLimit - 36) 82 | { 83 | // pretty sure it won't fit 84 | // (36 is an upper bound for JSON formatting, TimeOffset and TextColor) 85 | value = null; 86 | } 87 | else 88 | { 89 | // try to encode and see if it fits 90 | value = SerializationHelper.Serialize(line); 91 | 92 | if (value.Length > ValueFieldLimit) 93 | { 94 | value = null; 95 | } 96 | } 97 | 98 | if (value == null) 99 | { 100 | var referenceKey = Guid.NewGuid().ToString("N"); 101 | 102 | tran.SetRangeInHash(consoleId.GetHashKey(), new[] { new KeyValuePair(referenceKey, line.Message) }); 103 | 104 | line.Message = referenceKey; 105 | line.IsReference = true; 106 | 107 | value = SerializationHelper.Serialize(line); 108 | } 109 | 110 | tran.AddToSet(consoleId.GetSetKey(), value, line.TimeOffset); 111 | 112 | if (line.ProgressValue.HasValue && line.Message == "1") 113 | { 114 | var progress = line.ProgressValue.Value.ToString(CultureInfo.InvariantCulture); 115 | 116 | tran.SetRangeInHash(consoleId.GetHashKey(), new[] { new KeyValuePair("progress", progress) }); 117 | } 118 | 119 | tran.Commit(); 120 | } 121 | 122 | public TimeSpan GetConsoleTtl(ConsoleId consoleId) 123 | { 124 | if (consoleId == null) 125 | { 126 | throw new ArgumentNullException(nameof(consoleId)); 127 | } 128 | 129 | return _connection.GetHashTtl(consoleId.GetHashKey()); 130 | } 131 | 132 | public void Expire(ConsoleId consoleId, TimeSpan expireIn) 133 | { 134 | if (consoleId == null) 135 | { 136 | throw new ArgumentNullException(nameof(consoleId)); 137 | } 138 | 139 | using var tran = (JobStorageTransaction)_connection.CreateWriteTransaction(); 140 | using var expiration = new ConsoleExpirationTransaction(tran); 141 | 142 | expiration.Expire(consoleId, expireIn); 143 | 144 | tran.Commit(); 145 | } 146 | 147 | public int GetLineCount(ConsoleId consoleId) 148 | { 149 | if (consoleId == null) 150 | { 151 | throw new ArgumentNullException(nameof(consoleId)); 152 | } 153 | 154 | var result = (int)_connection.GetSetCount(consoleId.GetSetKey()); 155 | 156 | if (result == 0) 157 | { 158 | // Read operations should be backwards compatible and use 159 | // old keys, if new one don't contain any data. 160 | return (int)_connection.GetSetCount(consoleId.GetOldConsoleKey()); 161 | } 162 | 163 | return result; 164 | } 165 | 166 | public IEnumerable GetLines(ConsoleId consoleId, int start, int end) 167 | { 168 | if (consoleId == null) 169 | { 170 | throw new ArgumentNullException(nameof(consoleId)); 171 | } 172 | 173 | var useOldKeys = false; 174 | var items = _connection.GetRangeFromSet(consoleId.GetSetKey(), start, end); 175 | 176 | if (items == null || items.Count == 0) 177 | { 178 | // Read operations should be backwards compatible and use 179 | // old keys, if new one don't contain any data. 180 | items = _connection.GetRangeFromSet(consoleId.GetOldConsoleKey(), start, end); 181 | useOldKeys = true; 182 | } 183 | 184 | foreach (var item in items) 185 | { 186 | var line = SerializationHelper.Deserialize(item); 187 | 188 | if (line.IsReference) 189 | { 190 | if (useOldKeys) 191 | { 192 | try 193 | { 194 | line.Message = _connection.GetValueFromHash(consoleId.GetOldConsoleKey(), line.Message); 195 | } 196 | catch 197 | { 198 | // This may happen, when using Hangfire.Redis storage and having 199 | // background job, whose console session was stored using old key 200 | // format. 201 | } 202 | } 203 | else 204 | { 205 | line.Message = _connection.GetValueFromHash(consoleId.GetHashKey(), line.Message); 206 | } 207 | 208 | line.IsReference = false; 209 | } 210 | 211 | yield return line; 212 | } 213 | } 214 | 215 | public StateData? GetState(ConsoleId consoleId) 216 | { 217 | if (consoleId == null) 218 | { 219 | throw new ArgumentNullException(nameof(consoleId)); 220 | } 221 | 222 | return _connection.GetStateData(consoleId.JobId); 223 | } 224 | 225 | public double? GetProgress(ConsoleId consoleId) 226 | { 227 | if (consoleId == null) 228 | { 229 | throw new ArgumentNullException(nameof(consoleId)); 230 | } 231 | 232 | var progress = _connection.GetValueFromHash(consoleId.GetHashKey(), "progress"); 233 | if (string.IsNullOrEmpty(progress)) 234 | { 235 | // progress value is not set 236 | return null; 237 | } 238 | 239 | try 240 | { 241 | return double.Parse(progress, CultureInfo.InvariantCulture); 242 | } 243 | catch (Exception) 244 | { 245 | // corrupted data? 246 | return null; 247 | } 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/Server/ConsoleServerFilterFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Hangfire.Common; 4 | using Hangfire.Console.Server; 5 | using Hangfire.Server; 6 | using Hangfire.States; 7 | using Hangfire.Storage; 8 | using Moq; 9 | using Xunit; 10 | 11 | namespace Hangfire.Console.Tests.Server; 12 | 13 | public class ConsoleServerFilterFacts 14 | { 15 | private readonly Mock _cancellationToken; 16 | 17 | private readonly Mock _connection; 18 | 19 | private readonly Mock _jobStorage; 20 | 21 | private readonly Mock _otherFilter; 22 | 23 | private readonly Mock _transaction; 24 | 25 | public ConsoleServerFilterFacts() 26 | { 27 | _otherFilter = new Mock(); 28 | _cancellationToken = new Mock(); 29 | _connection = new Mock(); 30 | _transaction = new Mock(); 31 | _jobStorage = new Mock(); 32 | 33 | _connection.Setup(x => x.CreateWriteTransaction()) 34 | .Returns(_transaction.Object); 35 | } 36 | 37 | [Fact] 38 | public void DoesNotCreateConsoleContext_IfStateNotFound() 39 | { 40 | _connection.Setup(x => x.GetStateData("1")) 41 | .Returns((StateData)null!); 42 | 43 | var performer = new BackgroundJobPerformer(CreateJobFilterProvider()); 44 | var context = CreatePerformContext(); 45 | 46 | performer.Perform(context); 47 | 48 | var consoleContext = ConsoleContext.FromPerformContext(context); 49 | Assert.Null(consoleContext); 50 | } 51 | 52 | [Fact] 53 | public void DoesNotCreateConsoleContext_IfStateIsNotProcessing() 54 | { 55 | _connection.Setup(x => x.GetStateData("1")) 56 | .Returns(CreateState(SucceededState.StateName)); 57 | 58 | var performer = new BackgroundJobPerformer(CreateJobFilterProvider()); 59 | var context = CreatePerformContext(); 60 | 61 | performer.Perform(context); 62 | 63 | var consoleContext = ConsoleContext.FromPerformContext(context); 64 | Assert.Null(consoleContext); 65 | } 66 | 67 | [Fact] 68 | public void CreatesConsoleContext_IfStateIsProcessing_DoesNotExpireData_IfConsoleNotPresent() 69 | { 70 | _connection.Setup(x => x.GetStateData("1")) 71 | .Returns(CreateState(ProcessingState.StateName)); 72 | _otherFilter.Setup(x => x.OnPerforming(It.IsAny())) 73 | .Callback(x => { x.Items.Remove("ConsoleContext"); }); 74 | 75 | var performer = new BackgroundJobPerformer(CreateJobFilterProvider()); 76 | var context = CreatePerformContext(); 77 | 78 | performer.Perform(context); 79 | 80 | var consoleContext = ConsoleContext.FromPerformContext(context); 81 | Assert.Null(consoleContext); 82 | 83 | _transaction.Verify(x => x.Commit(), Times.Never); 84 | } 85 | 86 | [Fact] 87 | public void CreatesConsoleContext_IfStateIsProcessing_FixesExpiration_IfFollowsJobRetention() 88 | { 89 | _connection.Setup(x => x.GetStateData("1")) 90 | .Returns(CreateState(ProcessingState.StateName)); 91 | _connection.Setup(x => x.GetHashTtl(It.IsAny())) 92 | .Returns(TimeSpan.FromSeconds(1)); 93 | 94 | var performer = new BackgroundJobPerformer(CreateJobFilterProvider(true)); 95 | var context = CreatePerformContext(); 96 | 97 | performer.Perform(context); 98 | 99 | var consoleContext = ConsoleContext.FromPerformContext(context); 100 | Assert.Null(consoleContext); 101 | 102 | _connection.Verify(x => x.GetHashTtl(It.IsAny())); 103 | 104 | _transaction.Verify(x => x.ExpireSet(It.IsAny(), It.IsAny())); 105 | _transaction.Verify(x => x.ExpireHash(It.IsAny(), It.IsAny())); 106 | 107 | _transaction.Verify(x => x.Commit()); 108 | } 109 | 110 | [Fact] 111 | public void CreatesConsoleContext_IfStateIsProcessing_DoesNotFixExpiration_IfNegativeTtl_AndFollowsJobRetention() 112 | { 113 | _connection.Setup(x => x.GetStateData("1")) 114 | .Returns(CreateState(ProcessingState.StateName)); 115 | _connection.Setup(x => x.GetHashTtl(It.IsAny())) 116 | .Returns(TimeSpan.FromSeconds(-1)); 117 | 118 | var performer = new BackgroundJobPerformer(CreateJobFilterProvider(true)); 119 | var context = CreatePerformContext(); 120 | 121 | performer.Perform(context); 122 | 123 | var consoleContext = ConsoleContext.FromPerformContext(context); 124 | Assert.Null(consoleContext); 125 | 126 | _connection.Verify(x => x.GetHashTtl(It.IsAny())); 127 | 128 | _transaction.Verify(x => x.Commit(), Times.Never); 129 | } 130 | 131 | [Fact] 132 | public void CreatesConsoleContext_IfStateIsProcessing_DoesNotFixExpiration_IfZeroTtl_AndFollowsJobRetention() 133 | { 134 | _connection.Setup(x => x.GetStateData("1")) 135 | .Returns(CreateState(ProcessingState.StateName)); 136 | _connection.Setup(x => x.GetHashTtl(It.IsAny())) 137 | .Returns(TimeSpan.Zero); 138 | 139 | var performer = new BackgroundJobPerformer(CreateJobFilterProvider(true)); 140 | var context = CreatePerformContext(); 141 | 142 | performer.Perform(context); 143 | 144 | var consoleContext = ConsoleContext.FromPerformContext(context); 145 | Assert.Null(consoleContext); 146 | 147 | _connection.Verify(x => x.GetHashTtl(It.IsAny())); 148 | 149 | _transaction.Verify(x => x.Commit(), Times.Never); 150 | } 151 | 152 | [Fact] 153 | public void CreatesConsoleContext_IfStateIsProcessing_ExpiresData_IfNotFollowsJobRetention() 154 | { 155 | _connection.Setup(x => x.GetStateData("1")) 156 | .Returns(CreateState(ProcessingState.StateName)); 157 | 158 | var performer = new BackgroundJobPerformer(CreateJobFilterProvider()); 159 | var context = CreatePerformContext(); 160 | 161 | performer.Perform(context); 162 | 163 | var consoleContext = ConsoleContext.FromPerformContext(context); 164 | Assert.Null(consoleContext); 165 | 166 | _connection.Verify(x => x.GetHashTtl(It.IsAny()), Times.Never); 167 | 168 | _transaction.Verify(x => x.ExpireSet(It.IsAny(), It.IsAny())); 169 | _transaction.Verify(x => x.ExpireHash(It.IsAny(), It.IsAny())); 170 | 171 | _transaction.Verify(x => x.Commit()); 172 | } 173 | 174 | // ReSharper disable once RedundantDisableWarningComment 175 | #pragma warning disable xUnit1013 176 | 177 | // ReSharper disable once MemberCanBePrivate.Global 178 | public static void JobMethod(PerformContext context) 179 | #pragma warning restore xUnit1013 180 | { 181 | // reset transaction method calls after OnPerforming is completed 182 | var @this = (ConsoleServerFilterFacts)context.Items["this"]; 183 | @this._transaction.Invocations.Clear(); 184 | } 185 | 186 | private IJobFilterProvider CreateJobFilterProvider(bool followJobRetention = false) 187 | { 188 | var filters = new JobFilterCollection 189 | { 190 | new ConsoleServerFilter(new ConsoleOptions { FollowJobRetentionPolicy = followJobRetention }), 191 | _otherFilter.Object 192 | }; 193 | return new JobFilterProviderCollection(filters); 194 | } 195 | 196 | private PerformContext CreatePerformContext() 197 | { 198 | var context = new PerformContext( 199 | _jobStorage.Object, 200 | _connection.Object, 201 | new BackgroundJob("1", Job.FromExpression(() => JobMethod(null!)), DateTime.UtcNow), 202 | _cancellationToken.Object) 203 | { 204 | Items = 205 | { 206 | ["this"] = this 207 | } 208 | }; 209 | return context; 210 | } 211 | 212 | private StateData CreateState(string stateName) => new() 213 | { 214 | Name = stateName, 215 | Data = new Dictionary 216 | { 217 | ["StartedAt"] = JobHelper.SerializeDateTime(DateTime.UtcNow) 218 | } 219 | }; 220 | } 221 | -------------------------------------------------------------------------------- /src/Hangfire.Console/ConsoleExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Console.Progress; 3 | using Hangfire.Console.Server; 4 | using Hangfire.Server; 5 | using JetBrains.Annotations; 6 | 7 | namespace Hangfire.Console; 8 | 9 | /// 10 | /// Provides extension methods for writing to console. 11 | /// 12 | [PublicAPI] 13 | public static class ConsoleExtensions 14 | { 15 | /// 16 | /// Sets text color for next console lines. 17 | /// 18 | /// Context 19 | /// Text color to use 20 | public static void SetTextColor(this PerformContext context, ConsoleTextColor color) 21 | { 22 | if (color == null) 23 | { 24 | throw new ArgumentNullException(nameof(color)); 25 | } 26 | 27 | var consoleContext = ConsoleContext.FromPerformContext(context); 28 | if (consoleContext == null) 29 | { 30 | return; 31 | } 32 | 33 | consoleContext.TextColor = color; 34 | } 35 | 36 | /// 37 | /// Resets text color for next console lines. 38 | /// 39 | /// Context 40 | public static void ResetTextColor(this PerformContext context) 41 | { 42 | var consoleContext = ConsoleContext.FromPerformContext(context); 43 | if (consoleContext == null) 44 | { 45 | return; 46 | } 47 | 48 | consoleContext.TextColor = null; 49 | } 50 | 51 | /// 52 | /// Adds an updateable progress bar to console. 53 | /// 54 | /// Context 55 | /// Initial value 56 | /// Progress bar color 57 | /// The number of fractional digits or decimal places to use for the progress bar 58 | public static IProgressBar WriteProgressBar(this PerformContext context, int value = 0, ConsoleTextColor? color = null, int digits = 0) 59 | => ConsoleContext.FromPerformContext(context)?.WriteProgressBar(null, value, color, digits) ?? new NoOpProgressBar(); 60 | 61 | /// 62 | /// Adds an updateable named progress bar to console. 63 | /// 64 | /// Context 65 | /// Name 66 | /// Initial value 67 | /// Progress bar color 68 | /// The number of fractional digits or decimal places to use for the progress bar 69 | public static IProgressBar WriteProgressBar(this PerformContext context, string name, double value = 0, ConsoleTextColor? color = null, int digits = 0) 70 | => ConsoleContext.FromPerformContext(context)?.WriteProgressBar(name, value, color, digits) ?? new NoOpProgressBar(); 71 | 72 | /// 73 | /// Adds a string to console. 74 | /// 75 | /// Context 76 | /// String 77 | public static void WriteLine(this PerformContext context, string? value) 78 | => ConsoleContext.FromPerformContext(context)?.WriteLine(value, null); 79 | 80 | /// 81 | /// Adds a string to console. 82 | /// 83 | /// Context 84 | /// Text color 85 | /// String 86 | public static void WriteLine(this PerformContext context, ConsoleTextColor? color, string? value) 87 | => ConsoleContext.FromPerformContext(context)?.WriteLine(value, color); 88 | 89 | /// 90 | /// Adds an empty line to console. 91 | /// 92 | /// Context 93 | public static void WriteLine(this PerformContext context) 94 | => WriteLine(context, ""); 95 | 96 | /// 97 | /// Adds a value to a console. 98 | /// 99 | /// Context 100 | /// Value 101 | public static void WriteLine(this PerformContext context, object? value) 102 | => WriteLine(context, value?.ToString()); 103 | 104 | /// 105 | /// Adds a formatted string to a console. 106 | /// 107 | /// Context 108 | /// Format string 109 | /// Argument 110 | [StringFormatMethod("format")] 111 | public static void WriteLine(this PerformContext context, string format, object arg0) 112 | => WriteLine(context, string.Format(format, arg0)); 113 | 114 | /// 115 | /// Adds a formatted string to a console. 116 | /// 117 | /// Context 118 | /// Format string 119 | /// Argument 120 | /// Argument 121 | [StringFormatMethod("format")] 122 | public static void WriteLine(this PerformContext context, string format, object arg0, object arg1) 123 | => WriteLine(context, string.Format(format, arg0, arg1)); 124 | 125 | /// 126 | /// Adds a formatted string to a console. 127 | /// 128 | /// Context 129 | /// Format string 130 | /// Argument 131 | /// Argument 132 | /// Argument 133 | [StringFormatMethod("format")] 134 | public static void WriteLine(this PerformContext context, string format, object arg0, object arg1, object arg2) 135 | => WriteLine(context, string.Format(format, arg0, arg1, arg2)); 136 | 137 | /// 138 | /// Adds a formatted string to a console. 139 | /// 140 | /// Context 141 | /// Format string 142 | /// Arguments 143 | [StringFormatMethod("format")] 144 | public static void WriteLine(this PerformContext context, string format, params object[] args) 145 | => WriteLine(context, string.Format(format, args)); 146 | 147 | /// 148 | /// Adds a value to a console. 149 | /// 150 | /// Context 151 | /// Text color 152 | /// Value 153 | public static void WriteLine(this PerformContext context, ConsoleTextColor? color, object? value) 154 | => WriteLine(context, color, value?.ToString()); 155 | 156 | /// 157 | /// Adds a formatted string to a console. 158 | /// 159 | /// Context 160 | /// Text color 161 | /// Format string 162 | /// Argument 163 | [StringFormatMethod("format")] 164 | public static void WriteLine(this PerformContext context, ConsoleTextColor? color, string format, object arg0) 165 | => WriteLine(context, color, string.Format(format, arg0)); 166 | 167 | /// 168 | /// Adds a formatted string to a console. 169 | /// 170 | /// Context 171 | /// Text color 172 | /// Format string 173 | /// Argument 174 | /// Argument 175 | [StringFormatMethod("format")] 176 | public static void WriteLine(this PerformContext context, ConsoleTextColor? color, string format, object arg0, object arg1) 177 | => WriteLine(context, color, string.Format(format, arg0, arg1)); 178 | 179 | /// 180 | /// Adds a formatted string to a console. 181 | /// 182 | /// Context 183 | /// Text color 184 | /// Format string 185 | /// Argument 186 | /// Argument 187 | /// Argument 188 | [StringFormatMethod("format")] 189 | public static void WriteLine(this PerformContext context, ConsoleTextColor? color, string format, object arg0, object arg1, object arg2) 190 | => WriteLine(context, color, string.Format(format, arg0, arg1, arg2)); 191 | 192 | /// 193 | /// Adds a formatted string to a console. 194 | /// 195 | /// Context 196 | /// Text color 197 | /// Format string 198 | /// Arguments 199 | [StringFormatMethod("format")] 200 | public static void WriteLine(this PerformContext context, ConsoleTextColor? color, string format, params object[] args) 201 | => WriteLine(context, color, string.Format(format, args)); 202 | } 203 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Dashboard/ConsoleRenderer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Text; 5 | using System.Text.RegularExpressions; 6 | using Hangfire.Common; 7 | using Hangfire.Console.Serialization; 8 | using Hangfire.Console.Storage; 9 | using Hangfire.Dashboard; 10 | using Hangfire.States; 11 | 12 | namespace Hangfire.Console.Dashboard; 13 | 14 | /// 15 | /// Helper methods to render console shared between 16 | /// and . 17 | /// 18 | internal static class ConsoleRenderer 19 | { 20 | private static readonly HtmlHelper Helper = new(new DummyPage()); 21 | 22 | // Reference: http://www.regexguru.com/2008/11/detecting-urls-in-a-block-of-text/ 23 | private static readonly Regex LinkDetector = new(@" 24 | \b(?:(?(?:f|ht)tps?://)|www\.|ftp\.) 25 | (?:\([-\w+&@#/%=~|$?!:,.]*\)|[-\w+&@#/%=~|$?!:,.])* 26 | (?:\([-\w+&@#/%=~|$?!:,.]*\)|[\w+&@#/%=~|$])", 27 | RegexOptions.IgnorePatternWhitespace | RegexOptions.IgnoreCase | RegexOptions.Compiled); 28 | 29 | /// 30 | /// Renders text string (with possible hyperlinks) into buffer 31 | /// 32 | /// Buffer 33 | /// Text to render 34 | public static void RenderText(StringBuilder buffer, string text) 35 | { 36 | if (string.IsNullOrEmpty(text)) 37 | { 38 | return; 39 | } 40 | 41 | var start = 0; 42 | 43 | foreach (Match m in LinkDetector.Matches(text)) 44 | { 45 | if (m.Index > start) 46 | { 47 | buffer.Append(Helper.HtmlEncode(text.Substring(start, m.Index - start))); 48 | } 49 | 50 | var schema = ""; 51 | if (!m.Groups["schema"].Success) 52 | { 53 | // force schema for links without one (like www.google.com) 54 | schema = m.Value.StartsWith("ftp.", StringComparison.OrdinalIgnoreCase) ? "ftp://" : "https://"; 55 | } 56 | 57 | buffer.Append("") 59 | .Append(Helper.HtmlEncode(m.Value)) 60 | .Append(""); 61 | 62 | start = m.Index + m.Length; 63 | } 64 | 65 | if (start < text.Length) 66 | { 67 | buffer.Append(Helper.HtmlEncode(text.Substring(start))); 68 | } 69 | } 70 | 71 | /// 72 | /// Renders a single to a buffer. 73 | /// 74 | /// Buffer 75 | /// Line 76 | /// Reference timestamp for time offset 77 | public static void RenderLine(StringBuilder builder, ConsoleLine line, DateTime timestamp) 78 | { 79 | var offset = TimeSpan.FromSeconds(line.TimeOffset); 80 | var isProgressBar = line.ProgressValue.HasValue; 81 | 82 | builder.Append("
"); 95 | 96 | if (isProgressBar && !string.IsNullOrWhiteSpace(line.ProgressName)) 97 | { 98 | builder.Append(Helper.MomentTitle(timestamp + offset, Helper.HtmlEncode(line.ProgressName))); 99 | } 100 | else 101 | { 102 | builder.Append(Helper.MomentTitle(timestamp + offset, Helper.ToHumanDuration(offset))); 103 | } 104 | 105 | if (isProgressBar) 106 | { 107 | builder.AppendFormat(CultureInfo.InvariantCulture, "
", line.ProgressValue!.Value); 108 | } 109 | else 110 | { 111 | RenderText(builder, line.Message); 112 | } 113 | 114 | builder.Append("
"); 115 | } 116 | 117 | /// 118 | /// Renders a collection of to a buffer. 119 | /// 120 | /// Buffer 121 | /// Lines 122 | /// Reference timestamp for time offset 123 | public static void RenderLines(StringBuilder builder, IEnumerable? lines, DateTime timestamp) 124 | { 125 | if (builder == null) 126 | { 127 | throw new ArgumentNullException(nameof(builder)); 128 | } 129 | 130 | if (lines == null) 131 | { 132 | return; 133 | } 134 | 135 | foreach (var line in lines) 136 | { 137 | RenderLine(builder, line, timestamp); 138 | } 139 | } 140 | 141 | /// 142 | /// Fetches and renders console line buffer. 143 | /// 144 | /// Buffer 145 | /// Console data accessor 146 | /// Console identifier 147 | /// Offset to read lines from 148 | public static void RenderLineBuffer(StringBuilder builder, IConsoleStorage storage, ConsoleId consoleId, int start) 149 | { 150 | if (builder == null) 151 | { 152 | throw new ArgumentNullException(nameof(builder)); 153 | } 154 | 155 | if (storage == null) 156 | { 157 | throw new ArgumentNullException(nameof(storage)); 158 | } 159 | 160 | if (consoleId == null) 161 | { 162 | throw new ArgumentNullException(nameof(consoleId)); 163 | } 164 | 165 | var items = ReadLines(storage, consoleId, ref start); 166 | 167 | builder.AppendFormat(CultureInfo.InvariantCulture, "
", start); 168 | RenderLines(builder, items, consoleId.DateValue); 169 | builder.Append("
"); 170 | } 171 | 172 | /// 173 | /// Fetches console lines from storage. 174 | /// 175 | /// Console data accessor 176 | /// Console identifier 177 | /// Offset to read lines from 178 | /// 179 | /// On completion, is set to the end of the current batch, 180 | /// and can be used for next requests (or set to -1, if the job has finished processing). 181 | /// 182 | private static IEnumerable? ReadLines(IConsoleStorage storage, ConsoleId consoleId, ref int start) 183 | { 184 | if (start < 0) 185 | { 186 | return null; 187 | } 188 | 189 | var count = storage.GetLineCount(consoleId); 190 | var result = new List(Math.Max(1, count - start)); 191 | 192 | if (count > start) 193 | { 194 | // has some new items to fetch 195 | 196 | Dictionary? progressBars = null; 197 | 198 | foreach (var entry in storage.GetLines(consoleId, start, count - 1)) 199 | { 200 | if (entry.ProgressValue.HasValue) 201 | { 202 | // aggregate progress value updates into single record 203 | 204 | if (progressBars != null) 205 | { 206 | if (progressBars.TryGetValue(entry.Message, out var prev)) 207 | { 208 | prev.ProgressValue = entry.ProgressValue; 209 | prev.TextColor = entry.TextColor; 210 | continue; 211 | } 212 | } 213 | else 214 | { 215 | progressBars = new Dictionary(); 216 | } 217 | 218 | progressBars.Add(entry.Message, entry); 219 | } 220 | 221 | result.Add(entry); 222 | } 223 | } 224 | 225 | if (count <= start || start == 0) 226 | { 227 | // no new items or initial load, check if the job is still performing 228 | 229 | var state = storage.GetState(consoleId); 230 | if (state == null) 231 | { 232 | // No state found for a job, probably it was deleted 233 | count = -2; 234 | } 235 | else 236 | { 237 | if (!string.Equals(state.Name, ProcessingState.StateName, StringComparison.OrdinalIgnoreCase) || 238 | !consoleId.Equals(new ConsoleId(consoleId.JobId, JobHelper.DeserializeDateTime(state.Data["StartedAt"])))) 239 | { 240 | // Job state has changed (either not Processing, or another Processing with different console id) 241 | count = -1; 242 | } 243 | } 244 | } 245 | 246 | start = count; 247 | return result; 248 | } 249 | 250 | private class DummyPage : RazorPage 251 | { 252 | public override void Execute() { } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/Hangfire.Console/Resources/script.js: -------------------------------------------------------------------------------- 1 | (function ($, hangfire) { 2 | var pollUrl = hangfire.config.consolePollUrl; 3 | var pollInterval = hangfire.config.consolePollInterval; 4 | if (!pollUrl || !pollInterval) 5 | throw new Error("Hangfire.Console was not properly configured"); 6 | 7 | hangfire.LineBuffer = (function () { 8 | function updateMoments(container) { 9 | $(".line span[data-moment-title]:not([title])", container).each(function () { 10 | var $this = $(this), 11 | time = moment($this.data('moment-title'), 'X'); 12 | $this.prop('title', time.format('llll')) 13 | .attr('data-container', 'body'); 14 | }); 15 | } 16 | 17 | function LineBuffer(el) { 18 | if (!el || el.length !== 1) 19 | throw new Error("LineBuffer expects jQuery object with a single value"); 20 | 21 | this._el = el; 22 | this._n = parseInt(el.data('n')) || 0; 23 | updateMoments(el); 24 | } 25 | 26 | LineBuffer.prototype.replaceWith = function (other) { 27 | if (!(other instanceof LineBuffer)) 28 | throw new Error("LineBuffer.replaceWith() expects LineBuffer argument"); 29 | 30 | this._el.replaceWith(other._el); 31 | 32 | this._n = other._n; 33 | this._el = other._el; 34 | 35 | $(".line span[data-moment-title]", this._el).tooltip(); 36 | }; 37 | 38 | LineBuffer.prototype.append = function (other) { 39 | if (!other) return; 40 | 41 | if (!(other instanceof LineBuffer)) 42 | throw new Error("LineBuffer.append() expects LineBuffer argument"); 43 | 44 | var el = this._el; 45 | 46 | $(".line.pb", other._el).each(function () { 47 | var $this = $(this), 48 | $id = $this.data('id'); 49 | 50 | var pv = $(".line.pb[data-id='" + $id + "'] .pv", el); 51 | if (pv.length === 0) return; 52 | 53 | var $pv = $(".pv", $this); 54 | 55 | pv.attr("style", $pv.attr("style")) 56 | .attr("data-value", $pv.attr("data-value")); 57 | $this.addClass("ignore"); 58 | }); 59 | 60 | $(".line:not(.ignore)", other._el).addClass("new").appendTo(el); 61 | 62 | this._n = other._n; 63 | 64 | $(".line span[data-moment-title]", el).tooltip(); 65 | }; 66 | 67 | LineBuffer.prototype.next = function () { 68 | return this._n; 69 | }; 70 | 71 | LineBuffer.prototype.unmarkNew = function () { 72 | $(".line.new", this._el).removeClass("new"); 73 | }; 74 | 75 | LineBuffer.prototype.getHTMLElement = function () { 76 | return this._el[0]; 77 | }; 78 | 79 | return LineBuffer; 80 | })(); 81 | 82 | hangfire.Console = (function () { 83 | function Console(el) { 84 | if (!el || el.length !== 1) 85 | throw new Error("Console expects jQuery object with a single value"); 86 | 87 | this._el = el; 88 | this._id = el.data('id'); 89 | this._buffer = new hangfire.LineBuffer($(".line-buffer", el)); 90 | this._polling = false; 91 | } 92 | 93 | Console.prototype.reload = function () { 94 | var self = this; 95 | 96 | $.get(pollUrl + this._id, null, function (data) { 97 | self._buffer.replaceWith(new hangfire.LineBuffer($(data))); 98 | }, "html"); 99 | }; 100 | 101 | function resizeHandler(e) { 102 | var obj = e.target || e.srcElement, 103 | $buffer = $(obj).closest(".line-buffer"), 104 | $console = $buffer.closest(".console"); 105 | 106 | if (0 === $(".line:first", $buffer).length) { 107 | // collapse console area if there's no lines 108 | $console.height(0); 109 | return; 110 | } 111 | 112 | $console.height($buffer.outerHeight(false)); 113 | } 114 | 115 | Console.prototype.poll = function () { 116 | if (this._polling) return; 117 | 118 | if (this._buffer.next() < 0) return; 119 | 120 | var self = this; 121 | 122 | this._polling = true; 123 | this._el.addClass('active'); 124 | 125 | resizeHandler({target: this._buffer.getHTMLElement()}); 126 | window.addResizeListener(this._buffer.getHTMLElement(), resizeHandler); 127 | 128 | console.log("polling was started"); 129 | 130 | setTimeout(function () { 131 | self._poll(); 132 | }, pollInterval); 133 | }; 134 | 135 | Console.prototype._poll = function () { 136 | this._buffer.unmarkNew(); 137 | 138 | var next = this._buffer.next(); 139 | if (next < 0) { 140 | this._endPoll(); 141 | 142 | if (next === -1) { 143 | console.log("job state change detected"); 144 | location.reload(); 145 | } 146 | 147 | return; 148 | } 149 | 150 | var self = this; 151 | 152 | $.get(pollUrl + this._id, {start: next}, function (data) { 153 | var $data = $(data), 154 | buffer = new hangfire.LineBuffer($data), 155 | newLines = $(".line:not(.pb)", $data); 156 | self._buffer.append(buffer); 157 | self._el.toggleClass("waiting", newLines.length === 0); 158 | }, "html") 159 | 160 | .always(function () { 161 | setTimeout(function () { 162 | self._poll(); 163 | }, pollInterval); 164 | }); 165 | }; 166 | 167 | Console.prototype._endPoll = function () { 168 | console.log("polling was terminated"); 169 | 170 | window.removeResizeListener(this._buffer.getHTMLElement(), resizeHandler); 171 | 172 | this._el.removeClass("active waiting"); 173 | this._polling = false; 174 | }; 175 | 176 | return Console; 177 | })(); 178 | 179 | hangfire.JobProgress = (function () { 180 | function JobProgress(row) { 181 | if (!row || row.length !== 1) 182 | throw new Error("JobProgress expects jQuery object with a single value"); 183 | 184 | this._row = row; 185 | this._progress = null; 186 | this._bar = null; 187 | this._value = null; 188 | } 189 | 190 | JobProgress.prototype._create = function () { 191 | var parent = $('td:last-child', this._row); 192 | this._progress = $('
\n' + 193 | ' \n' + 194 | ' \n' + 195 | ' \n' + 196 | ' \n' + 197 | '
').prependTo(parent); 198 | this._bar = $('.bar', this._progress); 199 | }; 200 | 201 | JobProgress.prototype._destroy = function () { 202 | if (this._progress) 203 | this._progress.remove(); 204 | 205 | this._progress = null; 206 | this._bar = null; 207 | this._value = null; 208 | }; 209 | 210 | JobProgress.prototype.update = function (value) { 211 | if (typeof value !== 'number' || value < 0) { 212 | this._destroy(); 213 | return; 214 | } 215 | 216 | value = Math.min(Math.round(value), 100); 217 | 218 | if (!this._progress) { 219 | this._create(); 220 | } else if (this._value === value) { 221 | return; 222 | } 223 | 224 | var r = this._bar.attr('r'), 225 | c = Math.PI * r * 2; 226 | 227 | this._bar.css('stroke-dashoffset', ((100 - value) / 100) * c); 228 | this._progress.attr('data-value', value); 229 | this._value = value; 230 | }; 231 | 232 | return JobProgress; 233 | })(); 234 | 235 | hangfire.JobProgressPoller = (function () { 236 | function JobProgressPoller() { 237 | var jobsProgress = {}; 238 | $(".js-jobs-list-row").each(function () { 239 | var $this = $(this), 240 | jobId = $("input[name='jobs[]']", $this).val(); 241 | if (jobId) 242 | jobsProgress[jobId] = new Hangfire.JobProgress($this); 243 | }); 244 | 245 | this._jobsProgress = jobsProgress; 246 | this._jobIds = Object.getOwnPropertyNames(jobsProgress); 247 | this._timerId = null; 248 | this._timerCallback = null; 249 | } 250 | 251 | JobProgressPoller.prototype.start = function () { 252 | if (this._jobIds.length === 0) return; 253 | 254 | var self = this; 255 | this._timerCallback = function () { 256 | $.post(pollUrl + 'progress', {'jobs[]': self._jobIds}, function (data) { 257 | var jobsProgress = self._jobsProgress; 258 | Object.getOwnPropertyNames(data).forEach(function (jobId) { 259 | var progress = jobsProgress[jobId], 260 | value = data[jobId]; 261 | if (progress) 262 | progress.update(value); 263 | }); 264 | }).always(function () { 265 | if (self._timerCallback) { 266 | self._timerId = setTimeout(self._timerCallback, pollInterval); 267 | } else { 268 | self._timerId = null; 269 | } 270 | }); 271 | }; 272 | 273 | this._timerId = setTimeout(this._timerCallback, 50); 274 | }; 275 | 276 | JobProgressPoller.prototype.stop = function () { 277 | this._timerCallback = null; 278 | if (this._timerId !== null) { 279 | clearTimeout(this._timerId); 280 | this._timerId = null; 281 | } 282 | }; 283 | 284 | return JobProgressPoller; 285 | })(); 286 | 287 | })(jQuery, window.Hangfire = window.Hangfire || {}); 288 | 289 | $(function () { 290 | var path = window.location.pathname; 291 | 292 | if (/\/jobs\/details\/([^/]+)$/.test(path)) { 293 | // execute scripts for /jobs/details/ 294 | 295 | $(".console").each(function (index) { 296 | var $this = $(this), 297 | c = new Hangfire.Console($this); 298 | 299 | $this.data('console', c); 300 | 301 | if (index === 0) { 302 | // poll on the first console 303 | c.poll(); 304 | } else if ($this.find(".line").length > 0) { 305 | // collapse outdated consoles 306 | $this.addClass("collapsed"); 307 | } 308 | }); 309 | 310 | $(".container").on("click", ".console.collapsed", function () { 311 | $(this).removeClass("collapsed"); 312 | }); 313 | 314 | } else if (/\/jobs\/processing$/.test(path)) { 315 | // execute scripts for /jobs/processing 316 | 317 | Hangfire.page._jobProgressPoller = new Hangfire.JobProgressPoller(); 318 | Hangfire.page._jobProgressPoller.start(); 319 | } 320 | }); 321 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/Dashboard/ConsoleRendererFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Hangfire.Common; 6 | using Hangfire.Console.Dashboard; 7 | using Hangfire.Console.Serialization; 8 | using Hangfire.Console.Storage; 9 | using Hangfire.States; 10 | using Hangfire.Storage; 11 | using Moq; 12 | using Xunit; 13 | 14 | namespace Hangfire.Console.Tests.Dashboard; 15 | 16 | public class ConsoleRendererFacts 17 | { 18 | private readonly ConsoleId _consoleId = new("1", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 19 | 20 | private readonly Mock _storage = new(); 21 | 22 | [Fact] 23 | public void RenderText_Empty() 24 | { 25 | var text = ""; 26 | var builder = new StringBuilder(); 27 | 28 | ConsoleRenderer.RenderText(builder, text); 29 | 30 | Assert.Equal("", builder.ToString()); 31 | } 32 | 33 | [Fact] 34 | public void RenderText_Simple() 35 | { 36 | var text = "test"; 37 | var builder = new StringBuilder(); 38 | 39 | ConsoleRenderer.RenderText(builder, text); 40 | 41 | Assert.Equal("test", builder.ToString()); 42 | } 43 | 44 | [Fact] 45 | public void RenderText_HtmlEncode() 46 | { 47 | var text = ""; 48 | var builder = new StringBuilder(); 49 | 50 | ConsoleRenderer.RenderText(builder, text); 51 | 52 | Assert.Equal("<bolts & nuts>", builder.ToString()); 53 | } 54 | 55 | [Fact] 56 | public void RenderText_Hyperlink() 57 | { 58 | var text = "go to http://localhost?a=1&b=2 & enjoy!"; 59 | var builder = new StringBuilder(); 60 | 61 | ConsoleRenderer.RenderText(builder, text); 62 | 63 | Assert.Equal("go to http://localhost?a=1&b=2 & enjoy!", builder.ToString()); 64 | } 65 | 66 | [Fact] 67 | public void RenderLine_Basic() 68 | { 69 | var line = new ConsoleLine { TimeOffset = 0, Message = "test" }; 70 | var builder = new StringBuilder(); 71 | 72 | ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 73 | 74 | Assert.Equal("
+ <1mstest
", builder.ToString()); 75 | } 76 | 77 | [Fact] 78 | public void RenderLine_WithOffset() 79 | { 80 | var line = new ConsoleLine { TimeOffset = 1.108, Message = "test" }; 81 | var builder = new StringBuilder(); 82 | 83 | ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 84 | 85 | Assert.Equal("
+1.108stest
", builder.ToString()); 86 | } 87 | 88 | [Fact] 89 | public void RenderLine_WithNegativeOffset() 90 | { 91 | var line = new ConsoleLine { TimeOffset = -1.206, Message = "test" }; 92 | var builder = new StringBuilder(); 93 | 94 | ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 95 | 96 | Assert.Equal("
-1.206stest
", builder.ToString()); 97 | } 98 | 99 | [Fact] 100 | public void RenderLine_WithColor() 101 | { 102 | var line = new ConsoleLine { TimeOffset = 0, Message = "test", TextColor = "#ffffff" }; 103 | var builder = new StringBuilder(); 104 | 105 | ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 106 | 107 | Assert.Equal("
+ <1mstest
", builder.ToString()); 108 | } 109 | 110 | [Fact] 111 | public void RenderLine_WithProgress() 112 | { 113 | var line = new ConsoleLine { TimeOffset = 0, Message = "3", ProgressValue = 17 }; 114 | var builder = new StringBuilder(); 115 | 116 | ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 117 | 118 | Assert.Equal("
+ <1ms
", builder.ToString()); 119 | } 120 | 121 | [Fact] 122 | public void RenderLine_WithProgressName() 123 | { 124 | var line = new ConsoleLine { TimeOffset = 0, Message = "3", ProgressValue = 17, ProgressName = "test " }; 125 | var builder = new StringBuilder(); 126 | 127 | ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 128 | 129 | Assert.Equal("
test &lt;go&gt;
", builder.ToString()); 130 | } 131 | 132 | [Fact] 133 | public void RenderLine_WithFractionalProgress() 134 | { 135 | var line = new ConsoleLine { TimeOffset = 0, Message = "3", ProgressValue = 17.3 }; 136 | var builder = new StringBuilder(); 137 | 138 | ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 139 | 140 | Assert.Equal("
+ <1ms
", builder.ToString()); 141 | } 142 | 143 | [Fact] 144 | public void RenderLine_WithProgressAndColor() 145 | { 146 | var line = new ConsoleLine { TimeOffset = 0, Message = "3", ProgressValue = 17, TextColor = "#ffffff" }; 147 | var builder = new StringBuilder(); 148 | 149 | ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 150 | 151 | Assert.Equal("
+ <1ms
", builder.ToString()); 152 | } 153 | 154 | [Fact] 155 | public void RenderLines_ThrowsException_IfBuilderIsNull() 156 | { 157 | Assert.Throws("builder", () => ConsoleRenderer.RenderLines(null!, Enumerable.Empty(), DateTime.UtcNow)); 158 | } 159 | 160 | [Fact] 161 | public void RenderLines_RendersNothing_IfLinesIsNull() 162 | { 163 | var builder = new StringBuilder(); 164 | ConsoleRenderer.RenderLines(builder, null, DateTime.UtcNow); 165 | 166 | Assert.Equal(0, builder.Length); 167 | } 168 | 169 | [Fact] 170 | public void RenderLines_RendersNothing_IfLinesIsEmpty() 171 | { 172 | var builder = new StringBuilder(); 173 | ConsoleRenderer.RenderLines(builder, Enumerable.Empty(), DateTime.UtcNow); 174 | 175 | Assert.Equal(0, builder.Length); 176 | } 177 | 178 | [Fact] 179 | public void RenderLines_RendersAllLines() 180 | { 181 | var builder = new StringBuilder(); 182 | ConsoleRenderer.RenderLines(builder, new[] 183 | { 184 | new ConsoleLine { TimeOffset = 0, Message = "line1" }, 185 | new ConsoleLine { TimeOffset = 1, Message = "line2" } 186 | }, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); 187 | 188 | Assert.Equal( 189 | "
+ <1msline1
" + 190 | "
+1sline2
", 191 | builder.ToString()); 192 | } 193 | 194 | [Fact] 195 | public void RenderLineBuffer_ThrowsException_IfBuilderIsNull() 196 | { 197 | Assert.Throws("builder", () => ConsoleRenderer.RenderLineBuffer(null!, _storage.Object, _consoleId, 0)); 198 | } 199 | 200 | [Fact] 201 | public void RenderLineBuffer_ThrowsException_IfStorageIsNull() 202 | { 203 | Assert.Throws("storage", () => ConsoleRenderer.RenderLineBuffer(new StringBuilder(), null!, _consoleId, 0)); 204 | } 205 | 206 | [Fact] 207 | public void RenderLineBuffer_ThrowsException_IfConsoleIdIsNull() 208 | { 209 | Assert.Throws("consoleId", () => ConsoleRenderer.RenderLineBuffer(new StringBuilder(), _storage.Object, null!, 0)); 210 | } 211 | 212 | [Fact] 213 | public void RenderLineBuffer_RendersEmpty_WithNegativeN_IfStartIsNegative() 214 | { 215 | SetupStorage(CreateState(ProcessingState.StateName, _consoleId.DateValue)); 216 | 217 | var builder = new StringBuilder(); 218 | ConsoleRenderer.RenderLineBuffer(builder, _storage.Object, _consoleId, -1); 219 | 220 | Assert.Equal("
", builder.ToString()); 221 | } 222 | 223 | [Fact] 224 | public void RenderLineBuffer_RendersAllLines_IfStartIsZero() 225 | { 226 | SetupStorage(CreateState(ProcessingState.StateName, _consoleId.DateValue), 227 | new ConsoleLine { TimeOffset = 0, Message = "line1" }, 228 | new ConsoleLine { TimeOffset = 1, Message = "line2" }); 229 | 230 | var builder = new StringBuilder(); 231 | ConsoleRenderer.RenderLineBuffer(builder, _storage.Object, _consoleId, 0); 232 | 233 | Assert.Equal( 234 | "
" + 235 | "
+ <1msline1
" + 236 | "
+1sline2
" + 237 | "
", builder.ToString()); 238 | } 239 | 240 | [Fact] 241 | public void RenderLineBuffer_RendersLines_FromStartOffset() 242 | { 243 | SetupStorage(CreateState(ProcessingState.StateName, _consoleId.DateValue), 244 | new ConsoleLine { TimeOffset = 0, Message = "line1" }, 245 | new ConsoleLine { TimeOffset = 1, Message = "line2" }); 246 | 247 | var builder = new StringBuilder(); 248 | ConsoleRenderer.RenderLineBuffer(builder, _storage.Object, _consoleId, 1); 249 | 250 | Assert.Equal( 251 | "
" + 252 | "
+1sline2
" + 253 | "
", builder.ToString()); 254 | } 255 | 256 | [Fact] 257 | public void RenderLineBuffer_RendersEmpty_WithLineCount_IfNoMoreLines() 258 | { 259 | SetupStorage(CreateState(ProcessingState.StateName, _consoleId.DateValue), 260 | new ConsoleLine { TimeOffset = 0, Message = "line1" }, 261 | new ConsoleLine { TimeOffset = 1, Message = "line2" }); 262 | 263 | var builder = new StringBuilder(); 264 | ConsoleRenderer.RenderLineBuffer(builder, _storage.Object, _consoleId, 2); 265 | 266 | Assert.Equal("
", builder.ToString()); 267 | } 268 | 269 | [Fact] 270 | public void RenderLineBuffer_RendersEmpty_WithMinusTwo_IfStateNotFound() 271 | { 272 | SetupStorage(null, 273 | new ConsoleLine { TimeOffset = 0, Message = "line1" }, 274 | new ConsoleLine { TimeOffset = 1, Message = "line2" }); 275 | 276 | var builder = new StringBuilder(); 277 | ConsoleRenderer.RenderLineBuffer(builder, _storage.Object, _consoleId, 2); 278 | 279 | Assert.Equal("
", builder.ToString()); 280 | } 281 | 282 | [Fact] 283 | public void RenderLineBuffer_RendersEmpty_WithMinusOne_IfStateIsNotProcessing() 284 | { 285 | SetupStorage(CreateState(SucceededState.StateName, _consoleId.DateValue), 286 | new ConsoleLine { TimeOffset = 0, Message = "line1" }, 287 | new ConsoleLine { TimeOffset = 1, Message = "line2" }); 288 | 289 | var builder = new StringBuilder(); 290 | ConsoleRenderer.RenderLineBuffer(builder, _storage.Object, _consoleId, 2); 291 | 292 | Assert.Equal("
", builder.ToString()); 293 | } 294 | 295 | [Fact] 296 | public void RenderLineBuffer_RendersEmpty_WithMinusOne_IfStateIsAnotherProcessing() 297 | { 298 | SetupStorage(CreateState(ProcessingState.StateName, _consoleId.DateValue.AddMinutes(1)), 299 | new ConsoleLine { TimeOffset = 0, Message = "line1" }, 300 | new ConsoleLine { TimeOffset = 1, Message = "line2" }); 301 | 302 | var builder = new StringBuilder(); 303 | ConsoleRenderer.RenderLineBuffer(builder, _storage.Object, _consoleId, 2); 304 | 305 | Assert.Equal("
", builder.ToString()); 306 | } 307 | 308 | [Fact] 309 | public void RenderLineBuffer_AggregatesMultipleProgressLines() 310 | { 311 | SetupStorage(CreateState(ProcessingState.StateName, _consoleId.DateValue), 312 | new ConsoleLine { TimeOffset = 0, Message = "0", ProgressValue = 1 }, 313 | new ConsoleLine { TimeOffset = 1, Message = "1", ProgressValue = 3 }, 314 | new ConsoleLine { TimeOffset = 2, Message = "0", ProgressValue = 5 }); 315 | 316 | var builder = new StringBuilder(); 317 | ConsoleRenderer.RenderLineBuffer(builder, _storage.Object, _consoleId, 0); 318 | 319 | Assert.Equal( 320 | "
" + 321 | "
+ <1ms
" + 322 | "
+1s
" + 323 | "
", builder.ToString()); 324 | } 325 | 326 | private StateData CreateState(string stateName, DateTime startedAt) => new() 327 | { 328 | Name = stateName, 329 | Data = new Dictionary 330 | { 331 | ["StartedAt"] = JobHelper.SerializeDateTime(startedAt) 332 | } 333 | }; 334 | 335 | private void SetupStorage(StateData? stateData, params ConsoleLine[] lines) 336 | { 337 | _storage.Setup(x => x.GetState(It.IsAny())) 338 | .Returns(stateData); 339 | _storage.Setup(x => x.GetLineCount(It.IsAny())) 340 | .Returns(lines.Length); 341 | _storage.Setup(x => x.GetLines(It.IsAny(), It.IsAny(), It.IsAny())) 342 | .Returns((ConsoleId _, int start, int end) => lines.Where((_, i) => i >= start && i <= end)); 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /tests/Hangfire.Console.Tests/Storage/ConsoleStorageFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | using Hangfire.Common; 6 | using Hangfire.Console.Serialization; 7 | using Hangfire.Console.Storage; 8 | using Hangfire.States; 9 | using Hangfire.Storage; 10 | using Moq; 11 | using Xunit; 12 | using KVP = System.Collections.Generic.KeyValuePair; 13 | 14 | namespace Hangfire.Console.Tests.Storage; 15 | 16 | public class ConsoleStorageFacts 17 | { 18 | private readonly Mock _connection; 19 | 20 | private readonly ConsoleId _consoleId; 21 | 22 | private readonly Mock _transaction; 23 | 24 | public ConsoleStorageFacts() 25 | { 26 | _consoleId = new ConsoleId("1", DateTime.UtcNow); 27 | 28 | _connection = new Mock(); 29 | _transaction = new Mock(); 30 | 31 | _connection.Setup(x => x.CreateWriteTransaction()) 32 | .Returns(_transaction.Object); 33 | } 34 | 35 | [Fact] 36 | public void Ctor_ThrowsException_IfConnectionIsNull() 37 | { 38 | Assert.Throws("connection", () => new ConsoleStorage(null!)); 39 | } 40 | 41 | [Fact] 42 | public void Ctor_ThrowsException_IfNotImplementsJobStorageConnection() 43 | { 44 | var dummyConnection = new Mock(); 45 | 46 | Assert.Throws(() => new ConsoleStorage(dummyConnection.Object)); 47 | } 48 | 49 | [Fact] 50 | public void Dispose_ReallyDisposesConnection() 51 | { 52 | var storage = new ConsoleStorage(_connection.Object); 53 | 54 | storage.Dispose(); 55 | 56 | _connection.Verify(x => x.Dispose()); 57 | } 58 | 59 | [Fact] 60 | public void InitConsole_ThrowsException_IfConsoleIdIsNull() 61 | { 62 | var storage = new ConsoleStorage(_connection.Object); 63 | 64 | Assert.Throws("consoleId", () => storage.InitConsole(null!)); 65 | } 66 | 67 | [Fact] 68 | public void InitConsole_ThrowsException_IfNotImplementsJobStorageTransaction() 69 | { 70 | var dummyTransaction = new Mock(); 71 | _connection.Setup(x => x.CreateWriteTransaction()) 72 | .Returns(dummyTransaction.Object); 73 | 74 | var storage = new ConsoleStorage(_connection.Object); 75 | 76 | Assert.Throws(() => storage.InitConsole(_consoleId)); 77 | } 78 | 79 | [Fact] 80 | public void InitConsole_JobIdIsAddedToHash() 81 | { 82 | var storage = new ConsoleStorage(_connection.Object); 83 | 84 | storage.InitConsole(_consoleId); 85 | 86 | _connection.Verify(x => x.CreateWriteTransaction(), Times.Once); 87 | _transaction.Verify(x => x.SetRangeInHash(_consoleId.GetHashKey(), It2.AnyIs(p => p.Key == "jobId"))); 88 | _transaction.Verify(x => x.Commit(), Times.Once); 89 | } 90 | 91 | [Fact] 92 | public void AddLine_ThrowsException_IfConsoleIdIsNull() 93 | { 94 | var storage = new ConsoleStorage(_connection.Object); 95 | 96 | Assert.Throws("consoleId", () => storage.AddLine(null!, new ConsoleLine{ Message = "" })); 97 | } 98 | 99 | [Fact] 100 | public void AddLine_ThrowsException_IfLineIsNull() 101 | { 102 | var storage = new ConsoleStorage(_connection.Object); 103 | 104 | Assert.Throws("line", () => storage.AddLine(_consoleId, null!)); 105 | } 106 | 107 | [Fact] 108 | public void AddLine_ThrowsException_IfLineIsReference() 109 | { 110 | var storage = new ConsoleStorage(_connection.Object); 111 | 112 | Assert.Throws("line", () => storage.AddLine(_consoleId, new ConsoleLine { Message = "", IsReference = true })); 113 | } 114 | 115 | [Fact] 116 | public void AddLine_ShortLineIsAddedToSet() 117 | { 118 | var storage = new ConsoleStorage(_connection.Object); 119 | var line = new ConsoleLine { Message = "test" }; 120 | 121 | storage.AddLine(_consoleId, line); 122 | 123 | Assert.False(line.IsReference); 124 | _connection.Verify(x => x.CreateWriteTransaction(), Times.Once); 125 | _transaction.Verify(x => x.AddToSet(_consoleId.GetSetKey(), It.IsAny(), It.IsAny())); 126 | _transaction.Verify(x => x.SetRangeInHash(_consoleId.GetHashKey(), It.IsAny>()), Times.Never); 127 | _transaction.Verify(x => x.Commit(), Times.Once); 128 | } 129 | 130 | [Fact] 131 | public void AddLine_LongLineIsAddedToHash_AndReferenceIsAddedToSet() 132 | { 133 | var storage = new ConsoleStorage(_connection.Object); 134 | var line = new ConsoleLine 135 | { 136 | Message = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor " + 137 | "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud " + 138 | "exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure " + 139 | "dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. " + 140 | "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." 141 | }; 142 | 143 | storage.AddLine(_consoleId, line); 144 | 145 | Assert.True(line.IsReference); 146 | _connection.Verify(x => x.CreateWriteTransaction(), Times.Once); 147 | _transaction.Verify(x => x.AddToSet(_consoleId.GetSetKey(), It.IsAny(), It.IsAny())); 148 | _transaction.Verify(x => x.SetRangeInHash(_consoleId.GetHashKey(), It2.AnyIs(p => p.Key == line.Message))); 149 | _transaction.Verify(x => x.Commit(), Times.Once); 150 | } 151 | 152 | [Fact] 153 | public void AddLine_ProgressBarIsAddedToSet_AndProgressIsUpdated() 154 | { 155 | var storage = new ConsoleStorage(_connection.Object); 156 | var line = new ConsoleLine 157 | { 158 | Message = "1", 159 | ProgressValue = 10 160 | }; 161 | 162 | storage.AddLine(_consoleId, line); 163 | 164 | Assert.False(line.IsReference); 165 | _connection.Verify(x => x.CreateWriteTransaction(), Times.Once); 166 | _transaction.Verify(x => x.AddToSet(_consoleId.GetSetKey(), It.IsAny(), It.IsAny())); 167 | _transaction.Verify(x => x.SetRangeInHash(_consoleId.GetHashKey(), It2.AnyIs(p => p.Key == "progress"))); 168 | _transaction.Verify(x => x.Commit(), Times.Once); 169 | } 170 | 171 | [Fact] 172 | public void Expire_ThrowsException_IfConsoleIdIsNull() 173 | { 174 | var storage = new ConsoleStorage(_connection.Object); 175 | 176 | Assert.Throws("consoleId", () => storage.Expire(null!, TimeSpan.FromHours(1))); 177 | } 178 | 179 | [Fact] 180 | public void Expire_ExpiresSetAndHash() 181 | { 182 | var storage = new ConsoleStorage(_connection.Object); 183 | 184 | storage.Expire(_consoleId, TimeSpan.FromHours(1)); 185 | 186 | _connection.Verify(x => x.CreateWriteTransaction(), Times.Once); 187 | _transaction.Verify(x => x.ExpireSet(_consoleId.GetSetKey(), It.IsAny())); 188 | _transaction.Verify(x => x.ExpireHash(_consoleId.GetHashKey(), It.IsAny())); 189 | _transaction.Verify(x => x.Commit(), Times.Once); 190 | } 191 | 192 | [Fact] 193 | public void Expire_ExpiresOldSetAndHashKeysEither_ForBackwardsCompatibility() 194 | { 195 | var storage = new ConsoleStorage(_connection.Object); 196 | 197 | storage.Expire(_consoleId, TimeSpan.FromHours(1)); 198 | 199 | _connection.Verify(x => x.CreateWriteTransaction(), Times.Once); 200 | _transaction.Verify(x => x.ExpireSet(_consoleId.GetOldConsoleKey(), It.IsAny())); 201 | _transaction.Verify(x => x.ExpireHash(_consoleId.GetOldConsoleKey(), It.IsAny())); 202 | _transaction.Verify(x => x.Commit(), Times.Once); 203 | } 204 | 205 | [Fact] 206 | public void GetConsoleTtl_ThrowsException_IfConsoleIdIsNull() 207 | { 208 | var storage = new ConsoleStorage(_connection.Object); 209 | 210 | Assert.Throws("consoleId", () => storage.GetConsoleTtl(null!)); 211 | } 212 | 213 | [Fact] 214 | public void GetConsoleTtl_ReturnsTtlOfHash() 215 | { 216 | _connection.Setup(x => x.GetHashTtl(_consoleId.GetHashKey())) 217 | .Returns(TimeSpan.FromSeconds(123)); 218 | 219 | var storage = new ConsoleStorage(_connection.Object); 220 | 221 | var ttl = storage.GetConsoleTtl(_consoleId); 222 | 223 | Assert.Equal(TimeSpan.FromSeconds(123), ttl); 224 | } 225 | 226 | [Fact] 227 | public void GetLineCount_ThrowsException_IfConsoleIdIsNull() 228 | { 229 | var storage = new ConsoleStorage(_connection.Object); 230 | 231 | Assert.Throws("consoleId", () => storage.GetLineCount(null!)); 232 | } 233 | 234 | [Fact] 235 | public void GetLineCount_ReturnsCountOfSet() 236 | { 237 | _connection.Setup(x => x.GetSetCount(_consoleId.GetSetKey())) 238 | .Returns(123); 239 | 240 | var storage = new ConsoleStorage(_connection.Object); 241 | 242 | var count = storage.GetLineCount(_consoleId); 243 | 244 | Assert.Equal(123, count); 245 | } 246 | 247 | [Fact] 248 | public void GetLineCount_ReturnsCountOfOldSet_WhenNewOneReturnsZero_ForBackwardsCompatibility() 249 | { 250 | _connection.Setup(x => x.GetSetCount(_consoleId.GetSetKey())).Returns(0); 251 | _connection.Setup(x => x.GetSetCount(_consoleId.GetOldConsoleKey())).Returns(123); 252 | 253 | var storage = new ConsoleStorage(_connection.Object); 254 | 255 | var count = storage.GetLineCount(_consoleId); 256 | 257 | Assert.Equal(123, count); 258 | } 259 | 260 | [Fact] 261 | public void GetLines_ThrowsException_IfConsoleIdIsNull() 262 | { 263 | var storage = new ConsoleStorage(_connection.Object); 264 | 265 | Assert.Throws("consoleId", () => storage.GetLines(null!, 0, 1).ToArray()); 266 | } 267 | 268 | [Fact] 269 | public void GetLines_ReturnsRangeFromSet() 270 | { 271 | var lines = new[] 272 | { 273 | new ConsoleLine { TimeOffset = 0, Message = "line1" }, 274 | new ConsoleLine { TimeOffset = 1, Message = "line2" }, 275 | new ConsoleLine { TimeOffset = 2, Message = "line3" }, 276 | new ConsoleLine { TimeOffset = 3, Message = "line4" } 277 | }; 278 | 279 | _connection.Setup(x => x.GetRangeFromSet(_consoleId.GetSetKey(), It.IsAny(), It.IsAny())) 280 | .Returns((string _, int start, int end) => lines.Where((_, i) => i >= start && i <= end).Select(SerializationHelper.Serialize).ToList()); 281 | 282 | var storage = new ConsoleStorage(_connection.Object); 283 | 284 | var result = storage.GetLines(_consoleId, 1, 2).ToArray(); 285 | 286 | Assert.Equal(lines.Skip(1).Take(2).Select(x => x.Message), result.Select(x => x.Message)); 287 | } 288 | 289 | [Fact] 290 | public void GetLines_ReturnsRangeFromOldSet_ForBackwardsCompatibility() 291 | { 292 | var lines = new[] 293 | { 294 | new ConsoleLine { TimeOffset = 0, Message = "line1" }, 295 | new ConsoleLine { TimeOffset = 1, Message = "line2" }, 296 | new ConsoleLine { TimeOffset = 2, Message = "line3" }, 297 | new ConsoleLine { TimeOffset = 3, Message = "line4" } 298 | }; 299 | 300 | _connection.Setup(x => x.GetRangeFromSet(_consoleId.GetOldConsoleKey(), It.IsAny(), It.IsAny())) 301 | .Returns((string _, int start, int end) => lines.Where((_, i) => i >= start && i <= end).Select(SerializationHelper.Serialize).ToList()); 302 | 303 | var storage = new ConsoleStorage(_connection.Object); 304 | 305 | var result = storage.GetLines(_consoleId, 1, 2).ToArray(); 306 | 307 | Assert.Equal(lines.Skip(1).Take(2).Select(x => x.Message), result.Select(x => x.Message)); 308 | } 309 | 310 | [Fact] 311 | public void GetLines_ExpandsReferences() 312 | { 313 | var lines = new[] 314 | { 315 | new ConsoleLine { TimeOffset = 0, Message = "line1", IsReference = true } 316 | }; 317 | 318 | _connection.Setup(x => x.GetRangeFromSet(_consoleId.GetSetKey(), It.IsAny(), It.IsAny())) 319 | .Returns((string _, int start, int end) => lines.Where((_, i) => i >= start && i <= end).Select(SerializationHelper.Serialize).ToList()); 320 | _connection.Setup(x => x.GetValueFromHash(_consoleId.GetHashKey(), It.IsAny())) 321 | .Returns("Dereferenced Line"); 322 | 323 | var storage = new ConsoleStorage(_connection.Object); 324 | 325 | var result = storage.GetLines(_consoleId, 0, 1).Single(); 326 | 327 | Assert.False(result.IsReference); 328 | Assert.Equal("Dereferenced Line", result.Message); 329 | } 330 | 331 | [Fact] 332 | public void GetLines_ExpandsReferencesFromOldHash_ForBackwardsCompatibility() 333 | { 334 | var lines = new[] 335 | { 336 | new ConsoleLine { TimeOffset = 0, Message = "line1", IsReference = true } 337 | }; 338 | 339 | _connection.Setup(x => x.GetRangeFromSet(_consoleId.GetOldConsoleKey(), It.IsAny(), It.IsAny())) 340 | .Returns((string _, int start, int end) => lines.Where((_, i) => i >= start && i <= end).Select(SerializationHelper.Serialize).ToList()); 341 | _connection.Setup(x => x.GetValueFromHash(_consoleId.GetOldConsoleKey(), It.IsAny())) 342 | .Returns("Dereferenced Line"); 343 | 344 | var storage = new ConsoleStorage(_connection.Object); 345 | 346 | var result = storage.GetLines(_consoleId, 0, 1).Single(); 347 | 348 | Assert.False(result.IsReference); 349 | Assert.Equal("Dereferenced Line", result.Message); 350 | } 351 | 352 | [Fact] 353 | public void GetLines_HandlesHashException_WhenTryingToExpandReferences() 354 | { 355 | var lines = new[] 356 | { 357 | new ConsoleLine { TimeOffset = 0, Message = "line1", IsReference = true } 358 | }; 359 | 360 | _connection.Setup(x => x.GetRangeFromSet(_consoleId.GetOldConsoleKey(), It.IsAny(), It.IsAny())) 361 | .Returns((string _, int start, int end) => lines.Where((_, i) => i >= start && i <= end).Select(SerializationHelper.Serialize).ToList()); 362 | 363 | _connection.Setup(x => x.GetValueFromHash(_consoleId.GetOldConsoleKey(), It.IsAny())) 364 | .Throws(new NotSupportedException()); 365 | 366 | var storage = new ConsoleStorage(_connection.Object); 367 | 368 | var result = storage.GetLines(_consoleId, 0, 1).Single(); 369 | 370 | Assert.False(result.IsReference); 371 | Assert.Equal("line1", result.Message); 372 | } 373 | 374 | [Fact] 375 | public void GetState_ThrowsException_IfConsoleIdIsNull() 376 | { 377 | var storage = new ConsoleStorage(_connection.Object); 378 | 379 | Assert.Throws("consoleId", () => storage.GetState(null!)); 380 | } 381 | 382 | [Fact] 383 | public void GetState_ReturnsStateData() 384 | { 385 | var state = new StateData 386 | { 387 | Name = ProcessingState.StateName, 388 | Data = new Dictionary() 389 | }; 390 | 391 | _connection.Setup(x => x.GetStateData(It.IsAny())) 392 | .Returns(state); 393 | 394 | var storage = new ConsoleStorage(_connection.Object); 395 | 396 | var result = storage.GetState(_consoleId); 397 | 398 | Assert.Same(state, result); 399 | } 400 | 401 | [Fact] 402 | public void GetProgress_ThrowsException_IfConsoleIdIsNull() 403 | { 404 | var storage = new ConsoleStorage(_connection.Object); 405 | 406 | Assert.Throws("consoleId", () => storage.GetProgress(null!)); 407 | } 408 | 409 | [Fact] 410 | public void GetProgress_ReturnsNull_IfProgressNotPresent() 411 | { 412 | var storage = new ConsoleStorage(_connection.Object); 413 | 414 | var result = storage.GetProgress(_consoleId); 415 | 416 | Assert.Null(result); 417 | } 418 | 419 | [Fact] 420 | public void GetProgress_ReturnsNull_IfValueIsInvalid() 421 | { 422 | _connection.Setup(x => x.GetValueFromHash(It.IsAny(), It.IsIn("progress"))) 423 | .Returns("null"); 424 | 425 | var storage = new ConsoleStorage(_connection.Object); 426 | 427 | var result = storage.GetProgress(_consoleId); 428 | 429 | Assert.Null(result); 430 | } 431 | 432 | [Fact] 433 | public void GetProgress_ReturnsProgressValue() 434 | { 435 | const double progress = 12.5; 436 | 437 | _connection.Setup(x => x.GetValueFromHash(It.IsAny(), It.IsIn("progress"))) 438 | .Returns(progress.ToString(CultureInfo.InvariantCulture)); 439 | 440 | var storage = new ConsoleStorage(_connection.Object); 441 | 442 | var result = storage.GetProgress(_consoleId); 443 | 444 | Assert.Equal(progress, result); 445 | } 446 | } 447 | --------------------------------------------------------------------------------