Commit: d35c465
Parent: a28f89c

Add multi-monitoring management with scan triggers and editing.

Mårten Åsberg committed on 2026-06-14 at 19:50
Support multiple URLs with separate DB entries, pause/play/archive controls, manual scans via separate Quartz jobs, and editing monitor settings from the dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
CheckMonitoringJob.cs +76 -31
diff --git a/BfiScreeningCheckerJob.cs b/CheckMonitoringJob.cs
similarity index 57%
rename from BfiScreeningCheckerJob.cs
rename to CheckMonitoringJob.cs
index 883769b..ee20a37 100644
@@ -6,65 +6,101 @@ using Microsoft.Playwright;
using Quartz;
[DisallowConcurrentExecution]
internal sealed class BfiScreeningCheckerJob(
internal sealed class CheckMonitoringJob(
PlaywrightBrowserService browserService,
ScreeningRepository repository,
IOptionsMonitor<MonitorOptions> options,
ILogger<BfiScreeningCheckerJob> logger,
ILogger<CheckMonitoringJob> logger,
TimeProvider timeProvider
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
using var activity = Tracing.StartCheckerJob();
var monitorId = context.MergedJobDataMap.GetInt("monitorId");
var force = context.MergedJobDataMap.GetBoolean("force");
var latestHtml = await repository.LatestHtml(context.CancellationToken);
logger.LogLatestHtml(latestHtml);
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 opts = options.CurrentValue;
var page = await browserService.NewPageAsync();
try
{
logger.LogFetchingSite(opts.Url);
logger.LogFetchingSite(monitor.Id, monitor.Url);
await page.GotoAsync(
opts.Url,
monitor.Url,
new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = TimeSpan.FromSeconds(45).Milliseconds }
);
await Task.Delay(TimeSpan.FromSeconds(2));
var resultsElement = await page.WaitForSelectorAsync(opts.Selector);
var resultsElement = await page.WaitForSelectorAsync(monitor.Selector);
if (resultsElement is null)
{
logger.LogNoResultsElementFound(opts.Selector);
logger.LogNoResultsElementFound(monitor.Id, monitor.Selector);
activity?.SetTag("EndReason", "NoResultsElementFound");
activity?.SetStatus(ActivityStatusCode.Ok);
return;
}
var currentHtml = await resultsElement.InnerHTMLAsync();
logger.LogCurrentHtml(currentHtml);
logger.LogCurrentHtml(monitor.Id, currentHtml);
if (currentHtml == latestHtml)
{
logger.LogHtmlNotUpdated();
logger.LogHtmlNotUpdated(monitor.Id);
activity?.SetTag("EndReason", "HtmlNotUpdated");
activity?.SetStatus(ActivityStatusCode.Ok);
return;
}
logger.LogHtmlUpdated();
await repository.InsertNewDetection(currentHtml, timeProvider.GetUtcNow());
logger.LogHtmlUpdated(monitor.Id);
await repository.InsertNewDetection(
monitor.Id,
currentHtml,
timeProvider.GetUtcNow(),
context.CancellationToken
);
var message = $"Site updated: {opts.Url}";
var label = string.IsNullOrWhiteSpace(monitor.Name) ? monitor.Url : monitor.Name;
var message = $"Site updated: {label}";
var opts = options.CurrentValue;
foreach (var phoneNumber in opts.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", "RanToCompletion");
activity?.SetTag("EndReason", "ChangeDetected");
activity?.SetStatus(ActivityStatusCode.Ok);
}
catch (Exception ex)
@@ -81,25 +117,34 @@ internal sealed class BfiScreeningCheckerJob(
}
}
internal static partial class BfiScreeningCheckerJobLoggerExtensions
internal static partial class CheckMonitoringJobLoggerExtensions
{
[LoggerMessage(LogLevel.Information, "Latest HTML detected was {LatestHtml}")]
public static partial void LogLatestHtml(this ILogger logger, string? latestHtml);
[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, "No {ElementSelector} element found")]
public static partial void LogNoResultsElementFound(this ILogger logger, string elementSelector);
[LoggerMessage(LogLevel.Warning, "Monitor {MonitorId}: no {ElementSelector} element found")]
public static partial void LogNoResultsElementFound(this ILogger logger, int monitorId, string elementSelector);
[LoggerMessage(LogLevel.Information, "Fetching site {Url}")]
public static partial void LogFetchingSite(this ILogger logger, string url);
[LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: fetching {Url}")]
public static partial void LogFetchingSite(this ILogger logger, int monitorId, string url);
[LoggerMessage(LogLevel.Information, "Current HTML detected is {CurrentHtml}")]
public static partial void LogCurrentHtml(this ILogger logger, string? currentHtml);
[LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: current HTML is {CurrentHtml}")]
public static partial void LogCurrentHtml(this ILogger logger, int monitorId, string? currentHtml);
[LoggerMessage(LogLevel.Information, "HTML has not been updated since previous visit")]
public static partial void LogHtmlNotUpdated(this ILogger logger);
[LoggerMessage(LogLevel.Information, "Monitor {MonitorId}: HTML has not changed")]
public static partial void LogHtmlNotUpdated(this ILogger logger, int monitorId);
[LoggerMessage(LogLevel.Information, "HTML has been updated since previous visit")]
public static partial void LogHtmlUpdated(this ILogger logger);
[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);
MonitorOptions.cs +5 -2
diff --git a/MonitorOptions.cs b/MonitorOptions.cs
index 12e776f..0408165 100644
@@ -1,9 +1,12 @@
internal sealed class MonitorOptions
{
public int IntervalSeconds { get; set; } = 300;
public string Url { get; set; } = "";
public string Selector { get; set; } = ".detailed-search-results";
public string[] PhoneNumbers { get; set; } = [];
public string SmsSenderAddress { get; set; } = "http://localhost:50051";
public bool Headless { get; set; } = true;
public string DefaultSelector { get; set; } = ".detailed-search-results";
}
internal sealed record CreateMonitoringRequest(string Url, string Selector, string? Name);
internal sealed record UpdateMonitoringRequest(string Url, string Selector, string? Name);
MonitoringCheckScheduler.cs +66 -0
diff --git a/MonitoringCheckScheduler.cs b/MonitoringCheckScheduler.cs
new file mode 100644
index 0000000..4fb71bb
@@ -0,0 +1,66 @@
using Quartz;
internal static class MonitoringCheckJobs
{
public const string JobGroup = "monitoring-checks";
public const string TriggerGroup = "monitoring-check-triggers";
public static JobKey JobKeyFor(int monitorId) => new($"check-{monitorId}", JobGroup);
}
internal sealed class MonitoringCheckScheduler(ISchedulerFactory schedulerFactory, ScreeningRepository repository)
{
public Task TriggerAsync(int monitorId, CancellationToken cancellationToken = default) =>
ScheduleCheckAsync(monitorId, force: true, cancellationToken);
public async Task<int> TriggerAllAsync(CancellationToken cancellationToken = default)
{
var monitors = await repository.GetNonArchivedMonitoringsAsync(cancellationToken);
foreach (var monitor in monitors)
{
await ScheduleCheckAsync(monitor.Id, force: true, cancellationToken);
}
return monitors.Count;
}
public async Task ScheduleRunningChecksAsync(CancellationToken cancellationToken = default)
{
var monitors = await repository.GetActiveMonitoringsAsync(cancellationToken);
foreach (var monitor in monitors)
{
await ScheduleCheckAsync(monitor.Id, force: false, cancellationToken);
}
}
private async Task ScheduleCheckAsync(int monitorId, bool force, CancellationToken cancellationToken)
{
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
var jobKey = MonitoringCheckJobs.JobKeyFor(monitorId);
if (!await scheduler.CheckExists(jobKey, cancellationToken))
{
await scheduler.AddJob(
JobBuilder
.Create<CheckMonitoringJob>()
.WithIdentity(jobKey)
.UsingJobData("monitorId", monitorId)
.StoreDurably()
.RequestRecovery()
.Build(),
replace: true,
cancellationToken
);
}
var trigger = TriggerBuilder
.Create()
.ForJob(jobKey)
.WithIdentity(new TriggerKey($"{monitorId}-{Guid.NewGuid():N}", MonitoringCheckJobs.TriggerGroup))
.UsingJobData("force", force)
.StartNow()
.Build();
await scheduler.ScheduleJob(trigger, cancellationToken);
}
}
Program.cs +185 -10
diff --git a/Program.cs b/Program.cs
index 31b212f..47470dd 100644
@@ -21,6 +21,7 @@ builder.Services.AddSingleton<PlaywrightBrowserService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PlaywrightBrowserService>());
builder.Services.AddSingleton<ScreeningRepository>();
builder.Services.AddSingleton<MonitoringCheckScheduler>();
builder.Services.AddSingleton(TimeProvider.System);
@@ -28,10 +29,10 @@ var intervalSeconds = builder.Configuration.GetValue("Monitor:IntervalSeconds",
builder.Services.AddQuartz(q =>
{
var checkerKey = JobKey.Create(nameof(BfiScreeningCheckerJob));
q.AddJob<BfiScreeningCheckerJob>(checkerKey);
var scheduleKey = JobKey.Create(nameof(ScheduleMonitoringChecksJob));
q.AddJob<ScheduleMonitoringChecksJob>(scheduleKey);
q.AddTrigger(t =>
t.ForJob(checkerKey)
t.ForJob(scheduleKey)
.StartNow()
.WithSimpleSchedule(s => s.WithIntervalInSeconds(intervalSeconds).RepeatForever())
);
@@ -51,30 +52,204 @@ app.MapGet(
async (IOptions<MonitorOptions> options, ScreeningRepository repository, CancellationToken ct) =>
{
var opts = options.Value;
var latest = await repository.GetDetectionsAsync(1, ct);
return Results.Json(
new
{
opts.Url,
opts.Selector,
opts.IntervalSeconds,
opts.PhoneNumbers,
TotalDetections = await repository.CountDetectionsAsync(ct),
LatestDetectionAt = latest.FirstOrDefault()?.DetectedAt,
opts.DefaultSelector,
RunningMonitorings = await repository.CountRunningMonitoringsAsync(ct),
ActiveMonitorings = await repository.CountActiveMonitoringsAsync(ct),
}
);
}
);
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 monitoring = await repository.CreateMonitoringAsync(
request.Url.Trim(),
selector,
request.Name?.Trim() ?? "",
timeProvider.GetUtcNow(),
ct
);
return Results.Created($"/api/monitorings/{monitoring.Id}", monitoring);
}
);
app.MapPut(
"/api/monitorings/{id:int}",
async (
int id,
UpdateMonitoringRequest request,
ScreeningRepository repository,
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." });
}
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() ?? "",
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 limit = 50) =>
async (ScreeningRepository repository, CancellationToken ct, int? monitorId = null, int limit = 50) =>
{
var detections = await repository.GetDetectionsAsync(Math.Clamp(limit, 1, 200), ct);
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,
})
ScheduleMonitoringChecksJob.cs +36 -0
diff --git a/ScheduleMonitoringChecksJob.cs b/ScheduleMonitoringChecksJob.cs
new file mode 100644
index 0000000..6b3bf6a
@@ -0,0 +1,36 @@
using System.Diagnostics;
using BfiMonitor;
using Microsoft.Extensions.Logging;
using Quartz;
internal sealed class ScheduleMonitoringChecksJob(
MonitoringCheckScheduler scheduler,
ScreeningRepository repository,
ILogger<ScheduleMonitoringChecksJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
using var activity = Tracing.StartCheckerJob();
var monitors = await repository.GetActiveMonitoringsAsync(context.CancellationToken);
if (monitors.Count == 0)
{
logger.LogNoActiveMonitorings();
activity?.SetTag("EndReason", "NoActiveMonitorings");
activity?.SetStatus(ActivityStatusCode.Ok);
return;
}
await scheduler.ScheduleRunningChecksAsync(context.CancellationToken);
activity?.SetTag("ScheduledChecks", monitors.Count);
activity?.SetTag("EndReason", "RanToCompletion");
activity?.SetStatus(ActivityStatusCode.Ok);
}
}
internal static partial class ScheduleMonitoringChecksJobLoggerExtensions
{
[LoggerMessage(LogLevel.Information, "No active monitorings configured")]
public static partial void LogNoActiveMonitorings(this ILogger logger);
}
ScreeningRepository.cs +439 -23
diff --git a/ScreeningRepository.cs b/ScreeningRepository.cs
index 906f38c..710713b 100644
@@ -2,74 +2,457 @@ using BfiMonitor;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
internal sealed record Detection(int Id, string Html, DateTimeOffset DetectedAt);
internal sealed record Monitoring(
int Id,
string Name,
string Url,
string Selector,
DateTimeOffset CreatedAt,
DateTimeOffset? PausedAt,
DateTimeOffset? ArchivedAt
);
internal sealed record MonitoringSummary(
int Id,
string Name,
string Url,
string Selector,
DateTimeOffset CreatedAt,
DateTimeOffset? PausedAt,
DateTimeOffset? ArchivedAt,
int TotalDetections,
DateTimeOffset? LatestDetectionAt
);
internal sealed record Detection(int Id, int MonitorId, string Html, DateTimeOffset DetectedAt);
internal sealed class ScreeningRepository
{
private readonly string connectionString;
private readonly IConfiguration configuration;
public ScreeningRepository(IConfiguration configuration)
{
this.configuration = configuration;
connectionString = configuration.GetConnectionString("Screenings") ?? "DataSource=screenings.db";
Initialize();
}
private void Initialize()
{
const string sql =
"CREATE TABLE IF NOT EXISTS DetectedHtml (Id INTEGER PRIMARY KEY AUTOINCREMENT, Html TEXT NOT NULL, DetectedAt TEXT NOT NULL)";
using var activity = Tracing.StartInitializeScreeningsDatabase(sql);
using var connection = new SqliteConnection(connectionString);
connection.Open();
Execute(
connection,
"""
CREATE TABLE IF NOT EXISTS Monitorings (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Name TEXT NOT NULL DEFAULT '',
Url TEXT NOT NULL,
Selector TEXT NOT NULL,
CreatedAt TEXT NOT NULL,
ArchivedAt TEXT
)
"""
);
Execute(
connection,
"""
CREATE TABLE IF NOT EXISTS DetectedHtml (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Html TEXT NOT NULL,
DetectedAt TEXT NOT NULL
)
"""
);
EnsureMonitorIdColumn(connection);
EnsurePausedAtColumn(connection);
SeedDefaultMonitoring(connection);
}
private static void Execute(SqliteConnection connection, string sql)
{
using var activity = Tracing.StartInitializeScreeningsDatabase(sql);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
public async Task<string?> LatestHtml(CancellationToken cancellationToken)
private static bool ColumnExists(SqliteConnection connection, string table, string column)
{
using var command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info({table})";
using var reader = command.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private void EnsureMonitorIdColumn(SqliteConnection connection)
{
if (ColumnExists(connection, "DetectedHtml", "MonitorId"))
{
return;
}
Execute(connection, "ALTER TABLE DetectedHtml ADD COLUMN MonitorId INTEGER");
}
private static void EnsurePausedAtColumn(SqliteConnection connection)
{
if (ColumnExists(connection, "Monitorings", "PausedAt"))
{
return;
}
Execute(connection, "ALTER TABLE Monitorings ADD COLUMN PausedAt TEXT");
}
private void SeedDefaultMonitoring(SqliteConnection connection)
{
const string sql = "SELECT Html FROM DetectedHtml ORDER BY DetectedAt DESC LIMIT 1";
using var countCommand = connection.CreateCommand();
countCommand.CommandText = "SELECT COUNT(*) FROM Monitorings";
if (Convert.ToInt32(countCommand.ExecuteScalar()) > 0)
{
AssignOrphanDetections(connection);
return;
}
var url = configuration["Monitor:Url"];
if (string.IsNullOrWhiteSpace(url))
{
return;
}
var selector = configuration["Monitor:Selector"] ?? ".detailed-search-results";
var name = configuration["Monitor:Name"] ?? "";
var createdAt = DateTimeOffset.UtcNow.ToString("O");
using var insertCommand = connection.CreateCommand();
insertCommand.CommandText =
"INSERT INTO Monitorings (Name, Url, Selector, CreatedAt) VALUES ($name, $url, $selector, $createdAt)";
insertCommand.Parameters.AddWithValue("$name", name);
insertCommand.Parameters.AddWithValue("$url", url);
insertCommand.Parameters.AddWithValue("$selector", selector);
insertCommand.Parameters.AddWithValue("$createdAt", createdAt);
insertCommand.ExecuteNonQuery();
AssignOrphanDetections(connection);
}
private static void AssignOrphanDetections(SqliteConnection connection)
{
using var monitorCommand = connection.CreateCommand();
monitorCommand.CommandText = "SELECT Id FROM Monitorings ORDER BY Id LIMIT 1";
var firstMonitorId = monitorCommand.ExecuteScalar();
if (firstMonitorId is null)
{
return;
}
using var updateCommand = connection.CreateCommand();
updateCommand.CommandText = "UPDATE DetectedHtml SET MonitorId = $monitorId WHERE MonitorId IS NULL";
updateCommand.Parameters.AddWithValue("$monitorId", firstMonitorId);
updateCommand.ExecuteNonQuery();
}
public async Task<IReadOnlyList<MonitoringSummary>> GetMonitoringsAsync(
bool includeArchived,
CancellationToken cancellationToken = default
)
{
var sql = """
SELECT
m.Id,
m.Name,
m.Url,
m.Selector,
m.CreatedAt,
m.PausedAt,
m.ArchivedAt,
(SELECT COUNT(*) FROM DetectedHtml d WHERE d.MonitorId = m.Id) AS TotalDetections,
(SELECT MAX(d.DetectedAt) FROM DetectedHtml d WHERE d.MonitorId = m.Id) AS LatestDetectionAt
FROM Monitorings m
WHERE ($includeArchived = 1 OR m.ArchivedAt IS NULL)
ORDER BY m.ArchivedAt IS NOT NULL, m.PausedAt IS NOT NULL, m.CreatedAt DESC
""";
using var activity = Tracing.StartGetMonitorings(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$includeArchived", includeArchived ? 1 : 0);
var results = new List<MonitoringSummary>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(ReadMonitoringSummary(reader));
}
return results;
}
public async Task<IReadOnlyList<Monitoring>> GetActiveMonitoringsAsync(
CancellationToken cancellationToken = default
)
{
const string sql = """
SELECT Id, Name, Url, Selector, CreatedAt, PausedAt, ArchivedAt
FROM Monitorings
WHERE ArchivedAt IS NULL AND PausedAt IS NULL
ORDER BY CreatedAt
""";
using var activity = Tracing.StartGetMonitorings(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
var results = new List<Monitoring>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(ReadMonitoring(reader));
}
return results;
}
public async Task<IReadOnlyList<Monitoring>> GetNonArchivedMonitoringsAsync(
CancellationToken cancellationToken = default
)
{
const string sql = """
SELECT Id, Name, Url, Selector, CreatedAt, PausedAt, ArchivedAt
FROM Monitorings
WHERE ArchivedAt IS NULL
ORDER BY CreatedAt
""";
using var activity = Tracing.StartGetMonitorings(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
var results = new List<Monitoring>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(ReadMonitoring(reader));
}
return results;
}
public async Task<Monitoring?> GetMonitoringByIdAsync(int id, CancellationToken cancellationToken = default)
{
const string sql =
"SELECT Id, Name, Url, Selector, CreatedAt, PausedAt, ArchivedAt FROM Monitorings WHERE Id = $id";
using var activity = Tracing.StartGetMonitoringById(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken) ? ReadMonitoring(reader) : null;
}
public async Task<Monitoring> CreateMonitoringAsync(
string url,
string selector,
string name,
DateTimeOffset createdAt,
CancellationToken cancellationToken = default
)
{
const string sql = """
INSERT INTO Monitorings (Name, Url, Selector, CreatedAt)
VALUES ($name, $url, $selector, $createdAt)
RETURNING Id, Name, Url, Selector, CreatedAt, PausedAt, ArchivedAt
""";
using var activity = Tracing.StartCreateMonitoring(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$name", name);
command.Parameters.AddWithValue("$url", url);
command.Parameters.AddWithValue("$selector", selector);
command.Parameters.AddWithValue("$createdAt", createdAt.ToString("O"));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
await reader.ReadAsync(cancellationToken);
return ReadMonitoring(reader);
}
public async Task<Monitoring?> UpdateMonitoringAsync(
int id,
string url,
string selector,
string name,
CancellationToken cancellationToken = default
)
{
const string sql = """
UPDATE Monitorings
SET Name = $name, Url = $url, Selector = $selector
WHERE Id = $id AND ArchivedAt IS NULL
RETURNING Id, Name, Url, Selector, CreatedAt, PausedAt, ArchivedAt
""";
using var activity = Tracing.StartUpdateMonitoring(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$id", id);
command.Parameters.AddWithValue("$name", name);
command.Parameters.AddWithValue("$url", url);
command.Parameters.AddWithValue("$selector", selector);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken) ? ReadMonitoring(reader) : null;
}
public async Task<bool> ArchiveMonitoringAsync(
int id,
DateTimeOffset archivedAt,
CancellationToken cancellationToken = default
)
{
const string sql = "UPDATE Monitorings SET ArchivedAt = $archivedAt WHERE Id = $id AND ArchivedAt IS NULL";
using var activity = Tracing.StartArchiveMonitoring(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$id", id);
command.Parameters.AddWithValue("$archivedAt", archivedAt.ToString("O"));
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
}
public async Task<bool> PauseMonitoringAsync(
int id,
DateTimeOffset pausedAt,
CancellationToken cancellationToken = default
)
{
const string sql = """
UPDATE Monitorings
SET PausedAt = $pausedAt
WHERE Id = $id AND ArchivedAt IS NULL AND PausedAt IS NULL
""";
using var activity = Tracing.StartPauseMonitoring(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$id", id);
command.Parameters.AddWithValue("$pausedAt", pausedAt.ToString("O"));
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
}
public async Task<bool> ResumeMonitoringAsync(int id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE Monitorings
SET PausedAt = NULL
WHERE Id = $id AND ArchivedAt IS NULL AND PausedAt IS NOT NULL
""";
using var activity = Tracing.StartResumeMonitoring(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$id", id);
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
}
public async Task<string?> LatestHtml(int monitorId, CancellationToken cancellationToken)
{
const string sql =
"SELECT Html FROM DetectedHtml WHERE MonitorId = $monitorId ORDER BY DetectedAt DESC LIMIT 1";
using var activity = Tracing.StartLatestHtml(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$monitorId", monitorId);
return await command.ExecuteScalarAsync(cancellationToken) as string;
}
public async Task InsertNewDetection(string html, DateTimeOffset detectedAt)
public async Task InsertNewDetection(
int monitorId,
string html,
DateTimeOffset detectedAt,
CancellationToken cancellationToken = default
)
{
const string sql = "INSERT INTO DetectedHtml (Html, DetectedAt) VALUES ($html, $at)";
const string sql = "INSERT INTO DetectedHtml (MonitorId, Html, DetectedAt) VALUES ($monitorId, $html, $at)";
using var activity = Tracing.StartInsertNewDetection(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$monitorId", monitorId);
command.Parameters.AddWithValue("$html", html);
command.Parameters.AddWithValue("$at", detectedAt.ToString("O"));
await command.ExecuteNonQueryAsync();
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task<IReadOnlyList<Detection>> GetDetectionsAsync(
int? monitorId,
int limit,
CancellationToken cancellationToken = default
)
{
const string sql = "SELECT Id, Html, DetectedAt FROM DetectedHtml ORDER BY DetectedAt DESC LIMIT $limit";
var sql = monitorId is null
? """
SELECT Id, MonitorId, Html, DetectedAt
FROM DetectedHtml
ORDER BY DetectedAt DESC
LIMIT $limit
"""
: """
SELECT Id, MonitorId, Html, DetectedAt
FROM DetectedHtml
WHERE MonitorId = $monitorId
ORDER BY DetectedAt DESC
LIMIT $limit
""";
using var activity = Tracing.StartGetDetections(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$limit", limit);
if (monitorId is not null)
{
command.Parameters.AddWithValue("$monitorId", monitorId.Value);
}
var results = new List<Detection>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(
new Detection(reader.GetInt32(0), reader.GetString(1), DateTimeOffset.Parse(reader.GetString(2)))
);
results.Add(ReadDetection(reader));
}
return results;
@@ -77,7 +460,7 @@ internal sealed class ScreeningRepository
public async Task<Detection?> GetDetectionByIdAsync(int id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT Id, Html, DetectedAt FROM DetectedHtml WHERE Id = $id";
const string sql = "SELECT Id, MonitorId, Html, DetectedAt FROM DetectedHtml WHERE Id = $id";
using var activity = Tracing.StartGetDetectionById(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
@@ -86,22 +469,55 @@ internal sealed class ScreeningRepository
command.Parameters.AddWithValue("$id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return await reader.ReadAsync(cancellationToken) ? ReadDetection(reader) : null;
}
return new Detection(reader.GetInt32(0), reader.GetString(1), DateTimeOffset.Parse(reader.GetString(2)));
public async Task<int> CountRunningMonitoringsAsync(CancellationToken cancellationToken = default)
{
const string sql = "SELECT COUNT(*) FROM Monitorings WHERE ArchivedAt IS NULL AND PausedAt IS NULL";
using var activity = Tracing.StartCountMonitorings(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken));
}
public async Task<int> CountDetectionsAsync(CancellationToken cancellationToken = default)
public async Task<int> CountActiveMonitoringsAsync(CancellationToken cancellationToken = default)
{
const string sql = "SELECT COUNT(*) FROM DetectedHtml";
using var activity = Tracing.StartCountDetections(sql);
const string sql = "SELECT COUNT(*) FROM Monitorings WHERE ArchivedAt IS NULL";
using var activity = Tracing.StartCountMonitorings(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken));
}
private static Monitoring ReadMonitoring(SqliteDataReader reader) =>
new(
reader.GetInt32(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
DateTimeOffset.Parse(reader.GetString(4)),
reader.IsDBNull(5) ? null : DateTimeOffset.Parse(reader.GetString(5)),
reader.IsDBNull(6) ? null : DateTimeOffset.Parse(reader.GetString(6))
);
private static MonitoringSummary ReadMonitoringSummary(SqliteDataReader reader) =>
new(
reader.GetInt32(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
DateTimeOffset.Parse(reader.GetString(4)),
reader.IsDBNull(5) ? null : DateTimeOffset.Parse(reader.GetString(5)),
reader.IsDBNull(6) ? null : DateTimeOffset.Parse(reader.GetString(6)),
reader.GetInt32(7),
reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8))
);
private static Detection ReadDetection(SqliteDataReader reader) =>
new(reader.GetInt32(0), reader.GetInt32(1), reader.GetString(2), DateTimeOffset.Parse(reader.GetString(3)));
}
Tracing.cs +27 -0
diff --git a/Tracing.cs b/Tracing.cs
index 8ff00b6..36a79d1 100644
@@ -24,8 +24,35 @@ internal static class Tracing
public static Activity? StartCountDetections(string sql) =>
ActivitySource.StartActivity("CountDetections", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartGetMonitorings(string sql) =>
ActivitySource.StartActivity("GetMonitorings", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartGetMonitoringById(string sql) =>
ActivitySource.StartActivity("GetMonitoringById", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartCreateMonitoring(string sql) =>
ActivitySource.StartActivity("CreateMonitoring", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartUpdateMonitoring(string sql) =>
ActivitySource.StartActivity("UpdateMonitoring", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartArchiveMonitoring(string sql) =>
ActivitySource.StartActivity("ArchiveMonitoring", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartPauseMonitoring(string sql) =>
ActivitySource.StartActivity("PauseMonitoring", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartResumeMonitoring(string sql) =>
ActivitySource.StartActivity("ResumeMonitoring", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartCountMonitorings(string sql) =>
ActivitySource.StartActivity("CountMonitorings", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartSendSms(string phoneNumber) =>
ActivitySource.StartActivity("SendSms", ActivityKind.Internal)?.SetTag("PhoneNumber", phoneNumber);
public static Activity? StartCheckerJob() => ActivitySource.StartActivity("CheckerJob", ActivityKind.Internal);
public static Activity? StartCheckerJobForMonitor(int monitorId) =>
ActivitySource.StartActivity("CheckerJobForMonitor", ActivityKind.Internal)?.SetTag("MonitorId", monitorId);
}
wwwroot/app.js +371 -17
diff --git a/wwwroot/app.js b/wwwroot/app.js
index 3beb06e..71bb61a 100644
@@ -1,5 +1,27 @@
const statusCards = document.getElementById("status-cards");
const liveStatus = document.getElementById("live-status");
const monitoringList = document.getElementById("monitoring-list");
const monitoringsEmpty = document.getElementById("monitorings-empty");
const showArchivedToggle = document.getElementById("show-archived");
const addMonitoringBtn = document.getElementById("add-monitoring-btn");
const triggerAllBtn = document.getElementById("trigger-all-btn");
const addDialog = document.getElementById("add-dialog");
const addForm = document.getElementById("add-form");
const addNameInput = document.getElementById("add-name");
const addUrlInput = document.getElementById("add-url");
const addSelectorInput = document.getElementById("add-selector");
const addError = document.getElementById("add-error");
const closeAddDialogBtn = document.getElementById("close-add-dialog");
const cancelAddDialogBtn = document.getElementById("cancel-add-dialog");
const editDialog = document.getElementById("edit-dialog");
const editForm = document.getElementById("edit-form");
const editNameInput = document.getElementById("edit-name");
const editUrlInput = document.getElementById("edit-url");
const editSelectorInput = document.getElementById("edit-selector");
const editError = document.getElementById("edit-error");
const closeEditDialogBtn = document.getElementById("close-edit-dialog");
const cancelEditDialogBtn = document.getElementById("cancel-edit-dialog");
const detectionsTitle = document.getElementById("detections-title");
const detectionList = document.getElementById("detection-list");
const detectionsEmpty = document.getElementById("detections-empty");
const refreshBtn = document.getElementById("refresh-btn");
@@ -10,6 +32,11 @@ const drawerMeta = document.getElementById("drawer-meta");
const drawerPreview = document.getElementById("drawer-preview");
const closeDrawerBtn = document.getElementById("close-drawer");
let selectedMonitorId = null;
let editingMonitorId = null;
let monitorings = [];
let defaultSelector = ".detailed-search-results";
function formatDate(iso) {
if (!iso) return "—";
return new Intl.DateTimeFormat(undefined, {
@@ -29,34 +56,182 @@ function card(label, value, { link = false } = {}) {
el.className = "card";
el.innerHTML = `
<p class="card__label">${label}</p>
<p class="card__value">${link ? `<a class="card__value--link" href="${value}" target="_blank" rel="noopener">${value}</a>` : value}</p>
<p class="card__value">${link ? `<a class="card__value--link" href="${escapeHtml(value)}" target="_blank" rel="noopener">${escapeHtml(value)}</a>` : escapeHtml(value)}</p>
`;
return el;
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function monitoringLabel(monitoring) {
return monitoring.name || monitoring.url;
}
async function loadStatus() {
const res = await fetch("/api/status");
if (!res.ok) throw new Error("Failed to load status");
return res.json();
}
async function loadMonitorings() {
const includeArchived = showArchivedToggle.checked ? "true" : "false";
const res = await fetch(`/api/monitorings?includeArchived=${includeArchived}`);
if (!res.ok) throw new Error("Failed to load monitorings");
return res.json();
}
async function loadDetections() {
const res = await fetch("/api/detections?limit=100");
const params = new URLSearchParams({ limit: "100" });
if (selectedMonitorId !== null) {
params.set("monitorId", String(selectedMonitorId));
}
const res = await fetch(`/api/detections?${params}`);
if (!res.ok) throw new Error("Failed to load detections");
return res.json();
}
function renderStatus(status) {
defaultSelector = status.defaultSelector || defaultSelector;
statusCards.replaceChildren(
card("Monitored URL", status.url, { link: true }),
card("Check interval", formatInterval(status.intervalSeconds)),
card("CSS selector", status.selector),
card("Total detections", String(status.totalDetections)),
card("Latest change", formatDate(status.latestDetectionAt)),
card("Running monitorings", String(status.runningMonitorings)),
card("Paused monitorings", String(status.activeMonitorings - status.runningMonitorings)),
card("SMS recipients", status.phoneNumbers.join(", ") || "—")
);
liveStatus.querySelector("span:last-child").textContent = `Checking every ${formatInterval(status.intervalSeconds)}`;
liveStatus.querySelector("span:last-child").textContent =
`${status.runningMonitorings} running · every ${formatInterval(status.intervalSeconds)}`;
}
function renderMonitorings(items) {
monitorings = items;
monitoringList.replaceChildren();
const activeItems = items.filter((item) => !item.archivedAt);
if (activeItems.length === 0 && !showArchivedToggle.checked) {
monitoringsEmpty.classList.remove("hidden");
} else {
monitoringsEmpty.classList.add("hidden");
}
if (selectedMonitorId !== null && !items.some((item) => item.id === selectedMonitorId)) {
selectedMonitorId = null;
}
for (const item of items) {
const li = document.createElement("li");
li.className = "monitoring-item";
if (item.id === selectedMonitorId) {
li.classList.add("monitoring-item--selected");
}
if (item.archivedAt) {
li.classList.add("monitoring-item--archived");
} else if (item.pausedAt) {
li.classList.add("monitoring-item--paused");
}
const title = monitoringLabel(item);
const badges = [
item.archivedAt ? '<span class="badge badge--archived">Archived</span>' : "",
item.pausedAt ? '<span class="badge badge--paused">Paused</span>' : "",
]
.filter(Boolean)
.join("");
li.innerHTML = `
<button type="button" class="monitoring-item__main">
<div class="monitoring-item__title-row">
<p class="monitoring-item__title">${escapeHtml(title)}</p>
${badges}
</div>
<p class="monitoring-item__url">${escapeHtml(item.url)}</p>
<p class="monitoring-item__meta">
<span>${escapeHtml(item.selector)}</span>
<span>${item.totalDetections} detection${item.totalDetections === 1 ? "" : "s"}</span>
<span>Latest ${formatDate(item.latestDetectionAt)}</span>
</p>
</button>
${
item.archivedAt
? ""
: `<div class="monitoring-item__actions">
<button type="button" class="btn monitoring-item__scan" data-id="${item.id}">Scan</button>
<button type="button" class="btn monitoring-item__edit" data-id="${item.id}">Edit</button>
${
item.pausedAt
? `<button type="button" class="btn monitoring-item__play" data-id="${item.id}">Play</button>`
: `<button type="button" class="btn monitoring-item__pause" data-id="${item.id}">Pause</button>`
}
<button type="button" class="btn btn--danger monitoring-item__archive" data-id="${item.id}">Archive</button>
</div>`
}
`;
li.querySelector(".monitoring-item__main").addEventListener("click", () => {
selectedMonitorId = selectedMonitorId === item.id ? null : item.id;
renderMonitorings(monitorings);
renderDetectionsTitle();
loadDetections().then(renderDetections).catch(handleError);
});
const archiveBtn = li.querySelector(".monitoring-item__archive");
if (archiveBtn) {
archiveBtn.addEventListener("click", async (event) => {
event.stopPropagation();
await archiveMonitoring(item.id);
});
}
const pauseBtn = li.querySelector(".monitoring-item__pause");
if (pauseBtn) {
pauseBtn.addEventListener("click", async (event) => {
event.stopPropagation();
await pauseMonitoring(item.id);
});
}
const playBtn = li.querySelector(".monitoring-item__play");
if (playBtn) {
playBtn.addEventListener("click", async (event) => {
event.stopPropagation();
await playMonitoring(item.id);
});
}
const scanBtn = li.querySelector(".monitoring-item__scan");
if (scanBtn) {
scanBtn.addEventListener("click", async (event) => {
event.stopPropagation();
await triggerMonitoring(item.id, scanBtn);
});
}
const editBtn = li.querySelector(".monitoring-item__edit");
if (editBtn) {
editBtn.addEventListener("click", (event) => {
event.stopPropagation();
openEditDialog(item);
});
}
monitoringList.appendChild(li);
}
}
function renderDetectionsTitle() {
if (selectedMonitorId === null) {
detectionsTitle.textContent = "Detections";
return;
}
const monitoring = monitorings.find((item) => item.id === selectedMonitorId);
detectionsTitle.textContent = monitoring
? `Detections · ${monitoringLabel(monitoring)}`
: "Detections";
}
function renderDetections(detections) {
@@ -70,11 +245,17 @@ function renderDetections(detections) {
detectionsEmpty.classList.add("hidden");
for (const item of detections) {
const monitoring = monitorings.find((entry) => entry.id === item.monitorId);
const li = document.createElement("li");
li.className = "detection-item";
li.innerHTML = `
<div>
<p class="detection-item__time">${formatDate(item.detectedAt)}</p>
${
selectedMonitorId === null && monitoring
? `<p class="detection-item__monitor">${escapeHtml(monitoringLabel(monitoring))}</p>`
: ""
}
<p class="detection-item__preview">${escapeHtml(item.preview)}</p>
</div>
<span class="detection-item__action">View →</span>
@@ -84,12 +265,6 @@ function renderDetections(detections) {
}
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
async function openDetection(id) {
const res = await fetch(`/api/detections/${id}`);
if (!res.ok) return;
@@ -116,21 +291,200 @@ function closeDrawer() {
drawerPreview.replaceChildren();
}
function openAddDialog() {
addError.classList.add("hidden");
addError.textContent = "";
addNameInput.value = "";
addUrlInput.value = "";
addSelectorInput.value = defaultSelector;
addDialog.showModal();
}
function closeAddDialog() {
addDialog.close();
}
function openEditDialog(monitoring) {
editingMonitorId = monitoring.id;
editError.classList.add("hidden");
editError.textContent = "";
editNameInput.value = monitoring.name || "";
editUrlInput.value = monitoring.url;
editSelectorInput.value = monitoring.selector;
editDialog.showModal();
}
function closeEditDialog() {
editingMonitorId = null;
editDialog.close();
}
async function updateMonitoring(event) {
event.preventDefault();
if (editingMonitorId === null) {
return;
}
editError.classList.add("hidden");
editError.textContent = "";
const payload = {
name: editNameInput.value.trim() || null,
url: editUrlInput.value.trim(),
selector: editSelectorInput.value.trim(),
};
const res = await fetch(`/api/monitorings/${editingMonitorId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
editError.textContent = body.error || "Could not save monitoring.";
editError.classList.remove("hidden");
return;
}
closeEditDialog();
await refresh();
}
async function createMonitoring(event) {
event.preventDefault();
addError.classList.add("hidden");
addError.textContent = "";
const payload = {
name: addNameInput.value.trim() || null,
url: addUrlInput.value.trim(),
selector: addSelectorInput.value.trim(),
};
const res = await fetch("/api/monitorings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
addError.textContent = body.error || "Could not add monitoring.";
addError.classList.remove("hidden");
return;
}
closeAddDialog();
await refresh();
}
async function pauseMonitoring(id) {
const res = await fetch(`/api/monitorings/${id}/pause`, { method: "POST" });
if (!res.ok) {
handleError();
return;
}
await refresh();
}
async function playMonitoring(id) {
const res = await fetch(`/api/monitorings/${id}/play`, { method: "POST" });
if (!res.ok) {
handleError();
return;
}
await refresh();
}
async function triggerMonitoring(id, button) {
button.disabled = true;
try {
const res = await fetch(`/api/monitorings/${id}/trigger`, { method: "POST" });
if (!res.ok) {
handleError();
return;
}
liveStatus.querySelector("span:last-child").textContent = "Scan scheduled…";
} finally {
button.disabled = false;
}
}
async function triggerAllMonitorings() {
triggerAllBtn.disabled = true;
try {
const res = await fetch("/api/monitorings/trigger-all", { method: "POST" });
if (!res.ok) {
handleError();
return;
}
const body = await res.json();
liveStatus.querySelector("span:last-child").textContent =
`${body.scheduled} scan${body.scheduled === 1 ? "" : "s"} scheduled`;
} finally {
triggerAllBtn.disabled = false;
}
}
async function archiveMonitoring(id) {
const monitoring = monitorings.find((item) => item.id === id);
const label = monitoring ? monitoringLabel(monitoring) : "this monitoring";
if (!window.confirm(`Archive ${label}? It will stop being checked.`)) {
return;
}
const res = await fetch(`/api/monitorings/${id}/archive`, { method: "POST" });
if (!res.ok) {
handleError();
return;
}
if (selectedMonitorId === id) {
selectedMonitorId = null;
}
await refresh();
}
function handleError() {
liveStatus.querySelector("span:last-child").textContent = "Connection error";
}
async function refresh() {
try {
const [status, detections] = await Promise.all([loadStatus(), loadDetections()]);
const [status, nextMonitorings, detections] = await Promise.all([
loadStatus(),
loadMonitorings(),
loadDetections(),
]);
renderStatus(status);
renderMonitorings(nextMonitorings);
renderDetectionsTitle();
renderDetections(detections);
} catch {
liveStatus.querySelector("span:last-child").textContent = "Connection error";
handleError();
}
}
refreshBtn.addEventListener("click", refresh);
showArchivedToggle.addEventListener("change", refresh);
addMonitoringBtn.addEventListener("click", openAddDialog);
triggerAllBtn.addEventListener("click", triggerAllMonitorings);
closeAddDialogBtn.addEventListener("click", closeAddDialog);
cancelAddDialogBtn.addEventListener("click", closeAddDialog);
addForm.addEventListener("submit", createMonitoring);
closeEditDialogBtn.addEventListener("click", closeEditDialog);
cancelEditDialogBtn.addEventListener("click", closeEditDialog);
editForm.addEventListener("submit", updateMonitoring);
closeDrawerBtn.addEventListener("click", closeDrawer);
drawerBackdrop.addEventListener("click", closeDrawer);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeDrawer();
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeDrawer();
});
refresh();
wwwroot/index.html +73 -2
diff --git a/wwwroot/index.html b/wwwroot/index.html
index dfdfad3..f92175f 100644
@@ -24,18 +24,89 @@
<section class="cards" id="status-cards" aria-label="Monitor status"></section>
<section class="panel">
<div class="panel__head">
<h2>Monitorings</h2>
<div class="panel__actions">
<label class="toggle">
<input type="checkbox" id="show-archived" />
Show archived
</label>
<button type="button" class="btn" id="trigger-all-btn">Scan all</button>
<button type="button" class="btn btn--primary" id="add-monitoring-btn">Add monitoring</button>
</div>
</div>
<div id="monitorings-empty" class="empty hidden">
<p>No active monitorings yet.</p>
<p class="empty__hint">Add a URL and CSS selector to start watching for changes.</p>
</div>
<ul class="monitoring-list" id="monitoring-list"></ul>
</section>
<main class="panel">
<div class="panel__head">
<h2>Detections</h2>
<h2 id="detections-title">Detections</h2>
<button type="button" class="btn" id="refresh-btn">Refresh</button>
</div>
<div id="detections-empty" class="empty hidden">
<p>No changes detected yet.</p>
<p class="empty__hint">The checker runs on a schedule and records HTML when the monitored page updates.</p>
<p class="empty__hint">The checker runs on a schedule and records HTML when a monitored page updates.</p>
</div>
<ul class="detection-list" id="detection-list"></ul>
</main>
<dialog class="dialog" id="add-dialog">
<form method="dialog" id="add-form">
<div class="dialog__head">
<h2>Add monitoring</h2>
<button type="button" class="btn btn--ghost" id="close-add-dialog" aria-label="Close">✕</button>
</div>
<label class="field">
<span>Name <small>(optional)</small></span>
<input type="text" id="add-name" placeholder="Odyssey IMAX" autocomplete="off" />
</label>
<label class="field">
<span>URL</span>
<input type="url" id="add-url" required placeholder="https://..." autocomplete="off" />
</label>
<label class="field">
<span>CSS selector</span>
<input type="text" id="add-selector" required placeholder=".detailed-search-results" autocomplete="off" />
</label>
<p class="form-error hidden" id="add-error"></p>
<div class="dialog__actions">
<button type="button" class="btn btn--ghost" id="cancel-add-dialog">Cancel</button>
<button type="submit" class="btn btn--primary">Add monitoring</button>
</div>
</form>
</dialog>
<dialog class="dialog" id="edit-dialog">
<form method="dialog" id="edit-form">
<div class="dialog__head">
<h2>Edit monitoring</h2>
<button type="button" class="btn btn--ghost" id="close-edit-dialog" aria-label="Close">✕</button>
</div>
<label class="field">
<span>Name <small>(optional)</small></span>
<input type="text" id="edit-name" placeholder="Odyssey IMAX" autocomplete="off" />
</label>
<label class="field">
<span>URL</span>
<input type="url" id="edit-url" required placeholder="https://..." autocomplete="off" />
</label>
<label class="field">
<span>CSS selector</span>
<input type="text" id="edit-selector" required placeholder=".detailed-search-results" autocomplete="off" />
</label>
<p class="form-error hidden" id="edit-error"></p>
<div class="dialog__actions">
<button type="button" class="btn btn--ghost" id="cancel-edit-dialog">Cancel</button>
<button type="submit" class="btn btn--primary">Save changes</button>
</div>
</form>
</dialog>
<aside class="drawer hidden" id="detail-drawer" aria-hidden="true">
<div class="drawer__head">
<h2 id="drawer-title">Detection</h2>
wwwroot/styles.css +234 -0
diff --git a/wwwroot/styles.css b/wwwroot/styles.css
index d58a5a4..2eb0fa0 100644
@@ -167,6 +167,22 @@ body {
font-weight: 600;
}
.panel__actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--muted);
cursor: pointer;
}
.btn {
border: 1px solid var(--border);
border-radius: 8px;
@@ -183,10 +199,213 @@ body {
color: var(--gold);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--ghost {
background: transparent;
}
.btn--primary {
border-color: var(--gold-dim);
background: rgba(212, 175, 55, 0.12);
color: var(--gold);
}
.btn--danger {
border-color: #7a3030;
color: #ff8a8a;
}
.btn--danger:hover {
border-color: #a04040;
color: #ffb0b0;
}
.monitoring-list {
list-style: none;
margin: 0;
padding: 0;
}
.monitoring-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem 1rem;
align-items: center;
border-bottom: 1px solid var(--border);
}
.monitoring-item:last-child {
border-bottom: none;
}
.monitoring-item--selected .monitoring-item__main {
background: rgba(212, 175, 55, 0.08);
}
.monitoring-item--archived {
opacity: 0.72;
}
.monitoring-item--paused .monitoring-item__title {
color: var(--muted);
}
.monitoring-item__actions {
display: flex;
align-items: center;
gap: 0.45rem;
margin-right: 1rem;
}
.monitoring-item__main {
display: block;
width: 100%;
padding: 1rem 1.1rem;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.monitoring-item__main:hover {
background: rgba(212, 175, 55, 0.05);
}
.monitoring-item__title-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.monitoring-item__title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.monitoring-item__url {
margin: 0.25rem 0 0;
font-size: 0.82rem;
color: var(--accent);
word-break: break-all;
}
.monitoring-item__meta {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin: 0.45rem 0 0;
font-size: 0.78rem;
color: var(--muted);
}
.monitoring-item__archive {
margin-right: 0;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.12rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge--archived {
background: rgba(152, 152, 168, 0.15);
color: var(--muted);
}
.badge--paused {
background: rgba(107, 140, 255, 0.15);
color: var(--accent);
}
.dialog {
width: min(480px, calc(100vw - 2rem));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow);
}
.dialog::backdrop {
background: rgba(0, 0, 0, 0.55);
}
.dialog__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.dialog__head h2 {
margin: 0;
font-size: 1rem;
}
.dialog form {
padding: 1rem 1.1rem 1.1rem;
}
.field {
display: grid;
gap: 0.35rem;
margin-bottom: 0.85rem;
font-size: 0.85rem;
}
.field span {
color: var(--muted);
}
.field small {
color: var(--muted);
font-size: 0.78rem;
}
.field input {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.55rem 0.7rem;
background: var(--bg);
color: var(--text);
font: inherit;
}
.field input:focus {
outline: 2px solid rgba(212, 175, 55, 0.35);
border-color: var(--gold-dim);
}
.form-error {
margin: 0 0 0.85rem;
color: #ff8a8a;
font-size: 0.85rem;
}
.dialog__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.detection-list {
list-style: none;
margin: 0;
@@ -219,6 +438,12 @@ body {
font-weight: 600;
}
.detection-item__monitor {
margin: 0 0 0.35rem;
font-size: 0.82rem;
color: var(--accent);
}
.detection-item__preview {
margin: 0;
font-size: 0.9rem;
@@ -314,6 +539,15 @@ body {
align-items: flex-start;
}
.monitoring-item {
grid-template-columns: 1fr;
}
.monitoring-item__actions {
margin: 0 1rem 1rem;
justify-self: start;
}
.detection-item {
grid-template-columns: 1fr;
}