Name Message Date
📁 Properties Add web dashboard for viewing monitor status and detections. 2 hours ago
📁 Protos Initialize project 1 month ago
📁 wwwroot Add configurable settings and per-monitoring SMS recipients. 1 hour 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. 1 hour ago
📄 appsettings.Development.json Fix Claudes mess 10 days ago
📄 appsettings.json Fix Claudes mess 10 days ago
📄 BfiMonitor.csproj Add web dashboard for viewing monitor status and detections. 2 hours ago
📄 BfiMonitor.slnx Initialize project 1 month ago
📄 CheckMonitoringJob.cs Add configurable settings and per-monitoring SMS recipients. 1 hour ago
📄 dotnet-tools.json Initialize project 1 month ago
📄 global.json Fix Claudes mess 10 days ago
📄 IntervalScheduler.cs Add configurable settings and per-monitoring SMS recipients. 1 hour ago
📄 MonitoringCheckScheduler.cs Add multi-monitoring management with scan triggers and editing. 1 hour ago
📄 MonitorOptions.cs Add configurable settings and per-monitoring SMS recipients. 1 hour ago
📄 OpenTelemetryExtensions.cs Add web dashboard for viewing monitor status and detections. 2 hours ago
📄 packages.lock.json Add web dashboard for viewing monitor status and detections. 2 hours ago
📄 PlaywrightBrowserService.cs Use Playwright settings from Lukas 10 days ago
📄 Program.cs Add configurable settings and per-monitoring SMS recipients. 1 hour ago
📄 ScheduleMonitoringChecksJob.cs Add multi-monitoring management with scan triggers and editing. 1 hour ago
📄 ScreeningRepository.cs Add configurable settings and per-monitoring SMS recipients. 1 hour ago
📄 SendSmsJob.cs Add tracing 10 days ago
📄 Tracing.cs Add configurable settings and per-monitoring SMS recipients. 1 hour ago
📄 Program.cs
using BfiMonitor;
using HuaweiWifiSms.Grpc;
using Microsoft.Extensions.Options;
using Quartz;

var builder = WebApplication.CreateBuilder(args);

builder.ConfigureOpenTelemetry();

builder.Services.AddOptions<MonitorOptions>().BindConfiguration("Monitor");

builder.Services.AddGrpcClient<SmsSender.SmsSenderClient>(
    (sp, o) =>
    {
        var opts = sp.GetRequiredService<IOptions<MonitorOptions>>().Value;
        o.Address = new Uri(opts.SmsSenderAddress);
    }
);

builder.Services.AddSingleton<PlaywrightBrowserService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PlaywrightBrowserService>());

builder.Services.AddSingleton<ScreeningRepository>();
builder.Services.AddSingleton<MonitoringCheckScheduler>();
builder.Services.AddSingleton<IntervalScheduler>();

builder.Services.AddSingleton(TimeProvider.System);

var intervalSeconds = builder.Configuration.GetValue("Monitor:IntervalSeconds", 300);

builder.Services.AddQuartz(q =>
{
    q.AddJob<ScheduleMonitoringChecksJob>(QuartzScheduling.ScheduleMonitoringChecksJobKey);
    q.AddTrigger(t =>
        t.ForJob(QuartzScheduling.ScheduleMonitoringChecksJobKey)
            .WithIdentity(QuartzScheduling.ScheduleMonitoringChecksTriggerKey)
            .StartNow()
            .WithSimpleSchedule(s => s.WithIntervalInSeconds(intervalSeconds).RepeatForever())
    );

    q.AddJob<SendSmsJob>(SendSmsJob.Key, j => j.StoreDurably());
});

builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);

var app = builder.Build();

await app.Services.GetRequiredService<IntervalScheduler>().ApplyStoredIntervalAsync();

app.UseDefaultFiles();
app.UseStaticFiles();

app.MapGet(
    "/api/status",
    async (IOptions<MonitorOptions> options, ScreeningRepository repository, CancellationToken ct) =>
    {
        var settings = await repository.GetAppSettingsAsync(ct);
        return Results.Json(
            new
            {
                settings.IntervalSeconds,
                settings.DefaultPhoneNumbers,
                options.Value.DefaultSelector,
                RunningMonitorings = await repository.CountRunningMonitoringsAsync(ct),
                ActiveMonitorings = await repository.CountActiveMonitoringsAsync(ct),
            }
        );
    }
);

app.MapGet(
    "/api/settings",
    async (ScreeningRepository repository, CancellationToken ct) =>
        Results.Json(await repository.GetAppSettingsAsync(ct))
);

app.MapPut(
    "/api/settings",
    async (
        UpdateSettingsRequest request,
        ScreeningRepository repository,
        IntervalScheduler intervalScheduler,
        CancellationToken ct
    ) =>
    {
        if (request.IntervalSeconds < 60)
        {
            return Results.BadRequest(new { error = "Interval must be at least 60 seconds." });
        }

        var settings = await repository.UpdateAppSettingsAsync(
            request.IntervalSeconds,
            request.DefaultPhoneNumbers,
            ct
        );
        await intervalScheduler.RescheduleAsync(settings.IntervalSeconds, ct);
        return Results.Json(settings);
    }
);

app.MapGet(
    "/api/monitorings",
    async (ScreeningRepository repository, CancellationToken ct, bool includeArchived = false) =>
        Results.Json(await repository.GetMonitoringsAsync(includeArchived, ct))
);

app.MapPost(
    "/api/monitorings",
    async (
        CreateMonitoringRequest request,
        ScreeningRepository repository,
        TimeProvider timeProvider,
        IOptions<MonitorOptions> options,
        CancellationToken ct
    ) =>
    {
        if (string.IsNullOrWhiteSpace(request.Url))
        {
            return Results.BadRequest(new { error = "Url is required." });
        }

        if (!Uri.TryCreate(request.Url, UriKind.Absolute, out _))
        {
            return Results.BadRequest(new { error = "Url must be an absolute URL." });
        }

        var selector = string.IsNullOrWhiteSpace(request.Selector)
            ? options.Value.DefaultSelector
            : request.Selector.Trim();

        var settings = await repository.GetAppSettingsAsync(ct);
        var phoneNumbers = request.PhoneNumbers ?? settings.DefaultPhoneNumbers;

        var monitoring = await repository.CreateMonitoringAsync(
            request.Url.Trim(),
            selector,
            request.Name?.Trim() ?? "",
            phoneNumbers,
            timeProvider.GetUtcNow(),
            ct
        );

        return Results.Created($"/api/monitorings/{monitoring.Id}", monitoring);
    }
);

app.MapPut(
    "/api/monitorings/{id:int}",
    async (int id, UpdateMonitoringRequest request, ScreeningRepository repository, CancellationToken ct) =>
    {
        if (string.IsNullOrWhiteSpace(request.Url))
        {
            return Results.BadRequest(new { error = "Url is required." });
        }

        if (!Uri.TryCreate(request.Url, UriKind.Absolute, out _))
        {
            return Results.BadRequest(new { error = "Url must be an absolute URL." });
        }

        if (string.IsNullOrWhiteSpace(request.Selector))
        {
            return Results.BadRequest(new { error = "Selector is required." });
        }

        if (await repository.GetMonitoringByIdAsync(id, ct) is null)
        {
            return Results.NotFound();
        }

        var monitoring = await repository.UpdateMonitoringAsync(
            id,
            request.Url.Trim(),
            request.Selector.Trim(),
            request.Name?.Trim() ?? "",
            request.PhoneNumbers,
            ct
        );

        return monitoring is null
            ? Results.Conflict(new { error = "Archived monitorings cannot be edited." })
            : Results.Json(monitoring);
    }
);

app.MapPost(
    "/api/monitorings/{id:int}/archive",
    async (int id, ScreeningRepository repository, TimeProvider timeProvider, CancellationToken ct) =>
    {
        if (await repository.GetMonitoringByIdAsync(id, ct) is null)
        {
            return Results.NotFound();
        }

        if (!await repository.ArchiveMonitoringAsync(id, timeProvider.GetUtcNow(), ct))
        {
            return Results.Conflict(new { error = "Monitoring is already archived." });
        }

        return Results.NoContent();
    }
);

app.MapPost(
    "/api/monitorings/{id:int}/pause",
    async (int id, ScreeningRepository repository, TimeProvider timeProvider, CancellationToken ct) =>
    {
        if (await repository.GetMonitoringByIdAsync(id, ct) is null)
        {
            return Results.NotFound();
        }

        if (!await repository.PauseMonitoringAsync(id, timeProvider.GetUtcNow(), ct))
        {
            return Results.Conflict(new { error = "Monitoring is archived or already paused." });
        }

        return Results.NoContent();
    }
);

app.MapPost(
    "/api/monitorings/{id:int}/play",
    async (int id, ScreeningRepository repository, CancellationToken ct) =>
    {
        if (await repository.GetMonitoringByIdAsync(id, ct) is null)
        {
            return Results.NotFound();
        }

        if (!await repository.ResumeMonitoringAsync(id, ct))
        {
            return Results.Conflict(new { error = "Monitoring is archived or not paused." });
        }

        return Results.NoContent();
    }
);

app.MapPost(
    "/api/monitorings/trigger-all",
    async (MonitoringCheckScheduler scheduler, ScreeningRepository repository, CancellationToken ct) =>
    {
        var monitorings = await repository.GetNonArchivedMonitoringsAsync(ct);
        if (monitorings.Count == 0)
        {
            return Results.Conflict(new { error = "No monitorings available to scan." });
        }

        var scheduled = await scheduler.TriggerAllAsync(ct);
        return Results.Accepted(value: new { scheduled });
    }
);

app.MapPost(
    "/api/monitorings/{id:int}/trigger",
    async (int id, MonitoringCheckScheduler scheduler, ScreeningRepository repository, CancellationToken ct) =>
    {
        var monitoring = await repository.GetMonitoringByIdAsync(id, ct);
        if (monitoring is null)
        {
            return Results.NotFound();
        }

        if (monitoring.ArchivedAt is not null)
        {
            return Results.Conflict(new { error = "Archived monitorings cannot be scanned." });
        }

        await scheduler.TriggerAsync(id, ct);
        return Results.Accepted($"/api/monitorings/{id}", new { id });
    }
);

app.MapGet(
    "/api/detections",
    async (ScreeningRepository repository, CancellationToken ct, int? monitorId = null, int limit = 50) =>
    {
        var detections = await repository.GetDetectionsAsync(monitorId, Math.Clamp(limit, 1, 200), ct);
        return Results.Json(
            detections.Select(d => new
            {
                d.Id,
                d.MonitorId,
                d.DetectedAt,
                Preview = d.Html.Length > 200 ? d.Html[..200] + "" : d.Html,
            })
        );
    }
);

app.MapGet(
    "/api/detections/{id:int}",
    async (int id, ScreeningRepository repository, CancellationToken ct) =>
    {
        var detection = await repository.GetDetectionByIdAsync(id, ct);
        return detection is null ? Results.NotFound() : Results.Json(detection);
    }
);

app.Run();