Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Blashing.Server/Jobs/IJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Blashing.Server.Jobs;

/// <summary>
/// Represents a scheduled job that executes periodically.
/// Implement this interface to define a background job for the dashboard scheduler,
/// similar to how Dashing/Smashing jobs work (e.g. SCHEDULER.every '5s').
/// </summary>
public interface IJob
{
/// <summary>Gets the interval at which this job should run.</summary>
TimeSpan Period { get; }

/// <summary>Executes the job logic.</summary>
/// <param name="stoppingToken">A token that signals when the host is shutting down.</param>
Task ExecuteAsync(CancellationToken stoppingToken);
}
24 changes: 24 additions & 0 deletions src/Blashing.Server/Jobs/SampleJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Blashing.Server.Jobs;

/// <summary>
/// A sample job that demonstrates how to implement <see cref="IJob"/>.
/// Runs every 30 seconds and logs a message, similar to a Dashing/Smashing job
/// that sends periodic data events to dashboard widgets.
/// </summary>
public class SampleJob : IJob
{
private readonly ILogger<SampleJob> _logger;

public TimeSpan Period => TimeSpan.FromSeconds(30);

public SampleJob(ILogger<SampleJob> logger)
{
_logger = logger;
}

public Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Executing {JobName} at: {Time}", nameof(SampleJob), DateTimeOffset.UtcNow);
return Task.CompletedTask;
}
}
9 changes: 9 additions & 0 deletions src/Blashing.Server/Program.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
using Blashing.Server.Jobs;
using Blashing.Server.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();

// Register scheduler jobs (add more IJob implementations here as needed).
builder.Services.AddSingleton<IJob, SampleJob>();

// Register the hosted scheduler service that runs all IJob implementations.
builder.Services.AddHostedService<SchedulerService>();

var app = builder.Build();

// Configure the HTTP request pipeline.
Expand Down
53 changes: 53 additions & 0 deletions src/Blashing.Server/Services/SchedulerService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Blashing.Server.Jobs;

namespace Blashing.Server.Services;

/// <summary>
/// A hosted background service that runs all registered <see cref="IJob"/> implementations
/// on their specified periods, comparable to the Dashing/Smashing scheduler.
/// </summary>
public class SchedulerService : BackgroundService
{
private readonly IEnumerable<IJob> _jobs;
private readonly ILogger<SchedulerService> _logger;

public SchedulerService(IEnumerable<IJob> jobs, ILogger<SchedulerService> logger)
{
_jobs = jobs;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var jobList = _jobs.ToList();
if (jobList.Count == 0)
{
_logger.LogWarning("No jobs registered with {ServiceName}", nameof(SchedulerService));
return;
}

var tasks = jobList.Select(job => RunJobAsync(job, stoppingToken));
await Task.WhenAll(tasks);
}

private async Task RunJobAsync(IJob job, CancellationToken stoppingToken)
{
_logger.LogInformation("Starting job {JobName} with period {Period}", job.GetType().Name, job.Period);

using PeriodicTimer timer = new(job.Period);

while (!stoppingToken.IsCancellationRequested &&
await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
_logger.LogInformation("Executing job {JobName}", job.GetType().Name);
await job.ExecuteAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Error occurred executing job {JobName}", job.GetType().Name);
}
}
}
}
5 changes: 5 additions & 0 deletions src/Blashing.Shared/Layout/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
<span class="oi oi-grid-two-up" aria-hidden="true"></span> Additional Widgets
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="scheduler">
<span class="oi oi-timer" aria-hidden="true"></span> Scheduler
</NavLink>
</div>

<div class="nav-item px-3">
<NavLink class="nav-link" href="reports/dependency-check-report.html">
Expand Down
103 changes: 103 additions & 0 deletions src/Blashing.Shared/Pages/Scheduler.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
@using Excubo.Blazor.Grids
@implements IAsyncDisposable

@page "/scheduler"

<PageTitle>Scheduler</PageTitle>

<h3>Scheduler</h3>

<p>
This page demonstrates widgets updating on a schedule using a
<a href="https://learn.microsoft.com/en-us/dotnet/api/system.threading.periodictimer" target="_blank">PeriodicTimer</a>,
similar to how Dashing/Smashing jobs push data to widgets (e.g. <code>SCHEDULER.every '5s'</code>).
</p>

<Dashboard RowGap="1em" ColumnGap="1em" AspectRatio="1.5" ColumnCount="3" style="height: 500px; background-color: black;">

<Element Row="0" Column="0" style="border: none; box-shadow: 0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,.12)">
<ClockWidget Date="@_date" Time="@_time" />
</Element>

<Element Row="0" Column="1" style="border: none; box-shadow: 0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,.12)">
<NumberWidget
Title="Request Count"
Current="@_requestCount.ToString()"
Difference="@($"+{_requestDelta}")"
MoreInfo="requests per interval"
UpdatedAtMessage="@_updatedAt" />
</Element>

<Element Row="0" Column="2" style="border: none; box-shadow: 0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,.12)">
<TextWidget
Title="Scheduler Status"
Text="@_statusText"
MoreInfo="ticks every 5 seconds"
UpdatedAtMessage="@_updatedAt" />
</Element>

</Dashboard>

@code {
// Clock — updates every second
private string _date = string.Empty;
private string _time = string.Empty;
private PeriodicTimer? _clockTimer;
private Task? _clockTask;

// NumberWidget counter — updates every 5 seconds
private int _requestCount = 0;
private int _requestDelta = 0;
private string _statusText = "Running";
private string _updatedAt = string.Empty;
private PeriodicTimer? _dataTimer;
private Task? _dataTask;

private readonly Random _random = new();

protected override void OnInitialized()
{
var now = DateTime.Now;
_date = now.ToString("dd/MM/yyyy");
_time = now.ToString("HH:mm:ss");
_updatedAt = $"Last updated at {now:HH:mm:ss}";

_clockTimer = new PeriodicTimer(TimeSpan.FromSeconds(1));
_clockTask = RunClockAsync();

_dataTimer = new PeriodicTimer(TimeSpan.FromSeconds(5));
_dataTask = RunDataAsync();
}

private async Task RunClockAsync()
{
while (_clockTimer is not null &&
await _clockTimer.WaitForNextTickAsync())
{
_date = DateTime.Now.ToString("dd/MM/yyyy");
_time = DateTime.Now.ToString("HH:mm:ss");
await InvokeAsync(StateHasChanged);
}
}

private async Task RunDataAsync()
{
while (_dataTimer is not null &&
await _dataTimer.WaitForNextTickAsync())
{
_requestDelta = _random.Next(1, 50);
_requestCount += _requestDelta;
_updatedAt = $"Last updated at {DateTime.Now:HH:mm:ss}";
await InvokeAsync(StateHasChanged);
}
}

public async ValueTask DisposeAsync()
{
_clockTimer?.Dispose();
_dataTimer?.Dispose();

if (_clockTask is not null) await _clockTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
if (_dataTask is not null) await _dataTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
}