From 1cb7c38a86def30414cf22602a2dd500c779bd8c Mon Sep 17 00:00:00 2001 From: Wjouter <34310151+Wjouter@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:44:43 +0100 Subject: [PATCH] feat(admin): check a calendar now POST /api/admin/calendars/{id}/check pulls next_check_at forward to now, so the normal dispatcher runs the check within a second (respecting the host budget). A calendar with a live lease is mid-check and gets a 409 check_running; an expired lease from a crashed check doesn't block. The admin calendars page gets a "Check now" button per calendar. Co-Authored-By: Claude Opus 5.5 --- backend/InTheKnow.Tests/ApiTests.cs | 28 +++++++++++++++++++ backend/InTheKnow/Api.cs | 19 +++++++++++++ frontend/src/lib/codes.ts | 4 ++- frontend/src/lib/i18n/en.ts | 5 +++- frontend/src/lib/i18n/nl.ts | 5 +++- .../routes/admin/calendars/+page.server.ts | 6 +++- .../src/routes/admin/calendars/+page.svelte | 15 +++++++++- 7 files changed, 77 insertions(+), 5 deletions(-) diff --git a/backend/InTheKnow.Tests/ApiTests.cs b/backend/InTheKnow.Tests/ApiTests.cs index a28120f..8cf67e0 100644 --- a/backend/InTheKnow.Tests/ApiTests.cs +++ b/backend/InTheKnow.Tests/ApiTests.cs @@ -287,6 +287,34 @@ public async Task OperatorOverview() Assert.DoesNotContain("https://", (await op.JsonAsync("/api/admin/calendars")).ToJsonString()); } + // Admin "check now": pulls next_check_at forward to now, unless a live lease says a check is running. + [Fact] + public async Task CheckNow() + { + await using var h = await ApiHost.CreateAsync(); + var op = await h.SignInAsync(admin: true, secondFactor: true); + var s1 = await TestHost.AccountAsync(h.Db.DataSource); + var now = h.Time.GetUtcNow(); + var later = (await h.Db.SeedCalendarAsync("a.example", ["https://a.example/1"], s1, now.AddHours(1))).CalendarId; + var overdue = (await h.Db.SeedCalendarAsync("b.example", ["https://b.example/1"], s1, now.AddMinutes(-5))).CalendarId; + var running = (await h.Db.SeedCalendarAsync("c.example", ["https://c.example/1"], s1, now.AddMinutes(4))).CalendarId; + await h.Db.ExecAsync("UPDATE calendar SET lease_id = gen_random_uuid() WHERE id = $1", running); + Task Check(long id) => op.Client.PostAsync($"/api/admin/calendars/{id}/check", null, Ct); + async Task NextCheck(long id) => new((DateTime)(await h.Db.RowsAsync("SELECT next_check_at FROM calendar WHERE id = $1", id)).Single()[0]!); + + Assert.Equal(HttpStatusCode.Accepted, (await Check(later)).StatusCode); + Assert.Equal(now, await NextCheck(later)); + Assert.Equal(HttpStatusCode.Accepted, (await Check(overdue)).StatusCode); + Assert.Equal(now.AddMinutes(-5), await NextCheck(overdue)); // keeps its place in the queue + var r = await Check(running); + Assert.Equal((HttpStatusCode.Conflict, "check_running"), (r.StatusCode, await ReasonAsync(r))); + Assert.Equal(now.AddMinutes(4), await NextCheck(running)); + h.Time.Advance(TimeSpan.FromMinutes(5)); // the lease ran out: a crashed check no longer blocks + Assert.Equal(HttpStatusCode.Accepted, (await Check(running)).StatusCode); + var missing = await Check(long.MaxValue); + Assert.Equal((HttpStatusCode.NotFound, "not_found"), (missing.StatusCode, await ReasonAsync(missing))); + } + // Quickstart §4: 5,000 calendars on one domain, a baseline pass and an unchanged pass, then API reads. // Logs timings rather than asserting them. Never runs in the normal gate. [Fact(Explicit = true)] diff --git a/backend/InTheKnow/Api.cs b/backend/InTheKnow/Api.cs index 3aa1b4a..edc187e 100644 --- a/backend/InTheKnow/Api.cs +++ b/backend/InTheKnow/Api.cs @@ -146,6 +146,25 @@ ORDER BY c.id LIMIT $5 } return Page(items, take); }); + + // Check now: makes the calendar due, so the dispatcher runs a normal check within a second (host budget permitting). + // A calendar whose lease is still live is mid-check; pulling it forward would let a second worker claim it. + Admin(app).MapPost("/calendars/{id:long}/check", async (long id, NpgsqlDataSource db, TimeProvider time, CancellationToken ct) => + { + await using var cmd = db.CreateCommand(""" + UPDATE calendar SET next_check_at = CASE WHEN lease_id IS NULL OR next_check_at <= $2 THEN least(next_check_at, $2) ELSE next_check_at END + WHERE id = $1 + RETURNING lease_id IS NULL OR next_check_at <= $2 + """); + cmd.Parameters.Add(new() { Value = id }); + cmd.Parameters.Add(new() { Value = time.GetUtcNow() }); + return await cmd.ExecuteScalarAsync(ct) switch + { + null => Problem(404, "not_found"), + false => Problem(409, "check_running"), + _ => Results.StatusCode(202), + }; + }); } internal static IResult Problem(int status, string reason, Dictionary? extra = null) => diff --git a/frontend/src/lib/codes.ts b/frontend/src/lib/codes.ts index e4ef50d..3753bd5 100644 --- a/frontend/src/lib/codes.ts +++ b/frontend/src/lib/codes.ts @@ -89,7 +89,9 @@ export const reasons = [ // 005 additions 'invalid_order', 'invalid_cursor', - 'invalid_home_page' + 'invalid_home_page', + // admin check now + 'check_running' ] as const; export type Reason = (typeof reasons)[number]; diff --git a/frontend/src/lib/i18n/en.ts b/frontend/src/lib/i18n/en.ts index 4a897b5..97bf075 100644 --- a/frontend/src/lib/i18n/en.ts +++ b/frontend/src/lib/i18n/en.ts @@ -54,6 +54,7 @@ export const en = { host_budget_exhausted: 'The calendar server is busy.', invalid_limit: 'Something went wrong. Try again.', invalid_status: 'This can’t be done in the current state. Reload the page.', + check_running: 'A check of this calendar is running right now. Try again in a few minutes.', invalid_include_hidden: 'Something went wrong. Try again.', invalid_name: 'The name must be 1 to 100 characters.', invalid_rules: 'The rules aren’t valid.', @@ -662,7 +663,9 @@ export const en = { filter: 'Show', none: 'No calendars.', counts: (subscribers: number, valid: number, total: number) => - `${subscribers} ${subscribers === 1 ? 'account' : 'accounts'}, ${valid} of ${total} links working` + `${subscribers} ${subscribers === 1 ? 'account' : 'accounts'}, ${valid} of ${total} links working`, + checkNow: 'Check now', + checking: 'The check starts within a few seconds. Reload the page to see the result.' }, email: { tab: 'Email', diff --git a/frontend/src/lib/i18n/nl.ts b/frontend/src/lib/i18n/nl.ts index ac8f329..76aa2ae 100644 --- a/frontend/src/lib/i18n/nl.ts +++ b/frontend/src/lib/i18n/nl.ts @@ -54,6 +54,7 @@ export const nl: Dict = { host_budget_exhausted: 'De agendaserver is druk.', invalid_limit: 'Er ging iets mis. Probeer het opnieuw.', invalid_status: 'Dit kan niet in de huidige status. Laad de pagina opnieuw.', + check_running: 'Deze agenda wordt nu al gecontroleerd. Probeer het over een paar minuten opnieuw.', invalid_include_hidden: 'Er ging iets mis. Probeer het opnieuw.', invalid_name: 'De naam moet 1 tot 100 tekens lang zijn.', invalid_rules: 'De regels zijn niet geldig.', @@ -666,7 +667,9 @@ export const nl: Dict = { filter: 'Tonen', none: 'Geen agenda’s.', counts: (subscribers: number, valid: number, total: number) => - `${subscribers} ${subscribers === 1 ? 'account' : 'accounts'}, ${valid} van ${total} links werken` + `${subscribers} ${subscribers === 1 ? 'account' : 'accounts'}, ${valid} van ${total} links werken`, + checkNow: 'Nu controleren', + checking: 'De controle start binnen een paar seconden. Laad de pagina opnieuw voor het resultaat.' }, email: { tab: 'E-mail', diff --git a/frontend/src/routes/admin/calendars/+page.server.ts b/frontend/src/routes/admin/calendars/+page.server.ts index a776c26..333ec8a 100644 --- a/frontend/src/routes/admin/calendars/+page.server.ts +++ b/frontend/src/routes/admin/calendars/+page.server.ts @@ -1,5 +1,5 @@ import { api, must } from '$lib/server/api'; -import { after } from '$lib/server/admin'; +import { after, itemAction } from '$lib/server/admin'; import { checkFailureMessage } from '$lib/codes'; import { formatter } from '$lib/server/format'; @@ -48,3 +48,7 @@ export const load = async (event) => { })) }; }; + +export const actions = { + check: itemAction('calendarId', 'POST', (id) => `/api/admin/calendars/${id}/check`, 'checking') +}; diff --git a/frontend/src/routes/admin/calendars/+page.svelte b/frontend/src/routes/admin/calendars/+page.svelte index b54d2c2..4c9a65e 100644 --- a/frontend/src/routes/admin/calendars/+page.svelte +++ b/frontend/src/routes/admin/calendars/+page.svelte @@ -1,12 +1,18 @@