Serve static maintenance page with 503 when PocketBase is unreachable
This commit is contained in:
1 parent
ed6aa5d5ec
commit
7e56e83953
7 files changed
+500
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user