Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions backend/InTheKnow.Tests/ApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpResponseMessage> Check(long id) => op.Client.PostAsync($"/api/admin/calendars/{id}/check", null, Ct);
async Task<DateTimeOffset> 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)]
Expand Down
19 changes: 19 additions & 0 deletions backend/InTheKnow/Api.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?>? extra = null) =>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
5 changes: 4 additions & 1 deletion frontend/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -663,7 +664,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',
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/lib/i18n/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -667,7 +668,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',
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/routes/admin/calendars/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -48,3 +48,7 @@ export const load = async (event) => {
}))
};
};

export const actions = {
check: itemAction('calendarId', 'POST', (id) => `/api/admin/calendars/${id}/check`, 'checking')
};
15 changes: 14 additions & 1 deletion frontend/src/routes/admin/calendars/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Badge from '$lib/components/Badge.svelte';
import Button from '$lib/components/Button.svelte';
import Field from '$lib/components/Field.svelte';
import Toast from '$lib/components/Toast.svelte';
import { submitter } from '$lib/enhance.svelte';
import { dicts } from '$lib/i18n';
import { refusal } from '$lib/refusal';

let { data } = $props();
let { data, form } = $props();
const t = $derived(dicts[data.lang]);
const w = $derived(t.admin.calendars);
const s = submitter();
const r = $derived(refusal(form));
const nextHref = $derived(
`?${new URLSearchParams({ ...(data.status ? { status: data.status } : {}), ...(data.overdue ? { overdue: 'true' } : {}), after: String(data.next) })}`
);
Expand All @@ -15,6 +21,7 @@
<svelte:head><title>{w.tab} · InTheKnow</title></svelte:head>

<div class="stack">
{#if r}<p class="notice error" role="alert">{r.message}</p>{/if}
<form method="GET" class="row filters">
<Field label={w.status} id="status">
{#snippet children({ id })}
Expand Down Expand Up @@ -43,6 +50,10 @@
· {c.lastChecked ? `${t.calendars.lastChecked} ${c.lastChecked}` : t.calendars.states.notChecked}
· {t.calendars.nextCheck} {c.nextCheck}
</p>
<form method="POST" action="?/check" use:enhance={s.enhance}>
<input type="hidden" name="calendarId" value={String(c.id)} />
<Button size="sm" variant="secondary" disabled={s.pending}>{w.checkNow}</Button>
</form>
</li>
{/each}
</ul>
Expand All @@ -52,6 +63,8 @@
{#if data.next}<div><Button href={nextHref} variant="secondary">{t.admin.nextPage}</Button></div>{/if}
</div>

<Toast message={form && 'done' in form ? w.checking : null} />

<style>
.filters {
align-items: flex-end;
Expand Down
Loading