Name Message Date
📁 Properties Add web dashboard for viewing monitor status and detections. 5 hours ago
📁 Protos Initialize project 1 month ago
📁 wwwroot Add configurable settings and per-monitoring SMS recipients. 4 hours ago
📄 .containerfile Containerize 10 days ago
📄 .dockerignore Containerize 10 days ago
📄 .editorconfig Initialize project 1 month ago
📄 .gitignore Remove database file from repository 10 days ago
📄 AppSettings.cs Add configurable settings and per-monitoring SMS recipients. 4 hours ago
📄 appsettings.Development.json Fix Claudes mess 11 days ago
📄 appsettings.json Fix Claudes mess 11 days ago
📄 BfiMonitor.csproj Add web dashboard for viewing monitor status and detections. 5 hours ago
📄 BfiMonitor.slnx Initialize project 1 month ago
📄 CheckMonitoringJob.cs Add configurable settings and per-monitoring SMS recipients. 4 hours ago
📄 dotnet-tools.json Initialize project 1 month ago
📄 global.json Fix Claudes mess 11 days ago
📄 IntervalScheduler.cs Add configurable settings and per-monitoring SMS recipients. 4 hours ago
📄 MonitoringCheckScheduler.cs Add multi-monitoring management with scan triggers and editing. 5 hours ago
📄 MonitorOptions.cs Add configurable settings and per-monitoring SMS recipients. 4 hours ago
📄 OpenTelemetryExtensions.cs Add web dashboard for viewing monitor status and detections. 5 hours ago
📄 packages.lock.json Add web dashboard for viewing monitor status and detections. 5 hours ago
📄 PlaywrightBrowserService.cs Use Playwright settings from Lukas 10 days ago
📄 Program.cs Add configurable settings and per-monitoring SMS recipients. 4 hours ago
📄 ScheduleMonitoringChecksJob.cs Add multi-monitoring management with scan triggers and editing. 5 hours ago
📄 ScreeningRepository.cs Add configurable settings and per-monitoring SMS recipients. 4 hours ago
📄 SendSmsJob.cs Add tracing 10 days ago
📄 Tracing.cs Add configurable settings and per-monitoring SMS recipients. 4 hours ago
📄 CheckMonitoringJob.cs
using System.Diagnostics;
using BfiMonitor;
using Microsoft.Extensions.Logging;
using Microsoft.Playwright;
using Quartz;

[DisallowConcurrentExecution]
internal sealed class CheckMonitoringJob(
    PlaywrightBrowserService browserService,
    ScreeningRepository repository,
    ILogger<CheckMonitoringJob> logger,
    TimeProvider timeProvider
) : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        var monitorId = context.MergedJobDataMap.GetInt("monitorId");
        var force = context.MergedJobDataMap.GetBoolean("force");

        using var activity = Tracing.StartCheckerJobForMonitor(monitorId);
        activity?.SetTag("Force", force);

        var monitor = await repository.GetMonitoringByIdAsync(monitorId, context.CancellationToken);
        if (monitor is null)
        {
            logger.LogMonitoringNotFound(monitorId);
            activity?.SetTag("EndReason", "MonitoringNotFound");
            activity?.SetStatus(ActivityStatusCode.Ok);
            return;
        }

        activity?.SetTag("MonitorUrl", monitor.Url);

        if (monitor.ArchivedAt is not null)
        {
            logger.LogMonitoringArchived(monitorId);
            activity?.SetTag("EndReason", "MonitoringArchived");
            activity?.SetStatus(ActivityStatusCode.Ok);
            return;
        }

        if (monitor.PausedAt is not null && !force)
        {
            logger.LogMonitoringPaused(monitorId);
            activity?.SetTag("EndReason", "MonitoringPaused");
            activity?.SetStatus(ActivityStatusCode.Ok);
            return;
        }

        var latestHtml = await repository.LatestHtml(monitor.Id, context.CancellationToken);
        logger.LogLatestHtml(monitor.Id, latestHtml);

        var page = await browserService.NewPageAsync();
        try
        {
            logger.LogFetchingSite(monitor.Id, monitor.Url);
            await page.GotoAsync(
                monitor.Url,
                new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = TimeSpan.FromSeconds(45).Milliseconds }
            );
            await Task.Delay(TimeSpan.FromSeconds(2));
            var resultsElement = await page.WaitForSelectorAsync(monitor.Selector);
            if (resultsElement is null)
            {
                logger.LogNoResultsElementFound(monitor.Id, monitor.Selector);
                activity?.SetTag("EndReason", "NoResultsElementFound");
                activity?.SetStatus(ActivityStatusCode.Ok);
                return;
            }

            var currentHtml = await resultsElement.InnerHTMLAsync();
            logger.LogCurrentHtml(monitor.Id, currentHtml);

            if (currentHtml == latestHtml)
            {
                logger.LogHtmlNotUpdated(monitor.Id);
                activity?.SetTag("EndReason", "HtmlNotUpdated");
                activity?.SetStatus(ActivityStatusCode.Ok);
                return;
            }

            logger.LogHtmlUpdated(monitor.Id);
            await repository.InsertNewDetection(
                monitor.Id,
                currentHtml,
                timeProvider.GetUtcNow(),
                context.CancellationToken
            );

            var label = string.IsNullOrWhiteSpace(monitor.Name) ? monitor.Url : monitor.Name;
            var message = $"Site updated: {label}";
            foreach (var phoneNumber in monitor.PhoneNumbers)
            {
                var dataMap = new JobDataMap { { "phoneNumber", phoneNumber }, { "message", message } };
                var trigger = TriggerBuilder.Create().ForJob(SendSmsJob.Key).UsingJobData(dataMap).StartNow().Build();
                await context.Scheduler.ScheduleJob(trigger, context.CancellationToken);
                logger.LogSmsScheduled(phoneNumber);
            }

            activity?.SetTag("EndReason", "ChangeDetected");
            activity?.SetStatus(ActivityStatusCode.Ok);
        }
        catch (Exception ex)
        {
            activity?.SetTag("EndReason", "Exception");
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            activity?.AddException(ex);
            throw;
        }
        finally
        {
            await page.CloseAsync();
        }
    }
}

internal static partial class CheckMonitoringJobLoggerExtensions
{
    [LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: not found, skipping check")]
    public static partial void LogMonitoringNotFound(this ILogger logger, int monitorId);

    [LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: archived, skipping check")]
    public static partial void LogMonitoringArchived(this ILogger logger, int monitorId);

    [LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: paused, skipping scheduled check")]
    public static partial void LogMonitoringPaused(this ILogger logger, int monitorId);

    [LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: latest HTML was {LatestHtml}")]
    public static partial void LogLatestHtml(this ILogger logger, int monitorId, string? latestHtml);

    [LoggerMessage(LogLevel.Warning, "Monitor {MonitorId}: no {ElementSelector} element found")]
    public static partial void LogNoResultsElementFound(this ILogger logger, int monitorId, string elementSelector);

    [LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: fetching {Url}")]
    public static partial void LogFetchingSite(this ILogger logger, int monitorId, string url);

    [LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: current HTML is {CurrentHtml}")]
    public static partial void LogCurrentHtml(this ILogger logger, int monitorId, string? currentHtml);

    [LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: HTML has not changed")]
    public static partial void LogHtmlNotUpdated(this ILogger logger, int monitorId);

    [LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: HTML has changed")]
    public static partial void LogHtmlUpdated(this ILogger logger, int monitorId);

    [LoggerMessage(LogLevel.Information, "Scheduled SMS to {PhoneNumber}")]
    public static partial void LogSmsScheduled(this ILogger logger, string phoneNumber);
}