Commit:
abca893Parent:
d35c465Add configurable settings and per-monitoring SMS recipients.
Store check interval and default phone numbers in the database with a settings dialog, and send alerts using each monitoring's own recipient list. Co-authored-by: Cursor <cursoragent@cursor.com>
AppSettings.cs
+22
-0
diff --git a/AppSettings.cs b/AppSettings.cs
new file mode 100644
index 0000000..97474b3
@@ -0,0 +1,22 @@
using System.Text.Json;
internal sealed record AppSettings(int IntervalSeconds, string[] DefaultPhoneNumbers);
internal sealed record UpdateSettingsRequest(int IntervalSeconds, string[] DefaultPhoneNumbers);
internal static class PhoneNumbersJson
{
private static readonly JsonSerializerOptions Options = new() { WriteIndented = false };
public static string[] Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
return JsonSerializer.Deserialize<string[]>(json, Options) ?? [];
}
public static string Serialize(string[] phoneNumbers) => JsonSerializer.Serialize(phoneNumbers, Options);
}
CheckMonitoringJob.cs
+1
-4
diff --git a/CheckMonitoringJob.cs b/CheckMonitoringJob.cs
index ee20a37..27c30c2 100644
@@ -1,7 +1,6 @@
using System.Diagnostics;
using BfiMonitor;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Playwright;
using Quartz;
@@ -9,7 +8,6 @@ using Quartz;
internal sealed class CheckMonitoringJob(
PlaywrightBrowserService browserService,
ScreeningRepository repository,
IOptionsMonitor<MonitorOptions> options,
ILogger<CheckMonitoringJob> logger,
TimeProvider timeProvider
) : IJob
@@ -91,8 +89,7 @@ internal sealed class CheckMonitoringJob(
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)
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();
IntervalScheduler.cs
+42
-0
diff --git a/IntervalScheduler.cs b/IntervalScheduler.cs
new file mode 100644
index 0000000..c1e1c32
@@ -0,0 +1,42 @@
using Quartz;
internal static class QuartzScheduling
{
public static readonly JobKey ScheduleMonitoringChecksJobKey = JobKey.Create(nameof(ScheduleMonitoringChecksJob));
public static readonly TriggerKey ScheduleMonitoringChecksTriggerKey = new(
"interval",
nameof(ScheduleMonitoringChecksJob)
);
}
internal sealed class IntervalScheduler(ISchedulerFactory schedulerFactory, ScreeningRepository repository)
{
public async Task ApplyStoredIntervalAsync(CancellationToken cancellationToken = default)
{
var settings = await repository.GetAppSettingsAsync(cancellationToken);
await RescheduleAsync(settings.IntervalSeconds, cancellationToken);
}
public async Task RescheduleAsync(int intervalSeconds, CancellationToken cancellationToken = default)
{
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
var seconds = Math.Max(60, intervalSeconds);
var trigger = TriggerBuilder
.Create()
.ForJob(QuartzScheduling.ScheduleMonitoringChecksJobKey)
.WithIdentity(QuartzScheduling.ScheduleMonitoringChecksTriggerKey)
.StartNow()
.WithSimpleSchedule(s => s.WithIntervalInSeconds(seconds).RepeatForever())
.Build();
if (await scheduler.CheckExists(QuartzScheduling.ScheduleMonitoringChecksTriggerKey, cancellationToken))
{
await scheduler.RescheduleJob(
QuartzScheduling.ScheduleMonitoringChecksTriggerKey,
trigger,
cancellationToken
);
}
}
}
MonitorOptions.cs
+2
-2
diff --git a/MonitorOptions.cs b/MonitorOptions.cs
index 0408165..e326d1a 100644
@@ -7,6 +7,6 @@ internal sealed class MonitorOptions
public string DefaultSelector { get; set; } = ".detailed-search-results";
}
internal sealed record CreateMonitoringRequest(string Url, string Selector, string? Name);
internal sealed record CreateMonitoringRequest(string Url, string Selector, string? Name, string[]? PhoneNumbers);
internal sealed record UpdateMonitoringRequest(string Url, string Selector, string? Name);
internal sealed record UpdateMonitoringRequest(string Url, string Selector, string? Name, string[] PhoneNumbers);
Program.cs
+46
-14
diff --git a/Program.cs b/Program.cs
index 47470dd..4fd4c5b 100644
@@ -22,6 +22,7 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService<PlaywrightBrowserS
builder.Services.AddSingleton<ScreeningRepository>();
builder.Services.AddSingleton<MonitoringCheckScheduler>();
builder.Services.AddSingleton<IntervalScheduler>();
builder.Services.AddSingleton(TimeProvider.System);
@@ -29,10 +30,10 @@ var intervalSeconds = builder.Configuration.GetValue("Monitor:IntervalSeconds",
builder.Services.AddQuartz(q =>
{
var scheduleKey = JobKey.Create(nameof(ScheduleMonitoringChecksJob));
q.AddJob<ScheduleMonitoringChecksJob>(scheduleKey);
q.AddJob<ScheduleMonitoringChecksJob>(QuartzScheduling.ScheduleMonitoringChecksJobKey);
q.AddTrigger(t =>
t.ForJob(scheduleKey)
t.ForJob(QuartzScheduling.ScheduleMonitoringChecksJobKey)
.WithIdentity(QuartzScheduling.ScheduleMonitoringChecksTriggerKey)
.StartNow()
.WithSimpleSchedule(s => s.WithIntervalInSeconds(intervalSeconds).RepeatForever())
);
@@ -44,6 +45,8 @@ builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
var app = builder.Build();
await app.Services.GetRequiredService<IntervalScheduler>().ApplyStoredIntervalAsync();
app.UseDefaultFiles();
app.UseStaticFiles();
@@ -51,13 +54,13 @@ app.MapGet(
"/api/status",
async (IOptions<MonitorOptions> options, ScreeningRepository repository, CancellationToken ct) =>
{
var opts = options.Value;
var settings = await repository.GetAppSettingsAsync(ct);
return Results.Json(
new
{
opts.IntervalSeconds,
opts.PhoneNumbers,
opts.DefaultSelector,
settings.IntervalSeconds,
settings.DefaultPhoneNumbers,
options.Value.DefaultSelector,
RunningMonitorings = await repository.CountRunningMonitoringsAsync(ct),
ActiveMonitorings = await repository.CountActiveMonitoringsAsync(ct),
}
@@ -66,6 +69,36 @@ app.MapGet(
);
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))
@@ -95,10 +128,14 @@ app.MapPost(
? 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
);
@@ -109,13 +146,7 @@ app.MapPost(
app.MapPut(
"/api/monitorings/{id:int}",
async (
int id,
UpdateMonitoringRequest request,
ScreeningRepository repository,
IOptions<MonitorOptions> options,
CancellationToken ct
) =>
async (int id, UpdateMonitoringRequest request, ScreeningRepository repository, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.Url))
{
@@ -142,6 +173,7 @@ app.MapPut(
request.Url.Trim(),
request.Selector.Trim(),
request.Name?.Trim() ?? "",
request.PhoneNumbers,
ct
);
ScreeningRepository.cs
+142
-17
diff --git a/ScreeningRepository.cs b/ScreeningRepository.cs
index 710713b..e98570c 100644
@@ -7,6 +7,7 @@ internal sealed record Monitoring(
string Name,
string Url,
string Selector,
string[] PhoneNumbers,
DateTimeOffset CreatedAt,
DateTimeOffset? PausedAt,
DateTimeOffset? ArchivedAt
@@ -17,6 +18,7 @@ internal sealed record MonitoringSummary(
string Name,
string Url,
string Selector,
string[] PhoneNumbers,
DateTimeOffset CreatedAt,
DateTimeOffset? PausedAt,
DateTimeOffset? ArchivedAt,
@@ -70,6 +72,9 @@ internal sealed class ScreeningRepository
EnsureMonitorIdColumn(connection);
EnsurePausedAtColumn(connection);
EnsurePhoneNumbersColumn(connection);
SeedAppSettings(connection);
MigrateMonitoringPhoneNumbers(connection);
SeedDefaultMonitoring(connection);
}
@@ -117,6 +122,69 @@ internal sealed class ScreeningRepository
Execute(connection, "ALTER TABLE Monitorings ADD COLUMN PausedAt TEXT");
}
private static void EnsurePhoneNumbersColumn(SqliteConnection connection)
{
if (ColumnExists(connection, "Monitorings", "PhoneNumbers"))
{
return;
}
Execute(connection, "ALTER TABLE Monitorings ADD COLUMN PhoneNumbers TEXT NOT NULL DEFAULT '[]'");
}
private void SeedAppSettings(SqliteConnection connection)
{
Execute(
connection,
"""
CREATE TABLE IF NOT EXISTS AppSettings (
Id INTEGER PRIMARY KEY CHECK (Id = 1),
IntervalSeconds INTEGER NOT NULL,
DefaultPhoneNumbers TEXT NOT NULL DEFAULT '[]'
)
"""
);
using var countCommand = connection.CreateCommand();
countCommand.CommandText = "SELECT COUNT(*) FROM AppSettings";
if (Convert.ToInt32(countCommand.ExecuteScalar()) > 0)
{
return;
}
var intervalSeconds = configuration.GetValue("Monitor:IntervalSeconds", 300);
var defaultPhoneNumbers = PhoneNumbersJson.Serialize(
configuration.GetSection("Monitor:PhoneNumbers").Get<string[]>() ?? []
);
using var insertCommand = connection.CreateCommand();
insertCommand.CommandText =
"INSERT INTO AppSettings (Id, IntervalSeconds, DefaultPhoneNumbers) VALUES (1, $interval, $phones)";
insertCommand.Parameters.AddWithValue("$interval", intervalSeconds);
insertCommand.Parameters.AddWithValue("$phones", defaultPhoneNumbers);
insertCommand.ExecuteNonQuery();
}
private void MigrateMonitoringPhoneNumbers(SqliteConnection connection)
{
var configPhones = PhoneNumbersJson.Serialize(
configuration.GetSection("Monitor:PhoneNumbers").Get<string[]>() ?? []
);
if (configPhones == "[]")
{
return;
}
using var updateCommand = connection.CreateCommand();
updateCommand.CommandText = """
UPDATE Monitorings
SET PhoneNumbers = $phones
WHERE PhoneNumbers IS NULL OR PhoneNumbers = '[]'
""";
updateCommand.Parameters.AddWithValue("$phones", configPhones);
updateCommand.ExecuteNonQuery();
}
private void SeedDefaultMonitoring(SqliteConnection connection)
{
using var countCommand = connection.CreateCommand();
@@ -136,13 +204,19 @@ internal sealed class ScreeningRepository
var selector = configuration["Monitor:Selector"] ?? ".detailed-search-results";
var name = configuration["Monitor:Name"] ?? "";
var createdAt = DateTimeOffset.UtcNow.ToString("O");
var phoneNumbers = PhoneNumbersJson.Serialize(
configuration.GetSection("Monitor:PhoneNumbers").Get<string[]>() ?? []
);
using var insertCommand = connection.CreateCommand();
insertCommand.CommandText =
"INSERT INTO Monitorings (Name, Url, Selector, CreatedAt) VALUES ($name, $url, $selector, $createdAt)";
insertCommand.CommandText = """
INSERT INTO Monitorings (Name, Url, Selector, PhoneNumbers, CreatedAt)
VALUES ($name, $url, $selector, $phones, $createdAt)
""";
insertCommand.Parameters.AddWithValue("$name", name);
insertCommand.Parameters.AddWithValue("$url", url);
insertCommand.Parameters.AddWithValue("$selector", selector);
insertCommand.Parameters.AddWithValue("$phones", phoneNumbers);
insertCommand.Parameters.AddWithValue("$createdAt", createdAt);
insertCommand.ExecuteNonQuery();
@@ -176,6 +250,7 @@ internal sealed class ScreeningRepository
m.Name,
m.Url,
m.Selector,
m.PhoneNumbers,
m.CreatedAt,
m.PausedAt,
m.ArchivedAt,
@@ -208,7 +283,7 @@ internal sealed class ScreeningRepository
)
{
const string sql = """
SELECT Id, Name, Url, Selector, CreatedAt, PausedAt, ArchivedAt
SELECT Id, Name, Url, Selector, PhoneNumbers, CreatedAt, PausedAt, ArchivedAt
FROM Monitorings
WHERE ArchivedAt IS NULL AND PausedAt IS NULL
ORDER BY CreatedAt
@@ -235,7 +310,7 @@ internal sealed class ScreeningRepository
)
{
const string sql = """
SELECT Id, Name, Url, Selector, CreatedAt, PausedAt, ArchivedAt
SELECT Id, Name, Url, Selector, PhoneNumbers, CreatedAt, PausedAt, ArchivedAt
FROM Monitorings
WHERE ArchivedAt IS NULL
ORDER BY CreatedAt
@@ -260,7 +335,7 @@ internal sealed class ScreeningRepository
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";
"SELECT Id, Name, Url, Selector, PhoneNumbers, 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);
@@ -276,14 +351,15 @@ internal sealed class ScreeningRepository
string url,
string selector,
string name,
string[] phoneNumbers,
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
INSERT INTO Monitorings (Name, Url, Selector, PhoneNumbers, CreatedAt)
VALUES ($name, $url, $selector, $phones, $createdAt)
RETURNING Id, Name, Url, Selector, PhoneNumbers, CreatedAt, PausedAt, ArchivedAt
""";
using var activity = Tracing.StartCreateMonitoring(sql);
@@ -294,6 +370,7 @@ internal sealed class ScreeningRepository
command.Parameters.AddWithValue("$name", name);
command.Parameters.AddWithValue("$url", url);
command.Parameters.AddWithValue("$selector", selector);
command.Parameters.AddWithValue("$phones", PhoneNumbersJson.Serialize(phoneNumbers));
command.Parameters.AddWithValue("$createdAt", createdAt.ToString("O"));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -306,14 +383,15 @@ internal sealed class ScreeningRepository
string url,
string selector,
string name,
string[] phoneNumbers,
CancellationToken cancellationToken = default
)
{
const string sql = """
UPDATE Monitorings
SET Name = $name, Url = $url, Selector = $selector
SET Name = $name, Url = $url, Selector = $selector, PhoneNumbers = $phones
WHERE Id = $id AND ArchivedAt IS NULL
RETURNING Id, Name, Url, Selector, CreatedAt, PausedAt, ArchivedAt
RETURNING Id, Name, Url, Selector, PhoneNumbers, CreatedAt, PausedAt, ArchivedAt
""";
using var activity = Tracing.StartUpdateMonitoring(sql);
@@ -325,6 +403,7 @@ internal sealed class ScreeningRepository
command.Parameters.AddWithValue("$name", name);
command.Parameters.AddWithValue("$url", url);
command.Parameters.AddWithValue("$selector", selector);
command.Parameters.AddWithValue("$phones", PhoneNumbersJson.Serialize(phoneNumbers));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken) ? ReadMonitoring(reader) : null;
@@ -494,15 +573,60 @@ internal sealed class ScreeningRepository
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken));
}
public async Task<AppSettings> GetAppSettingsAsync(CancellationToken cancellationToken = default)
{
const string sql = "SELECT IntervalSeconds, DefaultPhoneNumbers FROM AppSettings WHERE Id = 1";
using var activity = Tracing.StartGetAppSettings(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return new AppSettings(300, []);
}
return new AppSettings(reader.GetInt32(0), PhoneNumbersJson.Deserialize(reader.GetString(1)));
}
public async Task<AppSettings> UpdateAppSettingsAsync(
int intervalSeconds,
string[] defaultPhoneNumbers,
CancellationToken cancellationToken = default
)
{
const string sql = """
UPDATE AppSettings
SET IntervalSeconds = $interval, DefaultPhoneNumbers = $phones
WHERE Id = 1
RETURNING IntervalSeconds, DefaultPhoneNumbers
""";
using var activity = Tracing.StartUpdateAppSettings(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$interval", Math.Max(60, intervalSeconds));
command.Parameters.AddWithValue("$phones", PhoneNumbersJson.Serialize(defaultPhoneNumbers));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
await reader.ReadAsync(cancellationToken);
return new AppSettings(reader.GetInt32(0), PhoneNumbersJson.Deserialize(reader.GetString(1)));
}
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))
PhoneNumbersJson.Deserialize(reader.GetString(4)),
DateTimeOffset.Parse(reader.GetString(5)),
reader.IsDBNull(6) ? null : DateTimeOffset.Parse(reader.GetString(6)),
reader.IsDBNull(7) ? null : DateTimeOffset.Parse(reader.GetString(7))
);
private static MonitoringSummary ReadMonitoringSummary(SqliteDataReader reader) =>
@@ -511,11 +635,12 @@ internal sealed class ScreeningRepository
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
DateTimeOffset.Parse(reader.GetString(4)),
reader.IsDBNull(5) ? null : DateTimeOffset.Parse(reader.GetString(5)),
PhoneNumbersJson.Deserialize(reader.GetString(4)),
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))
reader.IsDBNull(7) ? null : DateTimeOffset.Parse(reader.GetString(7)),
reader.GetInt32(8),
reader.IsDBNull(9) ? null : DateTimeOffset.Parse(reader.GetString(9))
);
private static Detection ReadDetection(SqliteDataReader reader) =>
Tracing.cs
+6
-0
diff --git a/Tracing.cs b/Tracing.cs
index 36a79d1..57b4542 100644
@@ -36,6 +36,12 @@ internal static class Tracing
public static Activity? StartUpdateMonitoring(string sql) =>
ActivitySource.StartActivity("UpdateMonitoring", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartGetAppSettings(string sql) =>
ActivitySource.StartActivity("GetAppSettings", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartUpdateAppSettings(string sql) =>
ActivitySource.StartActivity("UpdateAppSettings", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartArchiveMonitoring(string sql) =>
ActivitySource.StartActivity("ArchiveMonitoring", ActivityKind.Client)?.SetTag("Sql", sql);
wwwroot/app.js
+107
-1
diff --git a/wwwroot/app.js b/wwwroot/app.js
index 71bb61a..83d1cac 100644
@@ -10,6 +10,7 @@ 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 addPhonesInput = document.getElementById("add-phones");
const addError = document.getElementById("add-error");
const closeAddDialogBtn = document.getElementById("close-add-dialog");
const cancelAddDialogBtn = document.getElementById("cancel-add-dialog");
@@ -18,6 +19,7 @@ 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 editPhonesInput = document.getElementById("edit-phones");
const editError = document.getElementById("edit-error");
const closeEditDialogBtn = document.getElementById("close-edit-dialog");
const cancelEditDialogBtn = document.getElementById("cancel-edit-dialog");
@@ -31,11 +33,21 @@ const drawerTitle = document.getElementById("drawer-title");
const drawerMeta = document.getElementById("drawer-meta");
const drawerPreview = document.getElementById("drawer-preview");
const closeDrawerBtn = document.getElementById("close-drawer");
const settingsForm = document.getElementById("settings-form");
const settingsDialog = document.getElementById("settings-dialog");
const settingsIntervalInput = document.getElementById("settings-interval");
const settingsDefaultPhonesInput = document.getElementById("settings-default-phones");
const settingsError = document.getElementById("settings-error");
const saveSettingsBtn = document.getElementById("save-settings-btn");
const openSettingsBtn = document.getElementById("open-settings-btn");
const closeSettingsDialogBtn = document.getElementById("close-settings-dialog");
const cancelSettingsDialogBtn = document.getElementById("cancel-settings-dialog");
let selectedMonitorId = null;
let editingMonitorId = null;
let monitorings = [];
let defaultSelector = ".detailed-search-results";
let defaultPhoneNumbers = [];
function formatDate(iso) {
if (!iso) return "—";
@@ -71,6 +83,29 @@ function monitoringLabel(monitoring) {
return monitoring.name || monitoring.url;
}
function parsePhoneNumbers(text) {
return text
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function formatPhoneNumbers(numbers) {
return numbers.join("\n");
}
function formatPhoneNumbersSummary(numbers) {
if (!numbers || numbers.length === 0) {
return "None";
}
if (numbers.length === 1) {
return numbers[0];
}
return `${numbers[0]} +${numbers.length - 1} more`;
}
async function loadStatus() {
const res = await fetch("/api/status");
if (!res.ok) throw new Error("Failed to load status");
@@ -94,13 +129,24 @@ async function loadDetections() {
return res.json();
}
function renderSettings(status) {
defaultPhoneNumbers = status.defaultPhoneNumbers || [];
if (settingsDialog.open) {
return;
}
settingsIntervalInput.value = String(status.intervalSeconds);
settingsDefaultPhonesInput.value = formatPhoneNumbers(defaultPhoneNumbers);
}
function renderStatus(status) {
defaultSelector = status.defaultSelector || defaultSelector;
renderSettings(status);
statusCards.replaceChildren(
card("Check interval", formatInterval(status.intervalSeconds)),
card("Running monitorings", String(status.runningMonitorings)),
card("Paused monitorings", String(status.activeMonitorings - status.runningMonitorings)),
card("SMS recipients", status.phoneNumbers.join(", ") || "—")
card("Default SMS recipients", formatPhoneNumbersSummary(status.defaultPhoneNumbers))
);
liveStatus.querySelector("span:last-child").textContent =
@@ -151,6 +197,7 @@ function renderMonitorings(items) {
<p class="monitoring-item__url">${escapeHtml(item.url)}</p>
<p class="monitoring-item__meta">
<span>${escapeHtml(item.selector)}</span>
<span>SMS ${escapeHtml(formatPhoneNumbersSummary(item.phoneNumbers))}</span>
<span>${item.totalDetections} detection${item.totalDetections === 1 ? "" : "s"}</span>
<span>Latest ${formatDate(item.latestDetectionAt)}</span>
</p>
@@ -297,6 +344,7 @@ function openAddDialog() {
addNameInput.value = "";
addUrlInput.value = "";
addSelectorInput.value = defaultSelector;
addPhonesInput.value = formatPhoneNumbers(defaultPhoneNumbers);
addDialog.showModal();
}
@@ -304,6 +352,24 @@ function closeAddDialog() {
addDialog.close();
}
async function openSettingsDialog() {
settingsError.classList.add("hidden");
settingsError.textContent = "";
try {
const status = await loadStatus();
defaultPhoneNumbers = status.defaultPhoneNumbers || [];
settingsIntervalInput.value = String(status.intervalSeconds);
settingsDefaultPhonesInput.value = formatPhoneNumbers(defaultPhoneNumbers);
settingsDialog.showModal();
} catch {
handleError();
}
}
function closeSettingsDialog() {
settingsDialog.close();
}
function openEditDialog(monitoring) {
editingMonitorId = monitoring.id;
editError.classList.add("hidden");
@@ -311,6 +377,7 @@ function openEditDialog(monitoring) {
editNameInput.value = monitoring.name || "";
editUrlInput.value = monitoring.url;
editSelectorInput.value = monitoring.selector;
editPhonesInput.value = formatPhoneNumbers(monitoring.phoneNumbers || []);
editDialog.showModal();
}
@@ -332,6 +399,7 @@ async function updateMonitoring(event) {
name: editNameInput.value.trim() || null,
url: editUrlInput.value.trim(),
selector: editSelectorInput.value.trim(),
phoneNumbers: parsePhoneNumbers(editPhonesInput.value),
};
const res = await fetch(`/api/monitorings/${editingMonitorId}`, {
@@ -356,10 +424,12 @@ async function createMonitoring(event) {
addError.classList.add("hidden");
addError.textContent = "";
const phoneNumbers = parsePhoneNumbers(addPhonesInput.value);
const payload = {
name: addNameInput.value.trim() || null,
url: addUrlInput.value.trim(),
selector: addSelectorInput.value.trim(),
phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers : null,
};
const res = await fetch("/api/monitorings", {
@@ -455,6 +525,38 @@ function handleError() {
liveStatus.querySelector("span:last-child").textContent = "Connection error";
}
async function saveSettings(event) {
event.preventDefault();
settingsError.classList.add("hidden");
settingsError.textContent = "";
const payload = {
intervalSeconds: Number(settingsIntervalInput.value),
defaultPhoneNumbers: parsePhoneNumbers(settingsDefaultPhonesInput.value),
};
saveSettingsBtn.disabled = true;
try {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
settingsError.textContent = body.error || "Could not save settings.";
settingsError.classList.remove("hidden");
return;
}
closeSettingsDialog();
await refresh();
} finally {
saveSettingsBtn.disabled = false;
}
}
async function refresh() {
try {
const [status, nextMonitorings, detections] = await Promise.all([
@@ -478,6 +580,10 @@ triggerAllBtn.addEventListener("click", triggerAllMonitorings);
closeAddDialogBtn.addEventListener("click", closeAddDialog);
cancelAddDialogBtn.addEventListener("click", closeAddDialog);
addForm.addEventListener("submit", createMonitoring);
settingsForm.addEventListener("submit", saveSettings);
openSettingsBtn.addEventListener("click", openSettingsDialog);
closeSettingsDialogBtn.addEventListener("click", closeSettingsDialog);
cancelSettingsDialogBtn.addEventListener("click", closeSettingsDialog);
closeEditDialogBtn.addEventListener("click", closeEditDialog);
cancelEditDialogBtn.addEventListener("click", closeEditDialog);
editForm.addEventListener("submit", updateMonitoring);
wwwroot/index.html
+39
-3
diff --git a/wwwroot/index.html b/wwwroot/index.html
index f92175f..156d8f0 100644
@@ -16,9 +16,12 @@
<p class="header__subtitle">Screening change tracker</p>
</div>
</div>
<div class="header__status" id="live-status">
<span class="pulse"></span>
<span>Loading…</span>
<div class="header__actions">
<div class="header__status" id="live-status">
<span class="pulse"></span>
<span>Loading…</span>
</div>
<button type="button" class="btn btn--ghost" id="open-settings-btn">Settings</button>
</div>
</header>
@@ -73,6 +76,11 @@
<span>CSS selector</span>
<input type="text" id="add-selector" required placeholder=".detailed-search-results" autocomplete="off" />
</label>
<label class="field">
<span>SMS recipients</span>
<small>One phone number per line. Leave blank to use defaults.</small>
<textarea id="add-phones" rows="3" placeholder="+46701234567"></textarea>
</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>
@@ -99,6 +107,11 @@
<span>CSS selector</span>
<input type="text" id="edit-selector" required placeholder=".detailed-search-results" autocomplete="off" />
</label>
<label class="field">
<span>SMS recipients</span>
<small>One phone number per line.</small>
<textarea id="edit-phones" rows="3" placeholder="+46701234567"></textarea>
</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>
@@ -107,6 +120,29 @@
</form>
</dialog>
<dialog class="dialog" id="settings-dialog">
<form id="settings-form">
<div class="dialog__head">
<h2>Settings</h2>
<button type="button" class="btn btn--ghost" id="close-settings-dialog" aria-label="Close">✕</button>
</div>
<label class="field field--compact">
<span>Check interval (seconds)</span>
<input type="number" id="settings-interval" min="60" step="60" required />
</label>
<label class="field">
<span>Default SMS recipients</span>
<small>One phone number per line. Pre-filled when adding new monitorings.</small>
<textarea id="settings-default-phones" rows="3" placeholder="+46701234567"></textarea>
</label>
<p class="form-error hidden" id="settings-error"></p>
<div class="dialog__actions">
<button type="button" class="btn btn--ghost" id="cancel-settings-dialog">Cancel</button>
<button type="submit" class="btn btn--primary" id="save-settings-btn">Save</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
+41
-3
diff --git a/wwwroot/styles.css b/wwwroot/styles.css
index 2eb0fa0..ad6977d 100644
@@ -76,6 +76,14 @@ body {
font-size: 0.9rem;
}
.header__actions {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.header__status {
display: flex;
align-items: center;
@@ -108,7 +116,7 @@ body {
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.85rem;
margin-bottom: 1.5rem;
}
@@ -117,7 +125,13 @@ body {
padding: 1rem 1.1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
transition: border-color 0.15s, transform 0.15s;
}
.card:hover {
border-color: rgba(212, 175, 55, 0.35);
transform: translateY(-1px);
}
.card__label {
@@ -149,6 +163,8 @@ body {
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
margin-bottom: 1.25rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.panel__head {
@@ -389,7 +405,24 @@ body {
font: inherit;
}
.field input:focus {
.field textarea {
width: 100%;
min-height: 5rem;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.55rem 0.7rem;
background: var(--bg);
color: var(--text);
font: inherit;
resize: vertical;
}
.field--compact {
max-width: 100%;
}
.field input:focus,
.field textarea:focus {
outline: 2px solid rgba(212, 175, 55, 0.35);
border-color: var(--gold-dim);
}
@@ -539,6 +572,11 @@ body {
align-items: flex-start;
}
.header__actions {
width: 100%;
justify-content: space-between;
}
.monitoring-item {
grid-template-columns: 1fr;
}