Serve static maintenance page with 503 when PocketBase is unreachable #59
No files matched your search
@@ -0,0 +1,73 @@
|
|||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the health cache behind the maintenance gate. The probe is a fake
|
||||||
|
/// that counts its calls, so no PocketBase is needed.
|
||||||
|
/// </summary>
|
||||||
|
public class PocketBaseHealthCacheTests
|
||||||
|
{
|
||||||
|
private readonly SteppingClock _clock = new(new DateTimeOffset(2026, 10, 1, 10, 0, 0, TimeSpan.Zero));
|
||||||
|
private int _probes;
|
||||||
|
|
||||||
|
private Func<CancellationToken, Task<bool>> Probe(bool healthy) =>
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
_probes++;
|
||||||
|
return Task.FromResult(healthy);
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Result_is_reused_within_the_lifetime()
|
||||||
|
{
|
||||||
|
var cache = new PocketBaseHealthCache(_clock);
|
||||||
|
|
||||||
|
await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||||
|
_clock.Now += PocketBaseHealthCache.Lifetime - TimeSpan.FromSeconds(1);
|
||||||
|
var healthy = await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||||
|
|
||||||
|
healthy.ShouldBeTrue();
|
||||||
|
_probes.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stale_result_is_probed_again()
|
||||||
|
{
|
||||||
|
// The way back after an outage: once the lifetime is over, the new answer
|
||||||
|
// replaces the old one.
|
||||||
|
var cache = new PocketBaseHealthCache(_clock);
|
||||||
|
|
||||||
|
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||||
|
_clock.Now += PocketBaseHealthCache.Lifetime;
|
||||||
|
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||||
|
|
||||||
|
healthy.ShouldBeTrue();
|
||||||
|
_probes.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unhealthy_result_is_cached_too()
|
||||||
|
{
|
||||||
|
// While PocketBase is down, not every request should wait for a failing probe.
|
||||||
|
var cache = new PocketBaseHealthCache(_clock);
|
||||||
|
|
||||||
|
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||||
|
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||||
|
|
||||||
|
healthy.ShouldBeFalse();
|
||||||
|
_probes.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A clock the test moves forward by hand, to step past the cache lifetime without
|
||||||
|
/// waiting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="start">The moment the clock shows at first.</param>
|
||||||
|
private sealed class SteppingClock(DateTimeOffset start) : TimeProvider
|
||||||
|
{
|
||||||
|
public DateTimeOffset Now { get; set; } = start;
|
||||||
|
|
||||||
|
public override DateTimeOffset GetUtcNow() => Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,8 +308,64 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
|
||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||||
|
(await response.Content.ReadAsStringAsync()).ShouldBe("PocketBase unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/")]
|
||||||
|
[InlineData("/board")]
|
||||||
|
[InlineData("/posts/new-board")]
|
||||||
|
public async Task Page_shows_maintenance_page_when_PocketBase_is_unreachable(string route)
|
||||||
|
{
|
||||||
|
// The maintenance gate: with PocketBase down, a page is not rendered half-empty
|
||||||
|
// but answered with the static maintenance.html, a 503 and a hint when to retry.
|
||||||
|
await using var factory = UnreachablePocketBase();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||||
|
response.Headers.RetryAfter.ShouldNotBeNull();
|
||||||
|
(await response.Content.ReadAsStringAsync()).ShouldContain("Die Seite ist gerade nicht erreichbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Calendar_feed_stays_an_empty_calendar_when_PocketBase_is_unreachable()
|
||||||
|
{
|
||||||
|
// Not a page, so the gate lets it through: subscribed calendar apps keep
|
||||||
|
// getting a valid (empty) calendar instead of an HTML page.
|
||||||
|
await using var factory = UnreachablePocketBase();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(new Uri("/events.ics", UriKind.Relative));
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
(await response.Content.ReadAsStringAsync()).ShouldContain("BEGIN:VCALENDAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Maintenance_page_and_its_font_stay_reachable_when_PocketBase_is_unreachable()
|
||||||
|
{
|
||||||
|
// Static files are not gated: the maintenance page loads its self-hosted font
|
||||||
|
// from /fonts while PocketBase is down.
|
||||||
|
await using var factory = UnreachablePocketBase();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var page = await client.GetAsync(new Uri("/maintenance.html", UriKind.Relative));
|
||||||
|
var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
|
||||||
|
|
||||||
|
page.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
font.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A throwaway app pointed at a dead address: nothing listens on port 1, so every
|
||||||
|
/// PocketBase call is refused at once, without a timeout wait.
|
||||||
|
/// </summary>
|
||||||
|
private WebApplicationFactory<Program> UnreachablePocketBase() =>
|
||||||
|
_baseFactory.WithWebHostBuilder(builder =>
|
||||||
|
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Home_hero_takes_title_and_buttons_from_the_body()
|
public async Task Home_hero_takes_title_and_buttons_from_the_body()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ public partial class Home
|
|||||||
private HttpContext? HttpContext { get; set; }
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
// Heading, intro and buttons come from the "home" page's body. Until it is loaded
|
// 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
|
// -- and for good if PocketBase fails during this request -- the static fallback
|
||||||
// the start page always has a title.
|
// heading stands, so the start page always has a title.
|
||||||
private HomeIntro _intro = HomeIntro.Fallback;
|
private HomeIntro _intro = HomeIntro.Fallback;
|
||||||
|
|
||||||
// The hero, the tiles and the next-event card are fixed structure of the start
|
// 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
|
// 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
|
// page loses only the card, without pages only text and tiles. A PocketBase that
|
||||||
// start page stays a 200 rather than a 503 -- it is degraded, not broken.
|
// 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)
|
private async Task LoadPagesAsync(CancellationToken token)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using Elternbeirat.Contracts;
|
|||||||
using Elternbeirat.PocketBase;
|
using Elternbeirat.PocketBase;
|
||||||
using Elternbeirat.Web.Components;
|
using Elternbeirat.Web.Components;
|
||||||
using Elternbeirat.Web.Features.Events;
|
using Elternbeirat.Web.Features.Events;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Microsoft.AspNetCore.Components.Endpoints;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -25,6 +27,10 @@ builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
|||||||
client.Timeout = TimeSpan.FromSeconds(5);
|
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();
|
var app = builder.Build();
|
||||||
|
|
||||||
// NPM sits in front of the app and handles HTTPS. Without this line the app would
|
// 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.
|
// is down (a 503) it would wrongly show "not found" instead of our own message.
|
||||||
app.UseAntiforgery();
|
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.MapStaticAssets();
|
||||||
app.MapRazorComponents<App>();
|
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>
|
||||||
@@ -128,6 +128,31 @@ development and the tests and are **not** used on Unraid — see `entwicklung.md
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Outage: PocketBase unreachable
|
||||||
|
|
||||||
|
If `eb-pocketbase` is down while `eb-blazor` runs, the app does not render
|
||||||
|
half-empty pages (the menu comes from PocketBase too). A gate in `Program.cs`
|
||||||
|
answers every **page** request with the static `wwwroot/maintenance.html`:
|
||||||
|
|
||||||
|
| Request | Answer while PocketBase is down |
|
||||||
|
|---|---|
|
||||||
|
| Any page (`/`, `/board`, `/posts/…`, unknown slugs) | **503**, `Retry-After: 60`, maintenance page |
|
||||||
|
| `/health` | **503** `PocketBase unreachable` (for Uptime Kuma) |
|
||||||
|
| `/events.ics` | 200 with an empty calendar, so subscriptions keep working |
|
||||||
|
| `/events/{id}.ics` | 503 |
|
||||||
|
| Static files (CSS, fonts, PDFs, `/maintenance.html`) | served as usual |
|
||||||
|
|
||||||
|
The gate caches the health check for **10 seconds** (`PocketBaseHealthCache`). So
|
||||||
|
after PocketBase stops, pages can render for up to 10 s more (the components
|
||||||
|
degrade on their own in that window: empty menu, a short note, 503), and after it
|
||||||
|
starts again the maintenance page can stay up for up to 10 s. No restart of the
|
||||||
|
web app is needed — the site comes back by itself.
|
||||||
|
|
||||||
|
`maintenance.html` carries its styles inline, as a copy of the tokens from
|
||||||
|
`app.css`. When colours change there, copy them over.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
`scripts/release.sh` additionally tags each release with the short commit SHA,
|
`scripts/release.sh` additionally tags each release with the short commit SHA,
|
||||||
|
|||||||
Reference in new issue
Block a user