From 2ac0bc01f189582388b1418d81e7defd4d4bef7e Mon Sep 17 00:00:00 2001 From: Alexey Skalozub Date: Tue, 25 Oct 2016 03:21:27 +0300 Subject: [PATCH] Initial commit --- .gitignore | 169 ++++++++++++++++++ Hangfire.Console.sln | 43 +++++ README.md | 58 ++++++ dashboard.png | Bin 0 -> 65521 bytes global.json | 3 + src/Hangfire.Console/ConsoleExtensions.cs | 129 +++++++++++++ src/Hangfire.Console/ConsoleOptions.cs | 20 +++ src/Hangfire.Console/ConsoleTextColor.cs | 96 ++++++++++ .../Dashboard/ConsoleDispatcher.cs | 49 +++++ .../Dashboard/ConsoleRenderer.cs | 144 +++++++++++++++ .../Dashboard/ProcessingStateRenderer.cs | 80 +++++++++ .../GlobalConfigurationExtensions.cs | 45 +++++ src/Hangfire.Console/Hangfire.Console.xproj | 21 +++ .../Properties/AssemblyInfo.cs | 21 +++ src/Hangfire.Console/Resources/script.js | 84 +++++++++ src/Hangfire.Console/Resources/style.css | 66 +++++++ .../Serialization/ConsoleId.cs | 113 ++++++++++++ .../Serialization/ConsoleLine.cs | 16 ++ .../Server/ConsoleServerFilter.cs | 64 +++++++ .../Support/CompositeDispatcher.cs | 42 +++++ .../Support/EmbeddedResourceDispatcher.cs | 60 +++++++ .../Support/HtmlHelperExtensions.cs | 25 +++ .../Support/RouteCollectionExtensions.cs | 121 +++++++++++++ src/Hangfire.Console/project.json | 33 ++++ .../Dashboard/ConsoleRendererFacts.cs | 55 ++++++ .../Hangfire.Console.Tests.xproj | 22 +++ .../Properties/AssemblyInfo.cs | 19 ++ .../Serialization/ConsoleIdFacts.cs | 55 ++++++ tests/Hangfire.Console.Tests/project.json | 29 +++ 29 files changed, 1682 insertions(+) create mode 100644 .gitignore create mode 100644 Hangfire.Console.sln create mode 100644 README.md create mode 100644 dashboard.png create mode 100644 global.json create mode 100644 src/Hangfire.Console/ConsoleExtensions.cs create mode 100644 src/Hangfire.Console/ConsoleOptions.cs create mode 100644 src/Hangfire.Console/ConsoleTextColor.cs create mode 100644 src/Hangfire.Console/Dashboard/ConsoleDispatcher.cs create mode 100644 src/Hangfire.Console/Dashboard/ConsoleRenderer.cs create mode 100644 src/Hangfire.Console/Dashboard/ProcessingStateRenderer.cs create mode 100644 src/Hangfire.Console/GlobalConfigurationExtensions.cs create mode 100644 src/Hangfire.Console/Hangfire.Console.xproj create mode 100644 src/Hangfire.Console/Properties/AssemblyInfo.cs create mode 100644 src/Hangfire.Console/Resources/script.js create mode 100644 src/Hangfire.Console/Resources/style.css create mode 100644 src/Hangfire.Console/Serialization/ConsoleId.cs create mode 100644 src/Hangfire.Console/Serialization/ConsoleLine.cs create mode 100644 src/Hangfire.Console/Server/ConsoleServerFilter.cs create mode 100644 src/Hangfire.Console/Support/CompositeDispatcher.cs create mode 100644 src/Hangfire.Console/Support/EmbeddedResourceDispatcher.cs create mode 100644 src/Hangfire.Console/Support/HtmlHelperExtensions.cs create mode 100644 src/Hangfire.Console/Support/RouteCollectionExtensions.cs create mode 100644 src/Hangfire.Console/project.json create mode 100644 tests/Hangfire.Console.Tests/Dashboard/ConsoleRendererFacts.cs create mode 100644 tests/Hangfire.Console.Tests/Hangfire.Console.Tests.xproj create mode 100644 tests/Hangfire.Console.Tests/Properties/AssemblyInfo.cs create mode 100644 tests/Hangfire.Console.Tests/Serialization/ConsoleIdFacts.cs create mode 100644 tests/Hangfire.Console.Tests/project.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8fa3e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,169 @@ +################# +## IDEA Rider +################# +.idea +.idea.* + +################# +## Visual Studio +################# + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +packages/ + +# Build results + +[Bb]uild/ +[Dd]ebug/ +[Rr]elease/ +x64/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ + +# dotnet +*.lock.json +artifacts/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +coverage.xml + +*_i.c +*_p.c +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.log +*.scc + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +*.ncrunch* +.*crunch*.local.xml + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.Publish.xml +*.pubxml + +# NuGet Packages Directory +## TODO: If you have NuGet Package Restore enabled, uncomment the next line +#packages/ + +# Windows Azure Build Output +csx +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.[Pp]ublish.xml +*.pfx +*.publishsettings + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file to a newer +# Visual Studio version. Backup files are not needed, because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +App_Data/*.mdf +App_Data/*.ldf + +############# +## Windows detritus +############# + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac crap +.DS_Store diff --git a/Hangfire.Console.sln b/Hangfire.Console.sln new file mode 100644 index 0000000..7c08937 --- /dev/null +++ b/Hangfire.Console.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.25420.1 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{436BABB0-DBF7-4301-BC7F-CBB9D09FAD36}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{657DF223-42EC-4765-990B-334C62F602EA}" + ProjectSection(SolutionItems) = preProject + dashboard.png = dashboard.png + global.json = global.json + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CBD2EEC6-826C-4477-B8F7-EADE2D944B74}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Hangfire.Console", "src\Hangfire.Console\Hangfire.Console.xproj", "{C18CBFCC-955B-4B21-B698-851CC56364AF}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Hangfire.Console.Tests", "tests\Hangfire.Console.Tests\Hangfire.Console.Tests.xproj", "{D5068E09-A43C-4B05-8068-C50E9497EB25}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C18CBFCC-955B-4B21-B698-851CC56364AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C18CBFCC-955B-4B21-B698-851CC56364AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C18CBFCC-955B-4B21-B698-851CC56364AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C18CBFCC-955B-4B21-B698-851CC56364AF}.Release|Any CPU.Build.0 = Release|Any CPU + {D5068E09-A43C-4B05-8068-C50E9497EB25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5068E09-A43C-4B05-8068-C50E9497EB25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5068E09-A43C-4B05-8068-C50E9497EB25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5068E09-A43C-4B05-8068-C50E9497EB25}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C18CBFCC-955B-4B21-B698-851CC56364AF} = {436BABB0-DBF7-4301-BC7F-CBB9D09FAD36} + {D5068E09-A43C-4B05-8068-C50E9497EB25} = {CBD2EEC6-826C-4477-B8F7-EADE2D944B74} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..29373ba --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Hangfire.Console + +Inspired by AppVeyor, Hangfire.Console provides a console-like logging experience for your jobs. + +![dashboard](dashboard.png) + +## Setup + +In .NET Core's Startup.cs: +```c# +public void ConfigureServices(IServiceCollection services) +{ + services.AddHangfire(config => + { + config.UseSqlServerStorage("connectionSting"); + config.UseConsole(); + }); +} +``` + +Otherwise, +```c# +GlobalConfiguration.Configuration + .UseSqlServerStorage("connectionSting") + .UseConsole(); +``` + +As usual, you may provide additional options for `UseConsole()` method. + +**NOTE**: If you have Dashboard and Server running separately, +you'll need to call `UseConsole()` on both. + +## Log + +Hangfire.Console provides extension methods on `PerformContext` object, +hence you'll need to add it as a job argument. + +Now you can write to console: + +```c# +public void TastMethod(PerformContext context) +{ + context.WriteLine("Hello, world!"); +} +``` + +Like with `System.Console`, you can specify text color for your messages: + +```c# +public void TastMethod(PerformContext context) +{ + context.SetTextColor(ConsoleTextColor.Red); + context.WriteLine("Error!"); + context.ResetTextColor(); +} +``` + +Unless specified otherwise, console sessions will expire in 24 hours. \ No newline at end of file diff --git a/dashboard.png b/dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..e1d098e24cfcada6a9a9da9146c498f32f90238d GIT binary patch literal 65521 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 literal 0 HcmV?d00001 diff --git a/global.json b/global.json new file mode 100644 index 0000000..eb92859 --- /dev/null +++ b/global.json @@ -0,0 +1,3 @@ +{ + "projects": [ "src", "tests" ] +} diff --git a/src/Hangfire.Console/ConsoleExtensions.cs b/src/Hangfire.Console/ConsoleExtensions.cs new file mode 100644 index 0000000..31ffb27 --- /dev/null +++ b/src/Hangfire.Console/ConsoleExtensions.cs @@ -0,0 +1,129 @@ +using Hangfire.Common; +using Hangfire.Console.Serialization; +using Hangfire.Server; +using System; + +namespace Hangfire.Console +{ + /// + /// Provides extension methods for writing to console. + /// + public static class ConsoleExtensions + { + /// + /// Sets text color for next console lines. + /// + /// Context + /// Text color to use + public static void SetTextColor(this PerformContext context, ConsoleTextColor color) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (color == null) + throw new ArgumentNullException(nameof(color)); + + context.Items["ConsoleTextColor"] = color; + } + + /// + /// Resets text color for next console lines. + /// + /// Context + public static void ResetTextColor(this PerformContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + context.Items.Remove("ConsoleTextColor"); + } + + /// + /// Adds a string to console. + /// + /// Context + /// String + public static void WriteLine(this PerformContext context, string value) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (!context.Items.ContainsKey("ConsoleId")) + { + // Absence of ConsoleId means ConsoleServerFilter was not properly added + return; + } + + var consoleId = (ConsoleId)context.Items["ConsoleId"]; + + var line = new ConsoleLine(); + line.TimeOffset = Math.Round((DateTime.UtcNow - consoleId.Timestamp).TotalSeconds, 3); + line.Message = value ?? ""; + + if (context.Items.ContainsKey("ConsoleTextColor")) + { + line.TextColor = context.Items["ConsoleTextColor"].ToString(); + } + + using (var tran = context.Connection.CreateWriteTransaction()) + { + tran.AddToSet(consoleId.ToString(), JobHelper.ToJson(line)); + tran.Commit(); + } + } + + /// + /// Adds an empty line to console. + /// + /// Context + public static void WriteLine(this PerformContext context) + => WriteLine(context, ""); + + /// + /// Adds a value to a console. + /// + /// Context + /// Value + public static void WriteLine(this PerformContext context, object value) + => WriteLine(context, value?.ToString()); + + /// + /// Adds a formatted string to a console. + /// + /// Context + /// Format string + /// Argument + public static void WriteLine(this PerformContext context, string format, object arg0) + => WriteLine(context, string.Format(format, arg0)); + + /// + /// Adds a formatted string to a console. + /// + /// Context + /// Format string + /// Argument + /// Argument + public static void WriteLine(this PerformContext context, string format, object arg0, object arg1) + => WriteLine(context, string.Format(format, arg0, arg1)); + + /// + /// Adds a formatted string to a console. + /// + /// Context + /// Format string + /// Argument + /// Argument + /// Argument + public static void WriteLine(this PerformContext context, string format, object arg0, object arg1, object arg2) + => WriteLine(context, string.Format(format, arg0, arg1, arg2)); + + /// + /// Adds a formatted string to a console. + /// + /// Context + /// Format string + /// Arguments + public static void WriteLine(this PerformContext context, string format, params object[] args) + => WriteLine(context, string.Format(format, args)); + } +} diff --git a/src/Hangfire.Console/ConsoleOptions.cs b/src/Hangfire.Console/ConsoleOptions.cs new file mode 100644 index 0000000..adbbaef --- /dev/null +++ b/src/Hangfire.Console/ConsoleOptions.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hangfire.Console +{ + /// + /// Configuration options for console. + /// + public class ConsoleOptions + { + /// + /// Current options + /// + internal static ConsoleOptions Current { get; set; } + + /// + /// Gets or sets expiration time for console messages. + /// + public TimeSpan ExpireIn { get; set; } = TimeSpan.FromDays(1); + } +} diff --git a/src/Hangfire.Console/ConsoleTextColor.cs b/src/Hangfire.Console/ConsoleTextColor.cs new file mode 100644 index 0000000..e54d927 --- /dev/null +++ b/src/Hangfire.Console/ConsoleTextColor.cs @@ -0,0 +1,96 @@ +namespace Hangfire.Console +{ + /// + /// Text color values + /// + public class ConsoleTextColor + { + /// + /// The color dark blue. + /// + public static readonly ConsoleTextColor DarkBlue = new ConsoleTextColor("#000080"); + + /// + /// The color dark green. + /// + public static readonly ConsoleTextColor DarkGreen = new ConsoleTextColor("#008000"); + + /// + /// The color dark cyan (dark blue-green). + /// + public static readonly ConsoleTextColor DarkCyan = new ConsoleTextColor("#008080"); + + /// + /// The color dark red. + /// + public static readonly ConsoleTextColor DarkRed = new ConsoleTextColor("#800000"); + + /// + /// The color dark magenta (dark purplish-red). + /// + public static readonly ConsoleTextColor DarkMagenta = new ConsoleTextColor("#800080"); + + /// + /// The color dark yellow (ochre). + /// + public static readonly ConsoleTextColor DarkYellow = new ConsoleTextColor("#808000"); + + /// + /// The color gray. + /// + public static readonly ConsoleTextColor Gray = new ConsoleTextColor("#c0c0c0"); + + /// + /// The color dark gray. + /// + public static readonly ConsoleTextColor DarkGray = new ConsoleTextColor("#808080"); + + /// + /// The color blue. + /// + public static readonly ConsoleTextColor Blue = new ConsoleTextColor("#0000ff"); + + /// + /// The color green. + /// + public static readonly ConsoleTextColor Green = new ConsoleTextColor("#00ff00"); + + /// + /// The color cyan (blue-green). + /// + public static readonly ConsoleTextColor Cyan = new ConsoleTextColor("#00ffff"); + + /// + /// The color red. + /// + public static readonly ConsoleTextColor Red = new ConsoleTextColor("#ff0000"); + + /// + /// The color magenta (purplish-red). + /// + public static readonly ConsoleTextColor Magenta = new ConsoleTextColor("#ff00ff"); + + /// + /// The color yellow. + /// + public static readonly ConsoleTextColor Yellow = new ConsoleTextColor("#ffff00"); + + /// + /// The color white. + /// + public static readonly ConsoleTextColor White = new ConsoleTextColor("#ffffff"); + + private readonly string _color; + + private ConsoleTextColor(string color) + { + _color = color; + } + + /// + public override string ToString() + { + return _color; + } + } +} diff --git a/src/Hangfire.Console/Dashboard/ConsoleDispatcher.cs b/src/Hangfire.Console/Dashboard/ConsoleDispatcher.cs new file mode 100644 index 0000000..f7ef8d7 --- /dev/null +++ b/src/Hangfire.Console/Dashboard/ConsoleDispatcher.cs @@ -0,0 +1,49 @@ +using Hangfire.Dashboard; +using System.Threading.Tasks; +using Hangfire.Annotations; +using System.Text; +using Hangfire.Console.Serialization; +using System; + +namespace Hangfire.Console.Dashboard +{ + /// + /// Provides incremental updates for a console. + /// + internal class ConsoleDispatcher : IDashboardDispatcher + { + private readonly ConsoleOptions _options; + + public ConsoleDispatcher(ConsoleOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + _options = options; + } + + public Task Dispatch([NotNull] DashboardContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + var consoleId = ConsoleId.Parse(context.UriMatch.Groups[1].Value); + + var startArg = context.Request.GetQuery("start"); + + // try to parse offset at which we should start returning requests + int start; + if (string.IsNullOrEmpty(startArg) || !int.TryParse(startArg, out start)) + { + // if not provided or invalid, fetch records from the very start + start = 0; + } + + var buffer = new StringBuilder(); + ConsoleRenderer.RenderConsole(buffer, context.Storage, consoleId, start); + + context.Response.ContentType = "text/html"; + return context.Response.WriteAsync(buffer.ToString()); + } + } +} diff --git a/src/Hangfire.Console/Dashboard/ConsoleRenderer.cs b/src/Hangfire.Console/Dashboard/ConsoleRenderer.cs new file mode 100644 index 0000000..ed10e54 --- /dev/null +++ b/src/Hangfire.Console/Dashboard/ConsoleRenderer.cs @@ -0,0 +1,144 @@ +using Hangfire.Common; +using Hangfire.Console.Serialization; +using Hangfire.Dashboard; +using Hangfire.States; +using Hangfire.Storage; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Hangfire.Console.Dashboard +{ + /// + /// Helper methods to render console shared between + /// and . + /// + internal static class ConsoleRenderer + { + private static readonly HtmlHelper Helper = new HtmlHelper(new DummyPage()); + + private class DummyPage : RazorPage + { + public override void Execute() + { + } + } + + /// + /// Renders a single to a buffer. + /// + /// Buffer + /// Line + /// Reference timestamp for time offset + public static void RenderLine(StringBuilder builder, ConsoleLine line, DateTime timestamp) + { + var offset = TimeSpan.FromSeconds(line.TimeOffset); + + builder.Append("
") + .Append(Helper.MomentTitle(timestamp + offset, Helper.ToHumanDuration(offset))) + .Append(Helper.HtmlEncode(line.Message)) + .Append("
"); + } + + /// + /// Renders a collection of to a buffer. + /// + /// Buffer + /// Lines + /// Reference timestamp for time offset + public static void RenderLines(StringBuilder builder, IEnumerable lines, DateTime timestamp) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + if (lines == null) return; + + foreach (var line in lines) + { + RenderLine(builder, line, timestamp); + } + } + + /// + /// Fetches and renders console contents to a buffer. + /// + /// Buffer + /// Job storage + /// Console identifier + /// Offset to read lines from + public static void RenderConsole(StringBuilder builder, JobStorage storage, ConsoleId consoleId, int start) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + if (storage == null) + throw new ArgumentNullException(nameof(storage)); + if (consoleId == null) + throw new ArgumentNullException(nameof(consoleId)); + + var items = ReadLines(storage, consoleId, ref start); + + builder.AppendFormat("
", consoleId, start); + RenderLines(builder, items, consoleId.Timestamp); + builder.Append("
"); + } + + /// + /// Fetches console lines from storage. + /// + /// Job storage + /// Console identifier + /// Offset to read lines from + /// + /// On completion, is set to the end of the current batch, + /// and can be used for next requests (or set to -1, if the job has finished processing). + /// + private static IEnumerable ReadLines(JobStorage storage, ConsoleId consoleId, ref int start) + { + if (start < 0) return null; + + using (var connection = (JobStorageConnection)storage.GetConnection()) + { + var count = (int)connection.GetSetCount(consoleId.ToString()); + var result = new List(Math.Max(1, count - start)); + + if (count > start) + { + // has some new items to fetch + + var items = connection.GetRangeFromSet(consoleId.ToString(), start, count); + foreach (var item in items) + { + var entry = JobHelper.FromJson(item); + result.Add(entry); + } + } + + if (count <= start || start == 0) + { + // no new items or initial load, check if the job is still performing + + var state = connection.GetStateData(consoleId.JobId); + if (state == null) + { + // No state found for a job, probably it was deleted + count = -2; + } + else if (!string.Equals(state.Name, ProcessingState.StateName, StringComparison.OrdinalIgnoreCase) || + JobHelper.DeserializeDateTime(state.Data["StartedAt"]) != consoleId.Timestamp) + { + // Job has changed its state + count = -1; + } + } + + start = count; + return result; + } + } + } +} diff --git a/src/Hangfire.Console/Dashboard/ProcessingStateRenderer.cs b/src/Hangfire.Console/Dashboard/ProcessingStateRenderer.cs new file mode 100644 index 0000000..4a99aad --- /dev/null +++ b/src/Hangfire.Console/Dashboard/ProcessingStateRenderer.cs @@ -0,0 +1,80 @@ +using Hangfire.Common; +using Hangfire.Console.Serialization; +using Hangfire.Dashboard; +using Hangfire.Dashboard.Extensions; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Hangfire.Console.Dashboard +{ + /// + /// Replacement renderer for Processing state. + /// + internal class ProcessingStateRenderer + { + private readonly ConsoleOptions _options; + + public ProcessingStateRenderer(ConsoleOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + _options = options; + } + + public NonEscapedString Render(HtmlHelper helper, IDictionary stateData) + { + var builder = new StringBuilder(); + + builder.Append("
"); + + string serverId = null; + + if (stateData.ContainsKey("ServerId")) + { + serverId = stateData["ServerId"]; + } + else if (stateData.ContainsKey("ServerName")) + { + serverId = stateData["ServerName"]; + } + + if (serverId != null) + { + builder.Append("
Server:
"); + builder.Append($"
{helper.ServerId(serverId)}
"); + } + + if (stateData.ContainsKey("WorkerId")) + { + builder.Append("
Worker:
"); + builder.Append($"
{stateData["WorkerId"].Substring(0, 8)}
"); + } + else if (stateData.ContainsKey("WorkerNumber")) + { + builder.Append("
Worker:
"); + builder.Append($"
#{stateData["WorkerNumber"]}
"); + } + + builder.Append("
"); + + var page = helper.GetPage(); + if (page.RequestPath.StartsWith("/jobs/details/")) + { + // We cannot cast page to an internal type JobDetailsPage to get jobId :( + var jobId = page.RequestPath.Substring("/jobs/details/".Length); + + var startedAt = JobHelper.DeserializeDateTime(stateData["StartedAt"]); + + var consoleId = new ConsoleId(jobId, startedAt); + + builder.Append("
"); + ConsoleRenderer.RenderConsole(builder, page.Storage, consoleId, 0); + builder.Append("
"); + } + + return new NonEscapedString(builder.ToString()); + } + } +} diff --git a/src/Hangfire.Console/GlobalConfigurationExtensions.cs b/src/Hangfire.Console/GlobalConfigurationExtensions.cs new file mode 100644 index 0000000..2df67dc --- /dev/null +++ b/src/Hangfire.Console/GlobalConfigurationExtensions.cs @@ -0,0 +1,45 @@ +using Hangfire.Console.Dashboard; +using Hangfire.Console.Server; +using Hangfire.Dashboard; +using Hangfire.Dashboard.Extensions; +using Hangfire.States; +using System; +using System.Reflection; + +namespace Hangfire.Console +{ + /// + /// Provides extension methods to setup Hangfire.Console. + /// + public static class GlobalConfigurationExtensions + { + /// + /// Configures Hangfire to use Console. + /// + /// Global configuration + /// Options for console + public static IGlobalConfiguration UseConsole(this IGlobalConfiguration configuration, ConsoleOptions options = null) + { + if (configuration == null) + throw new ArgumentNullException(nameof(configuration)); + + options = options ?? new ConsoleOptions(); + + // register server filter for jobs + GlobalJobFilters.Filters.Add(new ConsoleServerFilter(options)); + + // replace renderer for Processing state + JobHistoryRenderer.Register(ProcessingState.StateName, new ProcessingStateRenderer(options).Render); + + // register dispatcher to serve console updates + DashboardRoutes.Routes.Add("/console/([0-9a-f]{11}.+)", new ConsoleDispatcher(options)); + + // register additional dispatchers for CSS and JS + var assembly = typeof(ConsoleRenderer).GetTypeInfo().Assembly; + DashboardRoutes.Routes.Append("/js[0-9]{3}", new EmbeddedResourceDispatcher(assembly, "Hangfire.Console.Resources.script.js")); + DashboardRoutes.Routes.Append("/css[0-9]{3}", new EmbeddedResourceDispatcher(assembly, "Hangfire.Console.Resources.style.css")); + + return configuration; + } + } +} diff --git a/src/Hangfire.Console/Hangfire.Console.xproj b/src/Hangfire.Console/Hangfire.Console.xproj new file mode 100644 index 0000000..51422e4 --- /dev/null +++ b/src/Hangfire.Console/Hangfire.Console.xproj @@ -0,0 +1,21 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + c18cbfcc-955b-4b21-b698-851cc56364af + Hangfire.Console + .\obj + .\bin\ + v4.5.2 + + + + 2.0 + + + diff --git a/src/Hangfire.Console/Properties/AssemblyInfo.cs b/src/Hangfire.Console/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..e79e184 --- /dev/null +++ b/src/Hangfire.Console/Properties/AssemblyInfo.cs @@ -0,0 +1,21 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Hangfire.Console")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("c18cbfcc-955b-4b21-b698-851cc56364af")] + +[assembly: InternalsVisibleTo("Hangfire.Console.Tests")] \ No newline at end of file diff --git a/src/Hangfire.Console/Resources/script.js b/src/Hangfire.Console/Resources/script.js new file mode 100644 index 0000000..8ebfa0b --- /dev/null +++ b/src/Hangfire.Console/Resources/script.js @@ -0,0 +1,84 @@ +(function (hangfire) { + hangfire.Console = (function () { + function Console (el) { + this._el = el; + this._id = el.data('id'); + this._n = parseInt(el.data('n')) || 0; + } + + Console.prototype._load = function (start, replace) { + if (start < 0) return true; + + var url = hangfire.config && hangfire.config.pollUrl; + if (!url) return false; + + url = url.replace(/\/stats$/, "/console/" + this._id); + + var $this = this; + return $.get(url, { start: start }, function (data) { + var $data = $(data); + $this._n = parseInt($data.data('n')); + + // add lines + if (replace) $this._el.empty(); + $this._el.append($(".line", $data)); + + // set tooltips on new lines + $(".line span[data-moment-title]:not([title])", $this._el).each(function () { + var $this = $(this), + time = moment($this.data('moment-title'), 'X'); + $this.prop('title', time.format('llll')) + .attr('data-container', 'body'); + }).tooltip(); + }, "html"); + } + + Console.prototype.reload = function () { + this._load(0, true); + } + + Console.prototype.poll = function () { + if (this._timerId) return; + + if (this._n < 0) { + this._el.removeClass('active'); + return; + } + + var interval = 1000; + + var $this = this; + + this._el.addClass('active'); + this._timerId = setInterval(function () { + if (!$this._load($this._n, false) || $this._n < 0) { + $this._el.removeClass('active'); + clearInterval($this._timerId); + $this._timerId = null; + + if ($this._n === -1) { + // job has changed its state (but still exists) + location.reload(); + } + } + }, interval); + } + + return Console; + })(); + +})(window.Hangfire = window.Hangfire || {}); + +$(function () { + $(".console").each(function (index) { + var $this = $(this), + c = new Hangfire.Console($this); + + $this.data('console', c); + + if (index === 0) { + // poll on the first console + c.poll(); + } + }); +}); \ No newline at end of file diff --git a/src/Hangfire.Console/Resources/style.css b/src/Hangfire.Console/Resources/style.css new file mode 100644 index 0000000..bcf6f4e --- /dev/null +++ b/src/Hangfire.Console/Resources/style.css @@ -0,0 +1,66 @@ +.console-area { + padding: 0; + margin: 0 -10px; +} + +.console { + font-family: Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, monospace; + padding: 10px; + border: none; + margin: 10px 0 0 0; + + background-color: #0d3163; + color: #fff; +} + +.console:empty { + display: none; +} + +.console .line { + margin: 0; + line-height: 1.4em; + min-height: 1.4em; + font-size: 0.85em; + word-break: break-word; + white-space: pre-wrap; + vertical-align: top; +} + +.console .line > span[data-moment-title] { + display: none; /* timestamp is hidden in compact view */ +} + +@media only screen { + + .console.active .line:last-child:after { + display: inline-block; + content: url(data:image/gif;base64,R0lGODlhEAAFAPUAAA4yZP///w8zZBQ3aCJDcDlXf152l3SIpYydtaOxxKSyxPDy9fv8/f7+/g0xYw4yY4WXsOfq7w8yZBQ3Zxk7ayBBbytLdjRSfFVvkml/nmqAn2+Fon6RrJSkupWlu5qpvtne5uTn7fn6+xI1Zhg6ah0+bR4/bidHdDBOeUxnjE1njF51l2F4mWV8m3GGo3OIpHiMqISWr4uctMHK18vS3c3U387V3+Lm7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/hZDcm9wcGVkIHdpdGggZXpnaWYuY29tACH5BAQUAP8ALAAAAAAQAAUAAAQZ0AHhqr2WIFWwdwcTJMB3GUuDmNdgHARrRQAh+QQFFAACACwAAAAACgAFAAAGKkCBQCIUAgCCCsdzEVo+H0smFOk8BJBAAIIBRTjCjUi0mWAyFSFFo6EEAQAh+QQFFAAOACwAAAAAEAAFAAAGPUDH4+EoGo8CgKPkgqCKJ5nsVCwoEIQVzRYrwiIRmAOQCDBeqVnNVWzdbq0iorEwjFQrU5HEYpGKBAcGA0EAOw==); + vertical-align: middle; + margin-left: 8px; + } + +} + +@media (min-width: 768px) { + + .console .line { + margin-left: 180px; /* same as dd */ + } + + .console .line > span[data-moment-title] { + display: inline-block; + width: 160px; /* same as dt */ + margin-left: -160px; + padding: 0 20px 0 10px; + + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + vertical-align: top; + white-space: pre; + + color: #00aad7; + } +} + diff --git a/src/Hangfire.Console/Serialization/ConsoleId.cs b/src/Hangfire.Console/Serialization/ConsoleId.cs new file mode 100644 index 0000000..d86c2de --- /dev/null +++ b/src/Hangfire.Console/Serialization/ConsoleId.cs @@ -0,0 +1,113 @@ +using System; + +namespace Hangfire.Console.Serialization +{ + /// + /// Console identifier + /// + internal class ConsoleId : IEquatable + { + private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private string _cachedString = null; + + /// + /// Job identifier + /// + public string JobId { get; } + + /// + /// Processing timestamp + /// + public DateTime Timestamp { get; } + + /// + /// Initializes an instance of + /// + /// Job identifier + /// Processing timestamp + public ConsoleId(string jobId, DateTime timestamp) + { + if (string.IsNullOrEmpty(jobId)) + throw new ArgumentNullException(nameof(jobId)); + + JobId = jobId; + Timestamp = timestamp.ToUniversalTime(); + } + + /// + /// Creates an instance of from string representation. + /// + /// String + public static ConsoleId Parse(string value) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + if (value.Length < 12) + throw new ArgumentException("Invalid value", nameof(value)); + + // Timestamp is serialized in reverse order for better randomness! + + long timestamp = 0; + for (int i = 10; i >= 0; i--) + { + var c = value[i] | 0x20; + + var x = (c >= '0' && c <= '9') ? (c - '0') : (c >= 'a' && c <= 'f') ? (c - 'a' + 10) : -1; + if (x == -1) + throw new ArgumentException("Invalid value", nameof(value)); + + timestamp = (timestamp << 4) | (long)x; + } + + return new ConsoleId(value.Substring(11), UnixEpoch.AddMilliseconds(timestamp)) { _cachedString = value }; + } + + /// + /// Determines if this instance is equal to instance. + /// + /// Other instance + public bool Equals(ConsoleId other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + + return other.Timestamp == Timestamp + && other.JobId == JobId; + } + + /// + public override string ToString() + { + if (_cachedString == null) + { + var buffer = new char[11 + JobId.Length]; + + var timestamp = (long)(Timestamp - UnixEpoch).TotalMilliseconds; + for (int i = 0; i < 11; i++, timestamp >>= 4) + { + var c = timestamp & 0x0F; + buffer[i] = (c < 10) ? (char)(c + '0') : (char)(c - 10 + 'a'); + } + + JobId.CopyTo(0, buffer, 11, JobId.Length); + + _cachedString = new string(buffer); + } + + return _cachedString; + } + + /// + public override bool Equals(object obj) + { + return Equals(obj as ConsoleId); + } + + /// + public override int GetHashCode() + { + return (JobId.GetHashCode() * 17) ^ Timestamp.GetHashCode(); + } + } +} diff --git a/src/Hangfire.Console/Serialization/ConsoleLine.cs b/src/Hangfire.Console/Serialization/ConsoleLine.cs new file mode 100644 index 0000000..bdba3ff --- /dev/null +++ b/src/Hangfire.Console/Serialization/ConsoleLine.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Hangfire.Console.Serialization +{ + internal class ConsoleLine + { + [JsonProperty("t", Required = Required.Always)] + public double TimeOffset { get; set; } + + [JsonProperty("s", Required = Required.Always)] + public string Message { get; set; } + + [JsonProperty("c", DefaultValueHandling = DefaultValueHandling.Ignore)] + public string TextColor { get; set; } + } +} diff --git a/src/Hangfire.Console/Server/ConsoleServerFilter.cs b/src/Hangfire.Console/Server/ConsoleServerFilter.cs new file mode 100644 index 0000000..9951c97 --- /dev/null +++ b/src/Hangfire.Console/Server/ConsoleServerFilter.cs @@ -0,0 +1,64 @@ +using Hangfire.Common; +using Hangfire.Console.Serialization; +using Hangfire.Server; +using Hangfire.States; +using Hangfire.Storage; +using System; + +namespace Hangfire.Console.Server +{ + /// + /// Server filter to initialize and cleanup console environment. + /// + internal class ConsoleServerFilter : IServerFilter + { + private readonly ConsoleOptions _options; + + public ConsoleServerFilter(ConsoleOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + _options = options; + } + + public void OnPerforming(PerformingContext context) + { + var state = context.Connection.GetStateData(context.BackgroundJob.Id); + + if (!string.Equals(state.Name, ProcessingState.StateName, StringComparison.OrdinalIgnoreCase)) + { + // Not in Processing state? Something is really off... + return; + } + + var startedAt = JobHelper.DeserializeDateTime(state.Data["StartedAt"]); + + context.Items["ConsoleId"] = new ConsoleId(context.BackgroundJob.Id, startedAt); + } + + public void OnPerformed(PerformedContext context) + { + if (context.Canceled) + { + // Processing was been cancelled by one of the job filters + // There's nothing to do here, as processing hasn't started + return; + } + + if (!context.Items.ContainsKey("ConsoleId")) + { + // Something gone wrong in OnPerforming + return; + } + + var consoleId = (ConsoleId)context.Items["ConsoleId"]; + + using (var tran = (JobStorageTransaction)context.Connection.CreateWriteTransaction()) + { + tran.ExpireSet(consoleId.ToString(), _options.ExpireIn); + tran.Commit(); + } + } + } +} diff --git a/src/Hangfire.Console/Support/CompositeDispatcher.cs b/src/Hangfire.Console/Support/CompositeDispatcher.cs new file mode 100644 index 0000000..6dbfd0f --- /dev/null +++ b/src/Hangfire.Console/Support/CompositeDispatcher.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Hangfire.Dashboard.Extensions +{ + /// + /// Dispatcher that combines output from several other dispatchers. + /// Used internally by . + /// + internal class CompositeDispatcher : IDashboardDispatcher + { + private readonly List _dispatchers; + + public CompositeDispatcher(params IDashboardDispatcher[] dispatchers) + { + _dispatchers = new List(dispatchers); + } + + public void AddDispatcher(IDashboardDispatcher dispatcher) + { + if (dispatcher == null) + throw new ArgumentNullException(nameof(dispatcher)); + + _dispatchers.Add(dispatcher); + } + + public async Task Dispatch(DashboardContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (_dispatchers.Count == 0) + throw new InvalidOperationException("CompositeDispatcher should contain at least one dispatcher"); + + foreach (var dispatcher in _dispatchers) + { + await dispatcher.Dispatch(context); + } + } + } +} diff --git a/src/Hangfire.Console/Support/EmbeddedResourceDispatcher.cs b/src/Hangfire.Console/Support/EmbeddedResourceDispatcher.cs new file mode 100644 index 0000000..65c0126 --- /dev/null +++ b/src/Hangfire.Console/Support/EmbeddedResourceDispatcher.cs @@ -0,0 +1,60 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; + +namespace Hangfire.Dashboard.Extensions +{ + /// + /// Alternative to built-in EmbeddedResourceDispatcher, which (for some reasons) is not public. + /// + internal class EmbeddedResourceDispatcher : IDashboardDispatcher + { + private readonly Assembly _assembly; + private readonly string _resourceName; + private readonly string _contentType; + + public EmbeddedResourceDispatcher(Assembly assembly, string resourceName, string contentType = null) + { + if (assembly == null) + throw new ArgumentNullException(nameof(assembly)); + if (string.IsNullOrEmpty(resourceName)) + throw new ArgumentNullException(nameof(resourceName)); + + _assembly = assembly; + _resourceName = resourceName; + _contentType = contentType; + } + + public Task Dispatch(DashboardContext context) + { + if (!string.IsNullOrEmpty(_contentType)) + { + var contentType = context.Response.ContentType; + + if (string.IsNullOrEmpty(contentType)) + { + // content type not yet set + context.Response.ContentType = _contentType; + } + else if (contentType != _contentType) + { + // content type already set, but doesn't match ours + throw new InvalidOperationException($"ContentType '{_contentType}' conflicts with '{context.Response.ContentType}'"); + } + } + + return WriteResourceAsync(context.Response, _assembly, _resourceName); + } + + private static async Task WriteResourceAsync(DashboardResponse response, Assembly assembly, string resourceName) + { + using (var stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) + throw new ArgumentException($@"Resource '{resourceName}' not found in assembly {assembly}."); + + await stream.CopyToAsync(response.Body); + } + } + } +} diff --git a/src/Hangfire.Console/Support/HtmlHelperExtensions.cs b/src/Hangfire.Console/Support/HtmlHelperExtensions.cs new file mode 100644 index 0000000..36d93be --- /dev/null +++ b/src/Hangfire.Console/Support/HtmlHelperExtensions.cs @@ -0,0 +1,25 @@ +using System; +using System.Reflection; + +namespace Hangfire.Dashboard.Extensions +{ + /// + /// Provides extension methods for . + /// + internal static class HtmlHelperExtensions + { + private static readonly FieldInfo _page = typeof(HtmlHelper).GetTypeInfo().GetDeclaredField(nameof(_page)); + + /// + /// Returs a associated with . + /// + /// Helper + public static RazorPage GetPage(this HtmlHelper helper) + { + if (helper == null) + throw new ArgumentNullException(nameof(helper)); + + return (RazorPage)_page.GetValue(helper); + } + } +} diff --git a/src/Hangfire.Console/Support/RouteCollectionExtensions.cs b/src/Hangfire.Console/Support/RouteCollectionExtensions.cs new file mode 100644 index 0000000..2dbfc53 --- /dev/null +++ b/src/Hangfire.Console/Support/RouteCollectionExtensions.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Hangfire.Dashboard.Extensions +{ + /// + /// Provides extension methods for . + /// + internal static class RouteCollectionExtensions + { + private static readonly FieldInfo _dispatchers = typeof(RouteCollection).GetTypeInfo().GetDeclaredField(nameof(_dispatchers)); + + /// + /// Returns a private list of registered routes. + /// + /// Route collection + private static List> GetDispatchers(this RouteCollection routes) + { + if (routes == null) + throw new ArgumentNullException(nameof(routes)); + + return (List>)_dispatchers.GetValue(routes); + } + + /// + /// Combines exising dispatcher for with . + /// If there's no dispatcher for the specified path, adds a new one. + /// + /// Route collection + /// Path template + /// Dispatcher to add or append for specified path + public static void Append(this RouteCollection routes, string pathTemplate, IDashboardDispatcher dispatcher) + { + if (routes == null) + throw new ArgumentNullException(nameof(routes)); + if (pathTemplate == null) + throw new ArgumentNullException(nameof(pathTemplate)); + if (dispatcher == null) + throw new ArgumentNullException(nameof(dispatcher)); + + var list = routes.GetDispatchers(); + + for (int i = 0; i < list.Count; i++) + { + var pair = list[i]; + if (pair.Item1 == pathTemplate) + { + var composite = pair.Item2 as CompositeDispatcher; + if (composite == null) + { + // replace original dispatcher with a composite one + composite = new CompositeDispatcher(pair.Item2); + list[i] = new Tuple(pathTemplate, composite); + } + + composite.AddDispatcher(dispatcher); + return; + } + } + + routes.Add(pathTemplate, dispatcher); + } + + /// + /// Replaces exising dispatcher for with . + /// If there's no dispatcher for the specified path, adds a new one. + /// + /// Route collection + /// Path template + /// Dispatcher to set for specified path + public static void Replace(this RouteCollection routes, string pathTemplate, IDashboardDispatcher dispatcher) + { + if (routes == null) + throw new ArgumentNullException(nameof(routes)); + if (pathTemplate == null) + throw new ArgumentNullException(nameof(pathTemplate)); + if (dispatcher == null) + throw new ArgumentNullException(nameof(dispatcher)); + + var list = routes.GetDispatchers(); + + for (int i = 0; i < list.Count; i++) + { + var pair = list[i]; + if (pair.Item1 == pathTemplate) + { + list[i] = new Tuple(pathTemplate, dispatcher); + return; + } + } + + routes.Add(pathTemplate, dispatcher); + } + + /// + /// Removes dispatcher for . + /// + /// Route collection + /// Path template + public static void Remove(this RouteCollection routes, string pathTemplate) + { + if (routes == null) + throw new ArgumentNullException(nameof(routes)); + if (pathTemplate == null) + throw new ArgumentNullException(nameof(pathTemplate)); + + var list = routes.GetDispatchers(); + + for (int i = 0; i < list.Count; i++) + { + var pair = list[i]; + if (pair.Item1 == pathTemplate) + { + list.RemoveAt(i); + return; + } + } + } + } +} diff --git a/src/Hangfire.Console/project.json b/src/Hangfire.Console/project.json new file mode 100644 index 0000000..9653c25 --- /dev/null +++ b/src/Hangfire.Console/project.json @@ -0,0 +1,33 @@ +{ + "version": "1.0.0-*", + "title": "Hangfire.Console", + "description": "Job console for Hangfire", + "authors": [ "Alexey Skalozub" ], + + "packOptions": { + "summary": "Job console extension for Hangfire", + "tags": [ "hangfire", "console", "logging" ], + "owners": [ "Alexey Skalozub" ], + "releaseNotes": "Initial release", + "repository": { + "type": "git", + "url": "https://github.com/pieceofsummer/Hangfire.Console" + } + }, + + "buildOptions": { + "warningsAsErrors": true, + "xmlDoc": true, + + "embed": [ "Resources/*" ] + }, + + "dependencies": { + "Hangfire.Core": "1.6.6" + }, + + "frameworks": { + "netstandard1.3": {}, + "net45": {} + } +} diff --git a/tests/Hangfire.Console.Tests/Dashboard/ConsoleRendererFacts.cs b/tests/Hangfire.Console.Tests/Dashboard/ConsoleRendererFacts.cs new file mode 100644 index 0000000..fd043f3 --- /dev/null +++ b/tests/Hangfire.Console.Tests/Dashboard/ConsoleRendererFacts.cs @@ -0,0 +1,55 @@ +using Hangfire.Console.Dashboard; +using Hangfire.Console.Serialization; +using System; +using System.Text; +using Xunit; + +namespace Hangfire.Console.Tests.Dashboard +{ + public class ConsoleRendererFacts + { + [Fact] + public void RendersLine() + { + var line = new ConsoleLine() { TimeOffset = 0, Message = "test" }; + var builder = new StringBuilder(); + + ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Equal("
+ <1mstest
", builder.ToString()); + } + + [Fact] + public void RendersLine_WithOffset() + { + var line = new ConsoleLine() { TimeOffset = 1.108, Message = "test" }; + var builder = new StringBuilder(); + + ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Equal("
+1.108stest
", builder.ToString()); + } + + [Fact] + public void RendersLine_WithNegativeOffset() + { + var line = new ConsoleLine() { TimeOffset = -1.206, Message = "test" }; + var builder = new StringBuilder(); + + ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Equal("
-1.206stest
", builder.ToString()); + } + + [Fact] + public void RendersLine_WithColor() + { + var line = new ConsoleLine() { TimeOffset = 0, Message = "test", TextColor = "#ffffff" }; + var builder = new StringBuilder(); + + ConsoleRenderer.RenderLine(builder, line, new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Equal("
+ <1mstest
", builder.ToString()); + } + } +} diff --git a/tests/Hangfire.Console.Tests/Hangfire.Console.Tests.xproj b/tests/Hangfire.Console.Tests/Hangfire.Console.Tests.xproj new file mode 100644 index 0000000..8761a88 --- /dev/null +++ b/tests/Hangfire.Console.Tests/Hangfire.Console.Tests.xproj @@ -0,0 +1,22 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + d5068e09-a43c-4b05-8068-c50e9497eb25 + Hangfire.Console.Tests + .\obj + .\bin\ + v4.5.2 + + + 2.0 + + + + + + \ No newline at end of file diff --git a/tests/Hangfire.Console.Tests/Properties/AssemblyInfo.cs b/tests/Hangfire.Console.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..d54f0d5 --- /dev/null +++ b/tests/Hangfire.Console.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Hangfire.Console.Tests")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("d5068e09-a43c-4b05-8068-c50e9497eb25")] diff --git a/tests/Hangfire.Console.Tests/Serialization/ConsoleIdFacts.cs b/tests/Hangfire.Console.Tests/Serialization/ConsoleIdFacts.cs new file mode 100644 index 0000000..58ed67f --- /dev/null +++ b/tests/Hangfire.Console.Tests/Serialization/ConsoleIdFacts.cs @@ -0,0 +1,55 @@ +using Hangfire.Console.Serialization; +using System; +using Xunit; + +namespace Hangfire.Console.Tests.Serialization +{ + public class ConsoleIdFacts + { + [Fact] + public void Ctor_ThrowsAnException_WhenJobIdIsNull() + { + Assert.Throws("jobId", () => new ConsoleId(null, DateTime.UtcNow)); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenJobIdIsEmpty() + { + Assert.Throws("jobId", () => new ConsoleId("", DateTime.UtcNow)); + } + + [Fact] + public void SerializesCorrectly() + { + var x = new ConsoleId("123", new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var s = x.ToString(); + Assert.Equal("00cdb7af151123", s); + } + + [Fact] + public void Parse_ThrowsAnException_WhenValueIsNull() + { + Assert.Throws("value", () => ConsoleId.Parse(null)); + } + + [Fact] + public void Parse_ThrowsAnException_WhenValueIsTooShort() + { + Assert.Throws("value", () => ConsoleId.Parse("00cdb7af1")); + } + + [Fact] + public void Parse_ThrowsAnException_WhenValueIsInvalid() + { + Assert.Throws("value", () => ConsoleId.Parse("00x00y00z001")); + } + + [Fact] + public void DeserializesCorrectly() + { + var x = ConsoleId.Parse("00cdb7af151123"); + Assert.Equal("123", x.JobId); + Assert.Equal(new DateTime(2016, 1, 1, 0, 0, 0, DateTimeKind.Utc), x.Timestamp); + } + } +} diff --git a/tests/Hangfire.Console.Tests/project.json b/tests/Hangfire.Console.Tests/project.json new file mode 100644 index 0000000..c6754b9 --- /dev/null +++ b/tests/Hangfire.Console.Tests/project.json @@ -0,0 +1,29 @@ +{ + "version": "1.0.0-*", + + "buildOptions": { + "debugType": "portable" + }, + + "testRunner": "xunit", + + "dependencies": { + "xunit": "2.2.0-*", + "dotnet-test-xunit": "2.2.0-*", + "Moq": "4.6.38-alpha", + "Hangfire.Console": { "target": "project" } + }, + + "frameworks": { + "netcoreapp1.0": { + "imports": "dnxcore50", + "dependencies": { + "Microsoft.NETCore.App": { + "type": "platform", + "version": "1.0.1" + }, + "System.Reflection.TypeExtensions": "4.1.0" + } + } + } +}