Commit:
a28f89cParent:
4693b9dAdd web dashboard for viewing monitor status and detections.
Expose detection history over HTTP so changes can be reviewed in the browser instead of logs or SQLite. Co-authored-by: Cursor <cursoragent@cursor.com>
BfiMonitor.csproj
+1
-10
diff --git a/BfiMonitor.csproj b/BfiMonitor.csproj
index 697800a..b38ec04 100644
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
@@ -9,14 +9,6 @@
<RuntimeIdentifiers>win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64</RuntimeIdentifiers>
</PropertyGroup>
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<Content
Condition="'$(Configuration)' == 'Debug'"
Include="appsettings.Development.json"
CopyToOutputDirectory="PreserveNewest"
/>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\sms.proto" GrpcServices="Client" />
</ItemGroup>
<ItemGroup>
@@ -30,7 +22,6 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="11.0.0-preview.4.26230.115" />
<PackageReference Include="Microsoft.Playwright" Version="1.60.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.4.26230.115" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
OpenTelemetryExtensions.cs
+1
-0
diff --git a/OpenTelemetryExtensions.cs b/OpenTelemetryExtensions.cs
index 8847176..950085f 100644
@@ -27,6 +27,7 @@ public static class OpenTelemetryExtensions
.WithTracing(tracing =>
{
tracing
.AddAspNetCoreInstrumentation()
.AddSource(builder.Environment.ApplicationName)
.AddHttpClientInstrumentation()
.AddQuartzInstrumentation()
Program.cs
+50
-5
diff --git a/Program.cs b/Program.cs
index 851b504..31b212f 100644
@@ -1,12 +1,9 @@
using BfiMonitor;
using HuaweiWifiSms.Grpc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Quartz;
var builder = Host.CreateApplicationBuilder(args);
var builder = WebApplication.CreateBuilder(args);
builder.ConfigureOpenTelemetry();
@@ -44,6 +41,54 @@ builder.Services.AddQuartz(q =>
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
using var app = builder.Build();
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet(
"/api/status",
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,
}
);
}
);
app.MapGet(
"/api/detections",
async (ScreeningRepository repository, CancellationToken ct, int limit = 50) =>
{
var detections = await repository.GetDetectionsAsync(Math.Clamp(limit, 1, 200), ct);
return Results.Json(
detections.Select(d => new
{
d.Id,
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();
Properties/launchSettings.json
+1
-0
diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json
index c3f3d80..7a857b0 100644
@@ -4,6 +4,7 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5080",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
ScreeningRepository.cs
+57
-0
diff --git a/ScreeningRepository.cs b/ScreeningRepository.cs
index 68dd1e1..906f38c 100644
@@ -2,6 +2,8 @@ using BfiMonitor;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
internal sealed record Detection(int Id, string Html, DateTimeOffset DetectedAt);
internal sealed class ScreeningRepository
{
private readonly string connectionString;
@@ -47,4 +49,59 @@ internal sealed class ScreeningRepository
command.Parameters.AddWithValue("$at", detectedAt.ToString("O"));
await command.ExecuteNonQueryAsync();
}
public async Task<IReadOnlyList<Detection>> GetDetectionsAsync(
int limit,
CancellationToken cancellationToken = default
)
{
const string sql = "SELECT Id, Html, DetectedAt FROM DetectedHtml 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);
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)))
);
}
return results;
}
public async Task<Detection?> GetDetectionByIdAsync(int id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT Id, Html, DetectedAt FROM DetectedHtml WHERE Id = $id";
using var activity = Tracing.StartGetDetectionById(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);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new Detection(reader.GetInt32(0), reader.GetString(1), DateTimeOffset.Parse(reader.GetString(2)));
}
public async Task<int> CountDetectionsAsync(CancellationToken cancellationToken = default)
{
const string sql = "SELECT COUNT(*) FROM DetectedHtml";
using var activity = Tracing.StartCountDetections(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));
}
}
Tracing.cs
+9
-0
diff --git a/Tracing.cs b/Tracing.cs
index 987bd76..8ff00b6 100644
@@ -15,6 +15,15 @@ internal static class Tracing
public static Activity? StartInsertNewDetection(string sql) =>
ActivitySource.StartActivity("InsertNewDetection", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartGetDetections(string sql) =>
ActivitySource.StartActivity("GetDetections", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartGetDetectionById(string sql) =>
ActivitySource.StartActivity("GetDetectionById", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartCountDetections(string sql) =>
ActivitySource.StartActivity("CountDetections", ActivityKind.Client)?.SetTag("Sql", sql);
public static Activity? StartSendSms(string phoneNumber) =>
ActivitySource.StartActivity("SendSms", ActivityKind.Internal)?.SetTag("PhoneNumber", phoneNumber);
packages.lock.json
+1
-217
diff --git a/packages.lock.json b/packages.lock.json
index 38f0695..651a915 100644
@@ -20,8 +20,7 @@
"resolved": "2.80.0",
"contentHash": "YtY1DWID2phwiGc8qBG7+wf00Do5jE7BkJgCc6nbu5b50OsD89mSd73oCE8fCnUo7IjtDAEYFOYI3NSzgn28gw==",
"dependencies": {
"Grpc.Net.Client": "2.80.0",
"Microsoft.Extensions.Http": "8.0.0"
"Grpc.Net.Client": "2.80.0"
}
},
"Grpc.Tools": {
@@ -41,30 +40,6 @@
"SQLitePCLRaw.core": "3.0.2"
}
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
"requested": "[11.0.0-preview.4.26230.115, )",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "JabRZnPjt57VHxcOm7G7runakIkv3PunM2GTJF32KF5hJChgpUh2gnuL4VQ0GsVtHXGsbefj9tddYs2K0YJmpA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Configuration.Binder": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Configuration.CommandLine": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Configuration.FileExtensions": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Configuration.Json": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Configuration.UserSecrets": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.DependencyInjection": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Diagnostics": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.FileProviders.Physical": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Logging": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Logging.Configuration": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Logging.Console": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Logging.Debug": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Logging.EventLog": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Logging.EventSource": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Playwright": {
"type": "Direct",
"requested": "[1.60.0, )",
@@ -107,7 +82,6 @@
"resolved": "1.15.1",
"contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
@@ -172,166 +146,11 @@
"SQLitePCLRaw.core": "3.0.2"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "CPb3DPC6kcgx+kuSTJ55H/A6Z+IZeW++VM+UiFpiwq6eQC2Mk3gHcFpqdQoK2MjWovWYN4Sz6n1RXhLWHVB35w=="
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "IOkgB4CnLUlKH+l0slH5Q+6m9pWfcCBocFPHc7czATwIx5jnVPgUMRj2gGhZtvGs1PKpeoZv7PCGK5cYIwlZzw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "AWgDyrMol+ywzG0MAAk+TkLIy4fMEsOCG1E/zb1kHE3Lp81GNNEMU+jOJTF1gEsLkDajY5AouNAWx3cvi+pxzg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "7vBrAODuFr5u548MNTaUybxQ+Hw/x9boZ2rWQynOS/KJkZhrriUFtgbQTeRamQFVna1eh78WjVAqEKm8GpQXBg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "//7zz5cgmkd/cfgKanPna9CHJ4o9Wp9JPpr+3rmSa7vd/+Wwxjb9Z9sUKvH0LsJl72LnUYWKJ7Ah02nr2yLsYA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.FileProviders.Physical": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "cXhGyFCd2LnPAMu5pVxf3CeIqYJ+wy866tNKjaAvLFQsJ1QDB4Xfmiy+6q0Zm8DvjektmB/IoDBl5F/0AKEBOA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Configuration.FileExtensions": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "GnJpXFEumu46mGMU++s54qgRwlkGhCP05xHOSgaAK/lLTUOt2mAMaIwFqrLFKfUFtkByjxSe/1FtzXx2zrIv9w==",
"dependencies": {
"Microsoft.Extensions.Configuration.Json": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.FileProviders.Physical": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "v+F34r5W5XgR95xG0Te2KyYmCDnkc4kljLMGEZ30WKQjAzuXz/pLTEvsRNYxNJUzfKPI0H4oqLglyHZkCe1lYw=="
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "L1+sxK5JT7X5jvZ0RInbDnyJZG9rkp4cbgzoL0aGRUOOMp3PkgCcUjAUhAprxBu9LgGdeqZg7JratDMHS1YN/Q==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Options.ConfigurationExtensions": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "L/+w2lo0fV2hcQuAe3YIBwXl4YP16c/xFUCHXjhzidByFbyECb2H8hJ5EEeszihGcT69nd+gJqrZyzyp1bObig==",
"dependencies": {
"Microsoft.Extensions.FileSystemGlobbing": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "y8hEW4rVMESwL9iMfc0PICy0ScXzj4MMXclEjf5AXZKOFrJe6nu8iU7K9VVZenNweXdLYvlcs2ZSnhVz7h8oYg=="
},
"Microsoft.Extensions.Http": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "cWz4caHwvx0emoYe7NkHPxII/KkTI8R/LC9qdqJqnKv2poTJ4e2qqPGQqvRoQ5kaSA4FU5IV3qFAuLuOhoqULQ==",
"dependencies": {
"Microsoft.Extensions.Diagnostics": "8.0.0",
"Microsoft.Extensions.Logging": "8.0.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "EpZS5qqhsNXwgeSCl2m1VkysA+pTh3qlhyjkjiu0peZt/V7IyANJXQQUuv3y2kp/jzd9XdfzuUJU0Fgp7rcDGg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "YkVeVCrOfKdYghClaYH/jcC2Irx6gtrWPAOjNkOeITQVQ41vvhPVp7EcXYD8IcfYH58YIS5WiX1ZJu6uLEVrAQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Configuration.Binder": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Logging": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Options.ConfigurationExtensions": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "1ATxqXTZnR5M4tOeCtZjA+fw1b+jWPsmUAp3PUCCvndV5ghIcSvnwZ5RiQFoTtYFSdNP+pMoxCtviI71/xyRCw==",
"dependencies": {
"Microsoft.Extensions.Logging": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Logging.Configuration": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "kD1p/UZ/NqHTLt1iLjtkJpPtPWWziLkV6kG3TxLi67gWasixyvH1grMfkd14XR24RIooTkavxGZJV+xjeSSsiA==",
"dependencies": {
"Microsoft.Extensions.Logging": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "DOtIsmOENF8xmQi0uw0sx4/1aDHDgj1PuGmlom2zobrqbJp2b5fkZPCIOTmPZsXbk78nxVnPH7/NTbR1cYRnsA==",
"dependencies": {
"Microsoft.Extensions.Logging": "11.0.0-preview.4.26230.115",
"Microsoft.Extensions.Logging.Configuration": "11.0.0-preview.4.26230.115",
"System.Diagnostics.EventLog": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "koDYnhbSTo+di3NMq13c0jhYFTIc7XUgnevONLwDpzR2GbbXfUR+dN5GuMeAVR4EQljUw5OYllrYflPNb0zoZA==",
"dependencies": {
"Microsoft.Extensions.Logging": "11.0.0-preview.4.26230.115"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "9QNxho6sxC4RXafVZGWzYq6KcYVpp1FkHtL+HJ967UNyUjhOWnGKMM0dPXkHKtlZ/0GGEDqnBDun0kuF633a6g==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "11.0.0-preview.4.26230.115"
}
},
"OpenTelemetry": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
"dependencies": {
"Microsoft.Extensions.Logging.Configuration": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
}
},
@@ -395,11 +214,6 @@
"dependencies": {
"SQLitePCLRaw.core": "3.0.2"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "/o76q92uK1eaosCQrIjp/V4OBHs9zIXxH+8ZEMFZg1GVH7fI24GUPx1BLawLCmdq/U1AdGeoto716k4BJEhi2g=="
}
},
"net11.0/linux-arm64": {
@@ -407,11 +221,6 @@
"type": "Transitive",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "/o76q92uK1eaosCQrIjp/V4OBHs9zIXxH+8ZEMFZg1GVH7fI24GUPx1BLawLCmdq/U1AdGeoto716k4BJEhi2g=="
}
},
"net11.0/linux-x64": {
@@ -419,11 +228,6 @@
"type": "Transitive",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "/o76q92uK1eaosCQrIjp/V4OBHs9zIXxH+8ZEMFZg1GVH7fI24GUPx1BLawLCmdq/U1AdGeoto716k4BJEhi2g=="
}
},
"net11.0/osx-arm64": {
@@ -431,11 +235,6 @@
"type": "Transitive",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "/o76q92uK1eaosCQrIjp/V4OBHs9zIXxH+8ZEMFZg1GVH7fI24GUPx1BLawLCmdq/U1AdGeoto716k4BJEhi2g=="
}
},
"net11.0/osx-x64": {
@@ -443,11 +242,6 @@
"type": "Transitive",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "/o76q92uK1eaosCQrIjp/V4OBHs9zIXxH+8ZEMFZg1GVH7fI24GUPx1BLawLCmdq/U1AdGeoto716k4BJEhi2g=="
}
},
"net11.0/win-arm64": {
@@ -455,11 +249,6 @@
"type": "Transitive",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "/o76q92uK1eaosCQrIjp/V4OBHs9zIXxH+8ZEMFZg1GVH7fI24GUPx1BLawLCmdq/U1AdGeoto716k4BJEhi2g=="
}
},
"net11.0/win-x64": {
@@ -467,11 +256,6 @@
"type": "Transitive",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "11.0.0-preview.4.26230.115",
"contentHash": "/o76q92uK1eaosCQrIjp/V4OBHs9zIXxH+8ZEMFZg1GVH7fI24GUPx1BLawLCmdq/U1AdGeoto716k4BJEhi2g=="
}
}
}
wwwroot/app.js
+137
-0
diff --git a/wwwroot/app.js b/wwwroot/app.js
new file mode 100644
index 0000000..3beb06e
@@ -0,0 +1,137 @@
const statusCards = document.getElementById("status-cards");
const liveStatus = document.getElementById("live-status");
const detectionList = document.getElementById("detection-list");
const detectionsEmpty = document.getElementById("detections-empty");
const refreshBtn = document.getElementById("refresh-btn");
const drawer = document.getElementById("detail-drawer");
const drawerBackdrop = document.getElementById("drawer-backdrop");
const drawerTitle = document.getElementById("drawer-title");
const drawerMeta = document.getElementById("drawer-meta");
const drawerPreview = document.getElementById("drawer-preview");
const closeDrawerBtn = document.getElementById("close-drawer");
function formatDate(iso) {
if (!iso) return "—";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(iso));
}
function formatInterval(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds % 60 === 0) return `${seconds / 60} min`;
return `${(seconds / 60).toFixed(1)} min`;
}
function card(label, value, { link = false } = {}) {
const el = document.createElement("article");
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>
`;
return el;
}
async function loadStatus() {
const res = await fetch("/api/status");
if (!res.ok) throw new Error("Failed to load status");
return res.json();
}
async function loadDetections() {
const res = await fetch("/api/detections?limit=100");
if (!res.ok) throw new Error("Failed to load detections");
return res.json();
}
function renderStatus(status) {
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("SMS recipients", status.phoneNumbers.join(", ") || "—")
);
liveStatus.querySelector("span:last-child").textContent = `Checking every ${formatInterval(status.intervalSeconds)}`;
}
function renderDetections(detections) {
detectionList.replaceChildren();
if (detections.length === 0) {
detectionsEmpty.classList.remove("hidden");
return;
}
detectionsEmpty.classList.add("hidden");
for (const item of detections) {
const li = document.createElement("li");
li.className = "detection-item";
li.innerHTML = `
<div>
<p class="detection-item__time">${formatDate(item.detectedAt)}</p>
<p class="detection-item__preview">${escapeHtml(item.preview)}</p>
</div>
<span class="detection-item__action">View →</span>
`;
li.addEventListener("click", () => openDetection(item.id));
detectionList.appendChild(li);
}
}
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;
const detection = await res.json();
drawerTitle.textContent = `Detection #${detection.id}`;
drawerMeta.textContent = `Detected ${formatDate(detection.detectedAt)}`;
drawerPreview.replaceChildren();
const iframe = document.createElement("iframe");
iframe.sandbox = "allow-same-origin";
iframe.srcdoc = `<!DOCTYPE html><html><head><meta charset="utf-8"><base target="_blank"></head><body>${detection.html}</body></html>`;
drawerPreview.appendChild(iframe);
drawer.classList.remove("hidden");
drawerBackdrop.classList.remove("hidden");
drawer.setAttribute("aria-hidden", "false");
}
function closeDrawer() {
drawer.classList.add("hidden");
drawerBackdrop.classList.add("hidden");
drawer.setAttribute("aria-hidden", "true");
drawerPreview.replaceChildren();
}
async function refresh() {
try {
const [status, detections] = await Promise.all([loadStatus(), loadDetections()]);
renderStatus(status);
renderDetections(detections);
} catch {
liveStatus.querySelector("span:last-child").textContent = "Connection error";
}
}
refreshBtn.addEventListener("click", refresh);
closeDrawerBtn.addEventListener("click", closeDrawer);
drawerBackdrop.addEventListener("click", closeDrawer);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeDrawer();
});
refresh();
setInterval(refresh, 30_000);
wwwroot/index.html
+51
-0
diff --git a/wwwroot/index.html b/wwwroot/index.html
new file mode 100644
index 0000000..dfdfad3
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BFI Monitor</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="layout">
<header class="header">
<div class="header__brand">
<span class="header__mark" aria-hidden="true">▶</span>
<div>
<h1>BFI Monitor</h1>
<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>
</header>
<section class="cards" id="status-cards" aria-label="Monitor status"></section>
<main class="panel">
<div class="panel__head">
<h2>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>
</div>
<ul class="detection-list" id="detection-list"></ul>
</main>
<aside class="drawer hidden" id="detail-drawer" aria-hidden="true">
<div class="drawer__head">
<h2 id="drawer-title">Detection</h2>
<button type="button" class="btn btn--ghost" id="close-drawer" aria-label="Close">✕</button>
</div>
<p class="drawer__meta" id="drawer-meta"></p>
<div class="drawer__preview" id="drawer-preview"></div>
</aside>
<div class="backdrop hidden" id="drawer-backdrop"></div>
</div>
<script src="app.js"></script>
</body>
</html>
wwwroot/styles.css
+320
-0
diff --git a/wwwroot/styles.css b/wwwroot/styles.css
new file mode 100644
index 0000000..d58a5a4
@@ -0,0 +1,320 @@
:root {
color-scheme: dark;
--bg: #0c0c10;
--surface: #14141c;
--surface-2: #1c1c28;
--border: #2a2a3a;
--text: #ececf1;
--muted: #9898a8;
--gold: #d4af37;
--gold-dim: #9a8230;
--accent: #6b8cff;
--ok: #3ecf8e;
--radius: 12px;
--shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(212, 175, 55, 0.12), transparent),
var(--bg);
color: var(--text);
line-height: 1.5;
}
.layout {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.75rem;
}
.header__brand {
display: flex;
align-items: center;
gap: 0.85rem;
}
.header__mark {
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
background: linear-gradient(135deg, var(--gold), var(--gold-dim));
color: #111;
font-size: 0.9rem;
box-shadow: var(--shadow);
}
.header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 650;
letter-spacing: -0.02em;
}
.header__subtitle {
margin: 0.1rem 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.header__status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
font-size: 0.85rem;
color: var(--muted);
}
.pulse {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 0 rgba(62, 207, 142, 0.5);
animation: pulse 2s infinite;
}
@keyframes pulse {
70% {
box-shadow: 0 0 0 8px rgba(62, 207, 142, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(62, 207, 142, 0);
}
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.85rem;
margin-bottom: 1.5rem;
}
.card {
padding: 1rem 1.1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.card__label {
margin: 0 0 0.35rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.card__value {
margin: 0;
font-size: 1rem;
font-weight: 600;
word-break: break-word;
}
.card__value--link {
color: var(--accent);
text-decoration: none;
}
.card__value--link:hover {
text-decoration: underline;
}
.panel {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.panel__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);
}
.panel__head h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.btn {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.4rem 0.85rem;
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.btn:hover {
border-color: var(--gold-dim);
color: var(--gold);
}
.btn--ghost {
background: transparent;
}
.detection-list {
list-style: none;
margin: 0;
padding: 0;
}
.detection-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem 1rem;
align-items: start;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
}
.detection-item:last-child {
border-bottom: none;
}
.detection-item:hover {
background: rgba(212, 175, 55, 0.05);
}
.detection-item__time {
margin: 0 0 0.35rem;
font-size: 0.85rem;
color: var(--gold);
font-weight: 600;
}
.detection-item__preview {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.detection-item__action {
align-self: center;
color: var(--muted);
font-size: 0.8rem;
}
.empty {
padding: 2.5rem 1.5rem;
text-align: center;
color: var(--muted);
}
.empty__hint {
margin-top: 0.5rem;
font-size: 0.9rem;
}
.hidden {
display: none !important;
}
.drawer {
position: fixed;
top: 0;
right: 0;
z-index: 20;
width: min(640px, 100vw);
height: 100vh;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow);
}
.drawer__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);
}
.drawer__head h2 {
margin: 0;
font-size: 1rem;
}
.drawer__meta {
margin: 0;
padding: 0.75rem 1.1rem;
font-size: 0.85rem;
color: var(--muted);
border-bottom: 1px solid var(--border);
}
.drawer__preview {
flex: 1;
overflow: auto;
padding: 1rem;
background: #fff;
color: #111;
}
.drawer__preview iframe {
width: 100%;
min-height: 100%;
border: none;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 10;
background: rgba(0, 0, 0, 0.55);
}
@media (max-width: 640px) {
.header {
flex-direction: column;
align-items: flex-start;
}
.detection-item {
grid-template-columns: 1fr;
}
}