Commit: 4d878be
Parent: 2761980

Store element screenshots with detections and show them in the View drawer.

Mårten Åsberg committed on 2026-06-22 at 21:35
Capture a PNG of the monitored selector on change, persist it in SQLite, and replace the HTML preview with a screenshot plus copyable raw HTML.

Co-authored-by: Cursor <cursoragent@cursor.com>
CheckMonitoringJob.cs +2 -0
diff --git a/CheckMonitoringJob.cs b/CheckMonitoringJob.cs
index 21f519c..d10a931 100644
@@ -80,9 +80,11 @@ internal sealed class CheckMonitoringJob(
}
logger.LogHtmlUpdated(monitor.Id);
var screenshot = await resultsElement.ScreenshotAsync(new() { Type = ScreenshotType.Png });
await repository.InsertNewDetection(
monitor.Id,
currentHtml,
screenshot,
timeProvider.GetUtcNow(),
context.CancellationToken
);
ScreeningRepository.cs +33 -4
diff --git a/ScreeningRepository.cs b/ScreeningRepository.cs
index e98570c..5390b42 100644
@@ -26,7 +26,13 @@ internal sealed record MonitoringSummary(
DateTimeOffset? LatestDetectionAt
);
internal sealed record Detection(int Id, int MonitorId, string Html, DateTimeOffset DetectedAt);
internal sealed record Detection(
int Id,
int MonitorId,
string Html,
DateTimeOffset DetectedAt,
byte[]? Screenshot = null
);
internal sealed class ScreeningRepository
{
@@ -71,6 +77,7 @@ internal sealed class ScreeningRepository
);
EnsureMonitorIdColumn(connection);
EnsureScreenshotColumn(connection);
EnsurePausedAtColumn(connection);
EnsurePhoneNumbersColumn(connection);
SeedAppSettings(connection);
@@ -112,6 +119,16 @@ internal sealed class ScreeningRepository
Execute(connection, "ALTER TABLE DetectedHtml ADD COLUMN MonitorId INTEGER");
}
private static void EnsureScreenshotColumn(SqliteConnection connection)
{
if (ColumnExists(connection, "DetectedHtml", "Screenshot"))
{
return;
}
Execute(connection, "ALTER TABLE DetectedHtml ADD COLUMN Screenshot BLOB");
}
private static void EnsurePausedAtColumn(SqliteConnection connection)
{
if (ColumnExists(connection, "Monitorings", "PausedAt"))
@@ -479,11 +496,13 @@ internal sealed class ScreeningRepository
public async Task InsertNewDetection(
int monitorId,
string html,
byte[] screenshot,
DateTimeOffset detectedAt,
CancellationToken cancellationToken = default
)
{
const string sql = "INSERT INTO DetectedHtml (MonitorId, Html, DetectedAt) VALUES ($monitorId, $html, $at)";
const string sql =
"INSERT INTO DetectedHtml (MonitorId, Html, Screenshot, DetectedAt) VALUES ($monitorId, $html, $screenshot, $at)";
using var activity = Tracing.StartInsertNewDetection(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
@@ -491,6 +510,7 @@ internal sealed class ScreeningRepository
command.CommandText = sql;
command.Parameters.AddWithValue("$monitorId", monitorId);
command.Parameters.AddWithValue("$html", html);
command.Parameters.AddWithValue("$screenshot", screenshot);
command.Parameters.AddWithValue("$at", detectedAt.ToString("O"));
await command.ExecuteNonQueryAsync(cancellationToken);
}
@@ -539,7 +559,7 @@ internal sealed class ScreeningRepository
public async Task<Detection?> GetDetectionByIdAsync(int id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT Id, MonitorId, Html, DetectedAt FROM DetectedHtml WHERE Id = $id";
const string sql = "SELECT Id, MonitorId, Html, DetectedAt, Screenshot FROM DetectedHtml WHERE Id = $id";
using var activity = Tracing.StartGetDetectionById(sql);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
@@ -548,7 +568,7 @@ internal sealed class ScreeningRepository
command.Parameters.AddWithValue("$id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken) ? ReadDetection(reader) : null;
return await reader.ReadAsync(cancellationToken) ? ReadDetectionWithScreenshot(reader) : null;
}
public async Task<int> CountRunningMonitoringsAsync(CancellationToken cancellationToken = default)
@@ -645,4 +665,13 @@ internal sealed class ScreeningRepository
private static Detection ReadDetection(SqliteDataReader reader) =>
new(reader.GetInt32(0), reader.GetInt32(1), reader.GetString(2), DateTimeOffset.Parse(reader.GetString(3)));
private static Detection ReadDetectionWithScreenshot(SqliteDataReader reader) =>
new(
reader.GetInt32(0),
reader.GetInt32(1),
reader.GetString(2),
DateTimeOffset.Parse(reader.GetString(3)),
reader.IsDBNull(4) ? null : reader.GetFieldValue<byte[]>(4)
);
}
wwwroot/app.js +44 -4
diff --git a/wwwroot/app.js b/wwwroot/app.js
index a753417..bc23e2c 100644
@@ -343,10 +343,50 @@ async function openDetection(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);
if (detection.screenshot) {
const img = document.createElement("img");
img.className = "drawer__screenshot";
img.alt = "Screenshot of monitored element";
img.src = `data:image/png;base64,${detection.screenshot}`;
drawerPreview.appendChild(img);
}
const codeSection = document.createElement("section");
codeSection.className = "drawer__code";
const codeHead = document.createElement("div");
codeHead.className = "drawer__code-head";
codeHead.innerHTML = "<span>Recorded HTML</span>";
const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.className = "btn btn--ghost btn--small";
copyBtn.textContent = "Copy";
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(detection.html);
copyBtn.textContent = "Copied";
setTimeout(() => {
copyBtn.textContent = "Copy";
}, 1500);
} catch {
copyBtn.textContent = "Failed";
setTimeout(() => {
copyBtn.textContent = "Copy";
}, 1500);
}
});
codeHead.appendChild(copyBtn);
const pre = document.createElement("pre");
const code = document.createElement("code");
code.textContent = detection.html;
pre.appendChild(code);
codeSection.appendChild(codeHead);
codeSection.appendChild(pre);
drawerPreview.appendChild(codeSection);
drawer.classList.remove("hidden");
drawerBackdrop.classList.remove("hidden");
wwwroot/styles.css +46 -7
diff --git a/wwwroot/styles.css b/wwwroot/styles.css
index ad6977d..39090aa 100644
@@ -547,16 +547,55 @@ body {
.drawer__preview {
flex: 1;
overflow: auto;
padding: 1rem;
background: #fff;
color: #111;
overflow: hidden auto;
color: var(--text);
display: grid;
gap: 1rem;
align-content: start;
}
.drawer__preview iframe {
.drawer__screenshot {
display: block;
width: 100%;
min-height: 100%;
border: none;
height: auto;
}
.drawer__code {
display: grid;
gap: 0.5rem;
}
.drawer__code-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--muted);
}
.drawer__code pre {
margin: 0;
padding: 0.85rem;
overflow: auto;
max-height: 40vh;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
font-size: 0.78rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.drawer__code code {
font-family: "Cascadia Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.btn--small {
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
}
.backdrop {