Dashboard Extension

I was wondering whether there is a standard way to extend the dashboard?

I want to schedule a job like this,

BackgroundJob.Schedule<BackgroundTask>(x => x.StartBackgroundTask("petermc", "password", "AT", "Signs"), TimeSpan.FromSeconds(10));

And the code to run it will look like this,

public void StartBackgroundTask(string username, string password, string database, string assetType)
    {
    }

Note that I have a database parameter there. We are dealing with hundreds of databases and it would be great in the dashboard if we could filter the results of the succeeded jobs by database.

What is the best way to do this? Anything specific we could do to the existing dashboard, or do we have to build our own separate dashboard for this?

For a start I am thinking we would need to create our own GlobalJobFilter to log the data we’d need to log. I would look to log the parameters used to start the process, the result, and the data related to the result, plus what database this was run against.

Will show you a direction, however I’ll leave the details on you until it is documented :frowning:

One. Create a class that implements IApplyStateFilter

DatabaseSpecificAttribute(string parameterName) : JobFilterAttribute, IApplyStateFilter
public void OnStateApplied(ApplyStateContext context)
{
    if (context.NewState is SucceededState)
    {
        var parameterIndex = context.BackgroundJob.Job.Method.<Use reflection to obtain index>;
        var databaseName = context.BackgroundJob.Job.Arguments[parameterIndex];
        context.Transaction.AddToSet("myapp:databases", databaseName);
        context.Transaction.AddToSet($"myapp:databases:{databaseName}", context.BackgroundJob.JobId);
        // You need to remove the job to prevent infinite growth, for example, using delayed job
        // that will use JobStorage.GetConnection().CreateWriteOnlyTransaction().RemoveFromSet();
        BackgroundJob.Schedule(() => RemoveFromMyAppDatabasesSet(context.BackgroundJob.JobId));
    }
}

Two. Apply attributes to your job methods.
Three. Add some pages that read those sets, please see RetriesPage.cshtml for examples.
Four. Add routes using DashboardRoutes.Routes, see DashboardRoutes for examples.
Five. Add a new item to NavigationMenu or JobsSidebarMenu

Is this now an “official thing”? Are the details documented? We run a dedicated server for our background tasks, and I want to be able to use the Hangfire dashboard, plus a couple other custom features, without having to create an entire front-end website.

So faced with the same need (custom dashboard modifications) I went about working with the cryptic suggestions above. I managed to figure it out without too much trouble so thought I’d share it here.

First, it’s a little cumbersome as there’s some sort of cshtml conversion tool used to auto generate RazorPage partial classes. As a result, you have to write html within code.

So. First step, add some routes and a navigation menu item. I did this in my startup.cs just before calling app.UseHangfireDashboard

  DashboardRoutes.Routes.AddRazorPage("/management", x=> new ManagementPage());
  NavigationMenu.Items.Add(page => new MenuItem("Management", page.Url.To("/management"))
        {
            Active = page.RequestPath.StartsWith("/management")
        });

This will add a new top bar navigation item and link it to /management url.

Now you need to create the ManagementPage defined above. It’s pretty basic right now, but shows you how to get started.

 public class ManagementPage : RazorPage
{
    public override void Execute()
    {

        WriteLiteral("\r\n");
        Layout = new LayoutPage("Management");

        WriteLiteral("<div class=\"row\">\r\n");
        WriteLiteral("<div class=\"col-md-3\">\r\n");

        Write(Html.RenderPartial(new CustomSidebarMenu(ManagementSidebarMenu.Items)));

        WriteLiteral("</div>\r\n");

        WriteLiteral("<div class=\"col-md-9\">\r\n");
        WriteLiteral("<h1 class=\"page-header\">\r\n");
        Write("Management");
        WriteLiteral("</h1>\r\n");

        WriteLiteral("<div class=\"alert alert-success\">\r\n");
        Write("Nothing");
        WriteLiteral("\r\n</div>\r\n");
        WriteLiteral("\r\n</div>\r\n");
    }

}

Next, you’ll notice I also played with adding a CustomSidebarMenu. This is an exact copy of the JobsSidebarMenu. However, since the classes end up being internal I couldn’t re-use them.

 public class CustomSidebarMenu : RazorPage
{
    public CustomSidebarMenu([NotNull] IEnumerable<Func<RazorPage, MenuItem>> items)
    {
        if (items == null) throw new ArgumentNullException(nameof(items));
        Items = items;
    }

    public IEnumerable<Func<RazorPage, MenuItem>> Items { get; }

    public override void Execute()
    {
        WriteLiteral("\r\n");

        if (!Items.Any()) return;

        WriteLiteral("<div id=\"stats\" class=\"list-group\">\r\n");

        foreach (var item in Items)
        {
            var itemValue = item(this);
            WriteLiteral("<a href=\"");
            Write(itemValue.Url);
            WriteLiteral("\" class=\"list-group-item ");
            Write(itemValue.Active ? "active" : null);
            WriteLiteral("\">\r\n");
            Write(itemValue.Text);
            WriteLiteral("\r\n<span class=\"pull-right\">\r\n");

            foreach (var metric in itemValue.GetAllMetrics())
            {
                Write(Html.InlineMetric(metric));
            }

            WriteLiteral("</span>\r\n</a>\r\n");
        }
        
        WriteLiteral("</div>\r\n");
    }
}

Now, here’s a sample of the custom sidebar definition.

 public static class ManagementSidebarMenu
{
    public static readonly List<Func<RazorPage, MenuItem>> Items
        = new List<Func<RazorPage, MenuItem>>();

    static ManagementSidebarMenu()
    {
        Items.Add(page => new MenuItem("Index", page.Url.To("/management/index"))
        {
            Active = page.RequestPath.StartsWith("/management/index")
        });

        Items.Add(page => new MenuItem("Import", page.Url.To("/management/import"))
        {
            Active = page.RequestPath.StartsWith("/management/import")
        });

        Items.Add(page => new MenuItem("Misc", page.Url.To("/management/misc"))
        {
            Active = page.RequestPath.StartsWith("/management/misc")
        });

        Items.Add(page => new MenuItem("Email", page.Url.To("/management/email"))
        {
            Active = page.RequestPath.StartsWith("/management/email")
        });
    }
}

That’s about as far as I have gotten so far, but it covers the basics of getting started. You should be able to add additional routes to then use the custom menu and more pages to do what you need.

I’ll add to this post if I find anything else useful.

4 Likes

Any news regarding this in terms of documentation? Trying out the answer from @tracstarr but it feels really complicated, anyone got any (new) suggestions?

1 Like

I found a solution.
What you need is a tool called RazorGenerator. You can download this with NuGet.
Then, by creating a cshtml file with whatever code you fit necessary, one can click into “properties” on the file, and write “RazorGenerator” in the “custom tool”-section. This will automatically convert the cshtml file into a .cs file (creating a new file, named the same but with “generated” in the file name), which one can use when adding to either NavigationSideBarMenu or JobSideBarMenu, in the Startup.cs. In my case, what I’ve done is added a new page called “Locked”, where I can see locked recurringjobs if exceptions occur. The “Locked” function is something extra I’ve added into the HangFire database, it basically locks a job and prevents it from running if it fails once.

Below is a code snippet (as previously written in this thread) on how you add a new page. The page is a .cshtml file named LockedPage(), which has been converted into a .cs file by using RazorGenerator.

DashboardRoutes.Routes.AddRazorPage("/locked", x => new LockedPage());
JobsSidebarMenu.Items.Add(page => new MenuItem("Locked", page.Url.To("/locked"))
            {
                Active = page.RequestPath.StartsWith("/locked")
            }); 

Some other problems regarding this, is when trying to convert a .cshtml file into a .cs file using Razorgenerator, this needs to be written in the .cshtml file in order to work.

@* Generator: Template
    TypeVisibility: Internal
    GeneratePrettyNames: True
    RazorVersion : 1
*@

The important part here is RazorVersion : 1 since it needs to comply with the version Hangfire is using.

1 Like

Hi, I have tried to implement [tracstarr] suggestion (kinda copied his code snippets directly into my solution, but I must be missing something, I keep getting a 404 management on the route. (Point to note, our company wraps the Hangfile assets in their own worker project for DI.

Solution is .net Core3.1

Any Help would be awesome

our Main:

    public static void Main(string[] args)
    {

        DashboardRoutes.Routes.AddRazorPage("/managment", x => new ManagementPage());

        NavigationMenu.Items.Add(page => new MenuItem("Management", page.Url.To("/management"))
        {
            Active = page.RequestPath.StartsWith("/management")
        });

        CreateHostBuilder(args)               
           .Build()
           .Run();        
    }

my CreateHostBuilder (AppWorker is our wrapper around HangFile

   public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureAppConfiguration((hostingContext, config) =>
            {
                config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
                config.AddJsonFile($"appsettings.{hostingContext.HostingEnvironment.EnvironmentName}.json", optional: true);
            })

            .ConfigureWebHostDefaults(webBuilder =>
            {

                webBuilder.ConfigureServices(services => services
                    .AddCors(options =>
                        {
                            options.AddDefaultPolicy(
                            builder =>
                            {
                                builder.AllowAnyOrigin()
                                .AllowAnyHeader()
                                .AllowAnyMethod();
                            });
                        })
                    .AddAuthentication(IISDefaults.AuthenticationScheme).Services
                    .AddAuthentication(HttpSysDefaults.AuthenticationScheme).Services
                    .Configure<IISOptions>(options =>
                    {
                        options.ForwardClientCertificate = false;
                        options.AutomaticAuthentication = true;
                    })

                    //.AddMvcCore(option => option.EnableEndpointRouting = false).Services
                    .AddMvc().Services

                    .AddControllers().Services
                    .AddAppBuilder()
                    .AddAuditor((b) => b
                        .AddLoggerAuditSink()
                        .AddPersistenceAuditSink<AuditTransaction>("Blah")
                    )
                    .AddPersistence(builder => builder
                        .AddSql<DatabaseContext>("Blah").AddEntityHelper()
                    )
                    .AddAppWorker((workerBuilder, serviceCollection, options) => workerBuilder.AddHangfire(serviceCollection, options),
                        jobBuilder => jobBuilder
                     	.AddRecurringJob("MinuteClock", () => Console.WriteLine(DateTime.Now), "* * * * *")

                     )
                    .AddAppWorkerServer((serverBuilder, serviceCollection) => serverBuilder.AddHangfireServer(serviceCollection))


                    .Build()
                    )

                    .Configure(app => app
                        .UseApiDeveloperExceptionPage()
                        .UseHttpsRedirection()
                        .UseRouting()
                        .UseAuthentication()
                        .UseAuthorization()
                        .UseApiAuditor()
                        .UseCors()
                        .UseInitializers()
                        .UseRestErrorExceptionHandler()
                        .UseAppWorker()
                        .UseAppWorkerDashboard()
                        .UseMvc()

                    );
            }).UseStructuredLogging(builder => builder.EnableConsoleSink(true));
}