Fix/content page heading #36

Merged
Tom merged 13 commits from fix/content-page-heading into main 2026-09-30 23:18:44 +02:00
4 changed files with 110 additions and 28 deletions
Showing only changes of commit 666614a3b2 - Show all commits

No files matched your search

@@ -108,6 +108,43 @@ public sealed class PocketBaseClient(HttpClient httpClient)
public Task<IReadOnlyList<FaqTopic>> GetFaqTopicsAsync(CancellationToken token = default) public Task<IReadOnlyList<FaqTopic>> GetFaqTopicsAsync(CancellationToken token = default)
=> GetRecordsAsync<FaqTopic>("faq_topics", "order", token, expand: "faqs_via_topic"); => GetRecordsAsync<FaqTopic>("faq_topics", "order", token, expand: "faqs_via_topic");
/// <summary>
/// Checks whether PocketBase answers its health endpoint.
/// </summary>
/// <remarks>
/// Backs the app's own <c>/health</c> endpoint, which an external monitor (Uptime
/// Kuma) polls: the app answering at all proves the app is up, and this probe adds
/// whether the content source behind it is reachable, so one monitor covers both.
/// This calls <c>GET /api/health</c>, PocketBase's own liveness endpoint, which
/// needs no auth and touches no collection.
/// </remarks>
/// <param name="token">
/// A token to cancel the probe.
/// </param>
/// <returns>
/// <see langword="true"/> if PocketBase answered with a success status within the
/// client timeout; <see langword="false"/> if it was unreachable, timed out, or
/// answered with an error status.
/// </returns>
public async Task<bool> IsHealthyAsync(CancellationToken token = default)
{
try
{
using var response = await httpClient.GetAsync(
new Uri("/api/health", UriKind.Relative), token);
return response.IsSuccessStatusCode;
}
// Same failure shapes as a record read: unreachable (HttpRequestException) or the
// client's own timeout (a TaskCanceledException wrapping a TimeoutException). A
// plain TaskCanceledException is the caller's cancellation and propagates.
catch (Exception exception) when (
exception is HttpRequestException
or TaskCanceledException { InnerException: TimeoutException })
{
return false;
}
}
/// <summary> /// <summary>
/// Gets all public records of a collection in a single request. /// Gets all public records of a collection in a single request.
/// </summary> /// </summary>
+30 -1
View File
@@ -1,5 +1,4 @@
using System.Net; using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
namespace Elternbeirat.Web.Tests; namespace Elternbeirat.Web.Tests;
@@ -140,4 +139,34 @@ public sealed class RouteSmokeTests : IDisposable
html.ShouldContain("Förderverein"); // the page body, rendered from Markdown html.ShouldContain("Förderverein"); // the page body, rendered from Markdown
} }
[Fact]
public async Task Health_returns_200_when_PocketBase_is_reachable()
{
// The monitor's happy path: the app answers and PocketBase (the seeded
// fixture) is up, so /health is 200. This mostly guards the wiring -- that the
// endpoint exists and reaches the client -- since the fixture keeps PocketBase
// alive; the unreachable case is covered separately below.
var client = _factory.CreateClient();
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task Health_returns_503_when_PocketBase_is_unreachable()
{
// The case the monitor exists for: the app is up but PocketBase is not. Point a
// throwaway app at a dead address (nothing listens on port 1, so the probe is
// refused at once, no timeout wait) and assert /health reports 503 rather than
// claiming healthy.
await using var factory = _baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
var client = factory.CreateClient();
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
}
} }
+31 -21
View File
@@ -6,52 +6,50 @@ using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
// The typed client reads content from PocketBase over its REST API. The base URL // The typed client reads content from PocketBase over its REST API. The base URL
// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl // comes from config: appsettings for local dev, the PocketBase__BaseUrl env var
// env var on the server (see compose.yaml). // on the server (see compose.yaml).
var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"] var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured."); ?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
builder.Services.AddHttpClient<PocketBaseClient>(client => builder.Services.AddHttpClient<PocketBaseClient>(client =>
client.BaseAddress = new Uri(pocketBaseUrl)); {
client.BaseAddress = new Uri(pocketBaseUrl);
// Every page waits for PocketBase. The default wait is 100 seconds, so if
// PocketBase is down the page would hang that long. 5 seconds fails fast instead.
client.Timeout = TimeSpan.FromSeconds(5);
});
var app = builder.Build(); var app = builder.Build();
// NPM terminates TLS and is the only way to reach the container (no port // NPM sits in front of the app and handles HTTPS. Without this line the app would
// mapping in production). Without UseForwardedHeaders the app sees every request // think every request is plain HTTP and would see NPM's IP, not the visitor's.
// as HTTP and with the proxy IP instead of the client IP. // KnownNetworks/KnownProxies are left empty on purpose: only NPM reaches the app.
// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches
// the container.
app.UseForwardedHeaders( app.UseForwardedHeaders(
new ForwardedHeadersOptions new ForwardedHeadersOptions
{ {
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
}); });
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment() is false) if (app.Environment.IsDevelopment() is false)
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseExceptionHandler("/Error", createScopeForErrors: true);
// No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates // No UseHsts() and no UseHttpsRedirection() here: NPM already handles HTTPS.
// TLS. Both here would create a redirect loop behind the proxy. // Adding them behind NPM would send the browser into a redirect loop.
} }
// No UseStatusCodePagesWithReExecute: the Router renders the NotFoundPage // Don't add UseStatusCodePagesWithReExecute here. Blazor already shows its own
// (Pages.NotFound) in process for both an unmatched route and an explicit // not-found page. That middleware would catch *every* error code, so when PocketBase
// NavigationManager.NotFound() from a component (see Routes.razor). Re-executing // is down (a 503) it would wrongly show "not found" instead of our own message.
// every error code to /not-found would also turn a 503 (content source down) into
// a "not found" page; leaving it out lets those components keep their own
// "temporarily unavailable" markup and 503 status.
app.UseAntiforgery(); app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();
app.MapRazorComponents<App>(); app.MapRazorComponents<App>();
// Subscribable calendar feed of all events. A minimal API endpoint rather than // Calendar feed people can subscribe to. It returns calendar data, not a web page,
// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase // so it is a plain endpoint, not a Razor page. If PocketBase is down it returns an
// per request; an unreachable source yields an empty calendar, not a 500. // empty calendar rather than an error.
app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) => app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) =>
{ {
IReadOnlyList<Event> events; IReadOnlyList<Event> events;
@@ -67,4 +65,16 @@ app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8"); return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
}); });
// Status page for an outside monitor (Uptime Kuma) to check. One check tells all
// three states apart: no reply = the app is down, 503 = the app runs but PocketBase
// is down, 200 = both are fine. It talks to PocketBase directly, not through the
// page-rendering code, so it can still report when that code is the thing failing.
app.MapGet("/health", async (PocketBaseClient pocketBase, CancellationToken token) =>
await pocketBase.IsHealthyAsync(token)
? Results.Text("healthy", "text/plain; charset=utf-8")
: Results.Text(
"PocketBase unreachable",
"text/plain; charset=utf-8",
statusCode: StatusCodes.Status503ServiceUnavailable));
app.Run(); app.Run();
+12 -6
View File
@@ -3,8 +3,12 @@
# #
# The two `ports:` blocks below are TEMPORARY. The target state is NPM in front: # The two `ports:` blocks below are TEMPORARY. The target state is NPM in front:
# NPM terminates TLS and is the only path to the web app, and the editors reach # NPM terminates TLS and is the only path to the web app, and the editors reach
# PocketBase through an NPM subdomain -- then both port mappings go away (#9). While # PocketBase through an NPM subdomain (#9). NPM still needs a target address and
# NPM is not set up yet, the ports are the only way onto the site and the admin UI. # port, so a port stays reachable either way -- the plan is to reach the containers
# by name over a shared Docker network (e.g. eb-blazor:8080) instead of publishing
# them on the LAN, and to drop these host mappings that expose the site and admin UI
# to anyone on the LAN. While NPM is not set up yet, these mappings are the only way
# onto the site and the admin UI.
# #
# For local development use the dev overlay on top, which adds host ports and builds # For local development use the dev overlay on top, which adds host ports and builds
# the web image from source: # the web image from source:
@@ -31,8 +35,9 @@ services:
- /mnt/user/appdata/pocketbase/pb_data:/pb_data - /mnt/user/appdata/pocketbase/pb_data:/pb_data
ports: ports:
# TEMPORARY: exposes the admin UI on the LAN (http://<unraid>:8090/_/) while # TEMPORARY: exposes the admin UI on the LAN (http://<unraid>:8090/_/) while
# NPM is not in front yet. Remove once the editors reach PocketBase through an # NPM is not in front yet. Replace once the editors reach PocketBase through an
# NPM subdomain (#9) -- until then the admin has no other way in. # NPM subdomain (#9): NPM points at eb-pocketbase:8090 over a shared network, so
# this host mapping can go -- until then the admin has no other way in.
- "8090:8090" - "8090:8090"
healthcheck: healthcheck:
# The image ships this endpoint; the web app waits for it to pass. # The image ships this endpoint; the web app waits for it to pass.
@@ -55,6 +60,7 @@ services:
PocketBase__BaseUrl: http://eb-pocketbase:8090 PocketBase__BaseUrl: http://eb-pocketbase:8090
ports: ports:
# TEMPORARY: reach the site on the LAN (http://<unraid>:5000) while NPM is not # TEMPORARY: reach the site on the LAN (http://<unraid>:5000) while NPM is not
# in front yet. Replace with the external npm network once NPM terminates TLS # in front yet. Replace once NPM terminates TLS and fronts the site (#9): join
# and is the only path in (#9). # the external NPM network and let NPM point at eb-blazor:8080 by name, so this
# host mapping can go and the site is no longer open on the LAN.
- "5000:8080" - "5000:8080"