Serve static maintenance page with 503 when PocketBase is unreachable

This commit is contained in:
tleininger committed 2026-10-01 17:12:00 +02:00
1 parent ed6aa5d5ec
commit 7e56e83953
7 files changed
+500 -4

No files matched your search

+8 -4
View File
@@ -35,8 +35,8 @@ public partial class Home
private HttpContext? HttpContext { get; set; }
// Heading, intro and buttons come from the "home" page's body. Until it is loaded
// -- and for good if PocketBase is down -- the static fallback heading stands, so
// the start page always has a title.
// -- and for good if PocketBase fails during this request -- the static fallback
// heading stands, so the start page always has a title.
private HomeIntro _intro = HomeIntro.Fallback;
// The hero, the tiles and the next-event card are fixed structure of the start
@@ -53,8 +53,12 @@ public partial class Home
}
// Pages and events load side by side and fail on their own: without events the
// page loses only the card, without pages only text and tiles. Either way the
// start page stays a 200 rather than a 503 -- it is degraded, not broken.
// page loses only the card, without pages only text and tiles. A PocketBase that
// is plainly down never gets this far -- the maintenance gate in Program.cs
// answers with maintenance.html first. These catches are the second line: the
// gate's health result is cached for a few seconds, so PocketBase can fail between
// that check and these reads. Without them that would be a 500; with them the
// start page stays a 200 rather than a 503 -- degraded, not broken, and rare.
private async Task LoadPagesAsync(CancellationToken token)
{
try
+37
View File
@@ -2,6 +2,8 @@ using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Elternbeirat.Web.Components;
using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components.Endpoints;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
@@ -25,6 +27,10 @@ builder.Services.AddHttpClient<PocketBaseClient>(client =>
client.Timeout = TimeSpan.FromSeconds(5);
});
// Shared by all requests, so the maintenance gate below asks PocketBase at most once
// every few seconds rather than on every page view.
builder.Services.AddSingleton<PocketBaseHealthCache>();
var app = builder.Build();
// NPM sits in front of the app and handles HTTPS. Without this line the app would
@@ -48,6 +54,37 @@ if (app.Environment.IsDevelopment() is false)
// is down (a 503) it would wrongly show "not found" instead of our own message.
app.UseAntiforgery();
// Maintenance gate: while PocketBase is down, every page gets the static
// maintenance.html with 503 instead of a half-empty page (the menu comes from
// PocketBase too). Routing has already picked the endpoint at this point, so the gate
// covers exactly the Razor component pages: /health, the calendar feeds, the static
// assets (incl. maintenance.html and its font) are not components and answer for
// themselves. A new endpoint is thus left alone unless it is a page. The health
// result is cached (PocketBaseHealthCache), so the page may come back a few seconds
// after PocketBase does. Retry-After tells well-behaved clients and crawlers when to
// come back; no-store keeps the 503 page out of any cache.
app.Use(async (context, next) =>
{
var isPage = context.GetEndpoint()?.Metadata.GetMetadata<ComponentTypeMetadata>() is not null;
var services = context.RequestServices;
if (!isPage
|| await services.GetRequiredService<PocketBaseHealthCache>().IsHealthyAsync(
services.GetRequiredService<PocketBaseClient>().IsHealthyAsync,
context.RequestAborted))
{
await next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.Headers.RetryAfter = "60";
context.Response.Headers.CacheControl = "no-store";
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(
app.Environment.WebRootFileProvider.GetFileInfo("maintenance.html"),
context.RequestAborted);
});
app.MapStaticAssets();
app.MapRazorComponents<App>();
@@ -0,0 +1,73 @@
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Remembers for a few seconds whether PocketBase answered its health check, so the
/// maintenance gate does not probe PocketBase on every page request.
/// </summary>
/// <param name="time">
/// The clock that decides when a result is stale; injected so tests can step past
/// <see cref="Lifetime"/> without waiting.
/// </param>
/// <remarks>
/// The gate in <c>Program.cs</c> asks this cache before rendering any page and serves
/// the static <c>maintenance.html</c> with 503 while the answer is "unhealthy". A
/// failed check is kept just as long as a passed one: while PocketBase is down every
/// probe would otherwise wait for the connection to fail again.
/// <para>
/// The price of caching is a short blind spot in both directions: after
/// PocketBase fails, pages may still render for up to <see cref="Lifetime"/> (the
/// components' own fallbacks cover that), and after it recovers the maintenance
/// page may stay up for as long.
/// </para>
/// <para>
/// Registered as a singleton. No lock: two requests that find the result stale at
/// the same moment both probe, and the later answer wins -- harmless, and cheaper
/// than making every request wait on one another.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var healthy = await cache.IsHealthyAsync(pocketBase.IsHealthyAsync, context.RequestAborted);
/// </code>
/// </example>
/// <seealso cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>
public sealed class PocketBaseHealthCache(TimeProvider time)
{
/// <summary>
/// How long a health check result is reused before PocketBase is asked again.
/// </summary>
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(10);
// One reference, swapped as a whole, so a reader never sees the result of one
// check paired with the time of another.
private volatile Check? _last;
/// <summary>
/// Returns the last known health of PocketBase, probing again once it is older
/// than <see cref="Lifetime"/>.
/// </summary>
/// <param name="probe">
/// The actual check, usually <see cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>.
/// Passed in rather than injected because the typed client is transient and this
/// cache is a singleton.
/// </param>
/// <param name="token">Cancels the probe, e.g. when the visitor goes away.</param>
/// <returns>
/// <see langword="true"/> if PocketBase answered its last check (or answers this
/// one), otherwise <see langword="false"/>.
/// </returns>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was cancelled during a probe; nothing is cached then.
/// </exception>
public async Task<bool> IsHealthyAsync(Func<CancellationToken, Task<bool>> probe, CancellationToken token) =>
_last is { } last && time.GetUtcNow() - last.At < Lifetime
? last.Healthy
: (_last = new Check(await probe(token), time.GetUtcNow())).Healthy;
/// <summary>
/// One health check result and when it was taken.
/// </summary>
/// <param name="Healthy">Whether PocketBase answered.</param>
/// <param name="At">When the answer came in.</param>
private sealed record Check(bool Healthy, DateTimeOffset At);
}
+228
View File
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<!--
Static maintenance page shown when PocketBase is unreachable. The gate in Program.cs
serves this file with status 503 before any component renders, so it must stand on
its own: no menu, no PocketBase, no script, and its styles are inlined rather than
linked to the fingerprinted app.css (whose hashed name this static file cannot know).
The tokens below are a copy of the ones this page needs from app.css, light and dark,
under the same names -- when a colour changes there, change it here too. The header
band and the signet mirror MainLayout and Signet.razor, so the page still looks like
the site. The font is our own self-hosted Nunito; static files stay reachable while
PocketBase is down, the gate only covers pages.
-->
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="robots" content="noindex"/>
<title>Elternbeirat der IGMH – gerade nicht erreichbar</title>
<style>
:root {
--color-brand: #2b6f9c;
--color-brand-dark: #1f5477;
--color-header: #2a77a6;
--color-on-header: #fff;
--color-accent-dark: #4f9247;
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #3a7a3e 100%);
--color-focus: #1f5477;
--color-heading: #2b6f9c;
--color-text: #222;
--color-text-soft: #555;
--color-bg: #fff;
--color-page: #f4f6f4;
--color-border: #e0e0e0;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2rem;
--radius: 12px;
--shadow: 0 1px 3px rgba(31, 58, 95, 0.08), 0 4px 12px rgba(31, 58, 95, 0.06);
--font-family: "Nunito", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-size-body: 1.0625rem;
--font-weight-heading: 800;
--letter-spacing-heading: -0.015em;
}
@media (prefers-color-scheme: dark) {
:root {
--color-brand: #7cc0ec;
--color-brand-dark: #a3d5f4;
--color-header: #266f9c;
--color-accent-dark: #86c97d;
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #2f6b33 100%);
--color-focus: #a3d5f4;
--color-heading: #c9ced6;
--color-text: #a2a8b2;
--color-text-soft: #a2a8b2;
--color-bg: #222834;
--color-page: #171b22;
--color-border: #333b47;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
/* Absolute path: this file is served for any page URL, e.g. /posts/new-board,
where a relative "fonts/..." would point into /posts/. */
@font-face {
font-family: "Nunito";
src: url("/fonts/nunito-latin-wght.woff2") format("woff2");
font-weight: 400 800;
font-style: normal;
font-display: swap;
}
html {
font-family: var(--font-family);
line-height: 1.65;
color-scheme: light dark;
}
body {
margin: 0;
min-height: 100dvh;
background: var(--color-page);
color: var(--color-text);
font-size: var(--font-size-body);
}
/* The header band of MainLayout without the menu: there is nothing to
navigate to while the pages are down. */
header {
background: var(--gradient-brand);
box-shadow: var(--shadow);
}
.header-inner {
max-width: 72rem;
margin: 0 auto;
padding: var(--space-3) 1.25rem;
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-on-header);
}
.signet {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
}
.signet-bubble {
fill: var(--color-on-header);
}
.signet-figures {
fill: var(--color-accent-dark);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.brand-name {
font-size: 1.35rem;
font-weight: var(--font-weight-heading);
letter-spacing: var(--letter-spacing-heading);
}
.brand-school {
font-size: 0.85rem;
font-weight: 600;
}
main {
box-sizing: border-box;
max-width: 36rem;
margin: var(--space-5) auto;
padding: 0 1.25rem;
}
.card {
background: var(--color-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: var(--space-5);
}
h1 {
margin: 0 0 var(--space-3);
color: var(--color-heading);
font-size: 1.5rem;
line-height: 1.25;
font-weight: var(--font-weight-heading);
letter-spacing: var(--letter-spacing-heading);
}
p {
margin: 0;
}
a {
color: var(--color-brand);
font-weight: 600;
text-underline-offset: 2px;
}
a:hover {
color: var(--color-brand-dark);
}
a:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
border-radius: 2px;
}
.contact {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
color: var(--color-text-soft);
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<!-- Not a link: the start page is down as well, it would only lead here. -->
<svg class="signet" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
<path class="signet-bubble"
d="M8 4h24a6 6 0 0 1 6 6v15a6 6 0 0 1-6 6H18l-8 6v-6H8a6 6 0 0 1-6-6V10a6 6 0 0 1 6-6z"/>
<g class="signet-figures">
<circle cx="15" cy="12" r="3.5"/>
<path d="M9 26c0-5 2.7-8 6-8s6 3 6 8z"/>
<circle cx="26" cy="16" r="2.6"/>
<path d="M21.5 26c0-3.8 2-6 4.5-6s4.5 2.2 4.5 6z"/>
</g>
</svg>
<span class="brand-text">
<span class="brand-name">Elternbeirat</span>
<span class="brand-school">IGMH Mannheim</span>
</span>
</div>
</header>
<main>
<div class="card">
<h1>Die Seite ist gerade nicht erreichbar</h1>
<p>
Wir bitten um Entschuldigung. Die Website des Elternbeirats der IGMH ist im
Moment nicht verfügbar. Wir arbeiten daran, bitte versuchen Sie es in Kürze
noch einmal.
</p>
<p class="contact">
In dringenden Fällen erreichen Sie den Elternbeirat per E-Mail:<br/>
<a href="mailto:kontakt@elternbeirat-igmh.info">kontakt@elternbeirat-igmh.info</a>
</p>
</div>
</main>
</body>
</html>