From e242eaf79a9e30ff9d3a454dd2a18096560b5396 Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 24 Sep 2026 13:00:14 +0200 Subject: [PATCH] Serve 503 when PocketBase is unavailable across all content pages --- Elternbeirat.PocketBase/PocketBaseClient.cs | 48 ++++++++-- .../PocketBaseUnavailableException.cs | 49 ++++++++++ .../Components/Layout/MainLayout.razor.cs | 23 +++-- .../Features/Embeds/EventsEmbed.razor | 17 ++-- .../Features/Embeds/EventsEmbed.razor.cs | 43 ++++++--- .../Features/Embeds/FaqsEmbed.razor | 17 ++-- .../Features/Embeds/FaqsEmbed.razor.cs | 32 +++++-- .../Features/Embeds/PostsEmbed.razor | 21 ++-- .../Features/Embeds/PostsEmbed.razor.cs | 32 ++++--- .../Features/Events/EventList.razor | 95 ++++++++++--------- .../Features/Events/EventList.razor.cs | 53 +++++++---- Elternbeirat.Web/Features/Faqs/FaqList.razor | 6 +- .../Features/Faqs/FaqList.razor.cs | 37 +++++--- Elternbeirat.Web/Features/Home/Home.razor.cs | 16 ++-- .../Features/Pages/ContentPage.razor | 10 +- .../Features/Pages/ContentPage.razor.cs | 37 +++++--- .../Features/Posts/PostDetail.razor | 22 +++-- .../Features/Posts/PostDetail.razor.cs | 48 ++++++---- .../Features/Posts/PostList.razor | 10 +- .../Features/Posts/PostList.razor.cs | 36 ++++--- Elternbeirat.Web/Program.cs | 29 +++--- Elternbeirat.Web/Shared/Cultures.cs | 21 ++++ Elternbeirat.Web/Shared/EventFormat.cs | 12 +-- Elternbeirat.Web/Shared/SourceUnavailable.cs | 30 ++++++ Elternbeirat.Web/_Imports.razor | 1 + 25 files changed, 492 insertions(+), 253 deletions(-) create mode 100644 Elternbeirat.PocketBase/PocketBaseUnavailableException.cs create mode 100644 Elternbeirat.Web/Shared/Cultures.cs create mode 100644 Elternbeirat.Web/Shared/SourceUnavailable.cs diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index 5438431..7b7189f 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -38,14 +38,11 @@ public sealed class PocketBaseClient(HttpClient httpClient) /// /// The public pages, or an empty list if there are none. /// - /// - /// PocketBase could not be reached or returned a non-success status code. - /// - /// - /// The response could not be deserialized. + /// + /// PocketBase could not be reached, timed out, or returned an unreadable response. /// /// - /// was canceled or the request timed out. + /// was canceled by the caller. /// public Task> GetPagesAsync(CancellationToken token = default) => GetRecordsAsync("pages", "order", token); @@ -73,15 +70,22 @@ public sealed class PocketBaseClient(HttpClient httpClient) => GetRecordsAsync("events", "start", token); /// - /// Gets all public FAQ entries, ordered by . + /// Gets all public FAQ entries, ordered by , then by + /// . /// + /// + /// The second sort key makes the order total: sorting by topic alone leaves + /// entries within a topic in an undefined order, so the same content could + /// render differently between requests. Adding the question as a tie-breaker + /// keeps the FAQ list and any teaser drawn from it stable. + /// /// /// /// The public FAQ entries, or an empty list if there are none. /// /// public Task> GetFaqsAsync(CancellationToken token = default) - => GetRecordsAsync("faqs", "topic", token); + => GetRecordsAsync("faqs", "topic,question", token); /// /// Gets all public records of a collection in a single request. @@ -101,6 +105,12 @@ public sealed class PocketBaseClient(HttpClient httpClient) /// /// The public records, or an empty list if there are none. /// + /// + /// PocketBase could not be reached, timed out, or returned an unreadable response. + /// + /// + /// was canceled by the caller. + /// private async Task> GetRecordsAsync( string collection, string sort, @@ -112,7 +122,25 @@ public sealed class PocketBaseClient(HttpClient httpClient) + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"&sort={Uri.EscapeDataString(sort)}"; - var result = await httpClient.GetFromJsonAsync>(url, token); - return result?.Items ?? []; + try + { + var result = await httpClient.GetFromJsonAsync>(url, token); + return result?.Items ?? []; + } + // These three mean PocketBase itself failed: it was unreachable + // (HttpRequestException), the request to it timed out (a TaskCanceledException + // whose inner exception is a TimeoutException, which is how HttpClient surfaces + // its own timeout), or it answered with something we could not read + // (JsonException). A plain TaskCanceledException with no inner TimeoutException + // is the caller's own cancellation via token and is deliberately not caught -- + // it propagates as OperationCanceledException so an aborted request stays an + // abort, not a fault. + catch (Exception exception) when ( + exception is HttpRequestException + or JsonException + or TaskCanceledException { InnerException: TimeoutException }) + { + throw new PocketBaseUnavailableException($"Could not read collection '{collection}'.", exception); + } } } diff --git a/Elternbeirat.PocketBase/PocketBaseUnavailableException.cs b/Elternbeirat.PocketBase/PocketBaseUnavailableException.cs new file mode 100644 index 0000000..28aae56 --- /dev/null +++ b/Elternbeirat.PocketBase/PocketBaseUnavailableException.cs @@ -0,0 +1,49 @@ +namespace Elternbeirat.PocketBase; + +/// +/// Thrown when PocketBase could not be reached, timed out, or returned a +/// response that could not be read. +/// +/// +/// This is the one failure the content pages need to distinguish: the site is +/// up but its data store is not, which is an HTTP 503, not a 404. Which +/// low-level exceptions mean that (a transport error, a timeout, unreadable +/// JSON) is 's concern; callers catch only this +/// type and need not know how the client talks to PocketBase. +/// +public sealed class PocketBaseUnavailableException : Exception +{ + /// + /// Initializes a new instance with a default message. + /// + public PocketBaseUnavailableException() + : base("PocketBase is unavailable.") + { + } + + /// + /// Initializes a new instance with the given message. + /// + /// + /// The message that describes the error. + /// + public PocketBaseUnavailableException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance with the given message and the underlying + /// failure that caused it. + /// + /// + /// The message that describes the error. + /// + /// + /// The transport, timeout, or deserialization exception that was caught. + /// + public PocketBaseUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs b/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs index 7edd795..c29e02e 100644 --- a/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs +++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs @@ -6,14 +6,19 @@ namespace Elternbeirat.Web.Components.Layout; public partial class MainLayout { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load navigation pages from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load navigation pages from PocketBase; the menu is empty.")] + private static partial void LogSourceUnavailable(ILogger logger, Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _header = []; private IReadOnlyList _footer = []; @@ -22,15 +27,15 @@ public partial class MainLayout { try { - var pages = await PocketBase.GetPagesAsync(); + var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default); _header = NavBuilder.Header(pages); _footer = NavBuilder.Footer(pages); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException ex) { - // Source unreachable -> render the chrome with an empty menu instead of - // a 500. The brand link still leads home. - LogLoadFailed(Logger, ex); + // Only the menu fails: render the chrome with an empty menu instead of a + // 500, so every page stays reachable. The brand link still leads home. + LogSourceUnavailable(Logger, ex); } } } diff --git a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor index 080ed5e..467b810 100644 --- a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor +++ b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor @@ -1,12 +1,7 @@ -
-

Kommende Termine

- - @if (_upcoming.Count == 0) - { -

Zurzeit sind keine Termine geplant.

- } - else - { +@if (_upcoming.Count > 0) +{ +
+

Kommende Termine

    @foreach (var ev in _upcoming) { @@ -21,5 +16,5 @@ }

Alle Termine

- } -
+
+} diff --git a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs index 5c42bcd..7d17223 100644 --- a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs +++ b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs @@ -6,14 +6,24 @@ namespace Elternbeirat.Web.Features.Embeds; public partial class EventsEmbed { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events for an embed from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + // Number of upcoming events shown in the teaser; the full list is one link away. + private const int TeaserCount = 3; + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load events for an embed from PocketBase; the teaser is omitted.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _upcoming = []; @@ -22,22 +32,25 @@ public partial class EventsEmbed IReadOnlyList events; try { - events = await PocketBase.GetEventsAsync(); + events = await PocketBase.GetEventsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show no teaser instead of a 500. - LogLoadFailed(Logger, ex); + // Only part of the page fails: omit the teaser, keep the page (and its + // 200) intact. A missing teaser is degraded, not broken. + LogSourceUnavailable(Logger, exception); return; } - // Teaser: the three soonest upcoming events, earliest first. An event - // earlier today still counts as upcoming. + // Teaser: the soonest upcoming events, earliest first. An event earlier + // today still counts as upcoming. var today = DateOnly.FromDateTime(DateTime.Today); - _upcoming = events - .Where(e => DateOnly.FromDateTime(e.Start) >= today) - .OrderBy(e => e.Start) - .Take(3) - .ToList(); + _upcoming = + [ + .. events + .Where(@event => DateOnly.FromDateTime(@event.Start) >= today) + .OrderBy(@event => @event.Start) + .Take(TeaserCount) + ]; } } diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor index 278e395..893d725 100644 --- a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor +++ b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor @@ -1,12 +1,7 @@ -
-

Häufige Fragen

- - @if (_faqs.Count == 0) - { -

Zurzeit gibt es keine Fragen.

- } - else - { +@if (_faqs.Count > 0) +{ +
+

Häufige Fragen

@foreach (var faq in _faqs) {
@@ -17,5 +12,5 @@
}

Alle Fragen

- } -
+
+} diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs index 5a90562..5d421ba 100644 --- a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs +++ b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs @@ -6,14 +6,24 @@ namespace Elternbeirat.Web.Features.Embeds; public partial class FaqsEmbed { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs for an embed from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + // Number of questions shown in the teaser; the full hub is one link away. + private const int TeaserCount = 3; + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load FAQs for an embed from PocketBase; the teaser is omitted.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _faqs = []; @@ -21,14 +31,16 @@ public partial class FaqsEmbed { try { - // Teaser: the first three questions, with a link to the full hub. - var faqs = await PocketBase.GetFaqsAsync(); - _faqs = [.. faqs.Take(3)]; + // GetFaqsAsync is ordered by topic, then question, so the first entries + // the teaser takes are stable across requests. + var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); + _faqs = [.. faqs.Take(TeaserCount)]; } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show no teaser instead of a 500. - LogLoadFailed(Logger, ex); + // Only part of the page fails: omit the teaser, keep the page (and its + // 200) intact. A missing teaser is degraded, not broken. + LogSourceUnavailable(Logger, exception); } } } diff --git a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor index a70df14..fccbcb1 100644 --- a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor +++ b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor @@ -1,23 +1,18 @@ -
-

Aktuelle Beiträge

- - @if (_latest.Count == 0) - { -

Zurzeit gibt es keine Beiträge.

- } - else - { +@if (_latest.Count > 0) +{ +
+

Aktuelle Beiträge

    @foreach (var post in _latest) {
  • @post.Title -
  • }

Alle Beiträge

- } -
+
+} diff --git a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs index 6468fc2..9bb2e0a 100644 --- a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs +++ b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Microsoft.AspNetCore.Components; @@ -7,16 +6,24 @@ namespace Elternbeirat.Web.Features.Embeds; public partial class PostsEmbed { - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + // Number of posts shown in the teaser; the full list is one link away. + private const int TeaserCount = 3; - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for an embed from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load posts for an embed from PocketBase; the teaser is omitted.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _latest = []; @@ -24,14 +31,15 @@ public partial class PostsEmbed { try { - // Teaser: the three most recent posts, with a link to the full list. - var posts = await PocketBase.GetPostsAsync(); - _latest = [.. posts.Take(3)]; + // GetPostsAsync returns newest first, so the first entries are the latest. + var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); + _latest = [.. posts.Take(TeaserCount)]; } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show no teaser instead of a 500. - LogLoadFailed(Logger, ex); + // Only part of the page fails: omit the teaser, keep the page (and its + // 200) intact. A missing teaser is degraded, not broken. + LogSourceUnavailable(Logger, exception); } } } diff --git a/Elternbeirat.Web/Features/Events/EventList.razor b/Elternbeirat.Web/Features/Events/EventList.razor index bf6af2e..f946897 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor +++ b/Elternbeirat.Web/Features/Events/EventList.razor @@ -4,53 +4,60 @@

Termine

-

- Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch - abonnieren und in Ihrer Kalender-App automatisch - aktuell halten. -

- -

Kommende Termine

- -@if (_upcoming.Count == 0) +@if (_unavailable) { -

Zurzeit sind keine Termine geplant.

+

Die Termine sind gerade nicht erreichbar. Bitte später erneut versuchen.

} else { -
    - @foreach (var ev in _upcoming) - { -
  • - - @ev.Title - @if (!string.IsNullOrWhiteSpace(ev.Location)) - { - @ev.Location - } - @if (!string.IsNullOrWhiteSpace(ev.Note)) - { -

    @ev.Note

    - } -
  • - } -
-} +

+ Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch + abonnieren und in Ihrer Kalender-App automatisch + aktuell halten. +

-@if (_past.Count > 0) -{ -

Vergangene Termine

-
    - @foreach (var ev in _past) - { -
  • - - @ev.Title - @if (!string.IsNullOrWhiteSpace(ev.Location)) - { - @ev.Location - } -
  • - } -
+

Kommende Termine

+ + @if (_upcoming.Count == 0) + { +

Zurzeit sind keine Termine geplant.

+ } + else + { +
    + @foreach (var ev in _upcoming) + { +
  • + + @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Location)) + { + @ev.Location + } + @if (!string.IsNullOrWhiteSpace(ev.Note)) + { +

    @ev.Note

    + } +
  • + } +
+ } + + @if (_past.Count > 0) + { +

Vergangene Termine

+
    + @foreach (var ev in _past) + { +
  • + + @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Location)) + { + @ev.Location + } +
  • + } +
+ } } diff --git a/Elternbeirat.Web/Features/Events/EventList.razor.cs b/Elternbeirat.Web/Features/Events/EventList.razor.cs index 67bf41f..01c9659 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor.cs +++ b/Elternbeirat.Web/Features/Events/EventList.razor.cs @@ -1,48 +1,67 @@ using Elternbeirat.Contracts; using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Events; public partial class EventList { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not load events from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _upcoming = []; private IReadOnlyList _past = []; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of "no events", matching the + // 503 this sets on the response. + private bool _unavailable; + protected override async Task OnInitializedAsync() { IReadOnlyList events; try { - events = await PocketBase.GetEventsAsync(); + events = await PocketBase.GetEventsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show empty sections instead of a 500. The page - // stays up; the "keine Termine" note is rendered. - LogLoadFailed(Logger, ex); + // Empty sections would claim there are no events. The site works, its + // data store is down: 503 and an honest note. + LogSourceUnavailable(Logger, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); return; } // Split into upcoming and past by date, so an event earlier today still // counts as upcoming. Upcoming earliest first, past most recent first. var today = DateOnly.FromDateTime(DateTime.Today); - _upcoming = events - .Where(e => DateOnly.FromDateTime(e.Start) >= today) - .OrderBy(e => e.Start) - .ToList(); - _past = events - .Where(e => DateOnly.FromDateTime(e.Start) < today) - .OrderByDescending(e => e.Start) - .ToList(); + _upcoming = + [ + .. events + .Where(@event => DateOnly.FromDateTime(@event.Start) >= today) + .OrderBy(@event => @event.Start) + ]; + _past = + [ + .. events + .Where(@event => DateOnly.FromDateTime(@event.Start) < today) + .OrderByDescending(@event => @event.Start) + ]; } } diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor b/Elternbeirat.Web/Features/Faqs/FaqList.razor index 07053a7..fdb5102 100644 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor +++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor @@ -4,7 +4,11 @@

Häufige Fragen

-@if (_groups.Count == 0) +@if (_unavailable) +{ +

Die Fragen sind gerade nicht erreichbar. Bitte später erneut versuchen.

+} +else if (_groups.Count == 0) {

Zurzeit gibt es keine Fragen.

} diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs index 90a2aed..fd93102 100644 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs +++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs @@ -1,13 +1,18 @@ using Elternbeirat.Contracts; using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Faqs; public partial class FaqList { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not load FAQs from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); // German heading per topic. Unknown topics fall back to the raw key so a new // topic in PocketBase still shows up rather than being silently dropped. @@ -21,28 +26,38 @@ public partial class FaqList }; [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList> _groups = []; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of "no questions", matching + // the 503 this sets on the response. + private bool _unavailable; + protected override async Task OnInitializedAsync() { try { - var faqs = await PocketBase.GetFaqsAsync(); + var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); _groups = [.. faqs.GroupBy(faq => faq.Topic)]; } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show an empty page instead of a 500. The page - // stays up; the "keine Fragen" note is rendered. - LogLoadFailed(Logger, ex); + // An empty page would claim there are no questions. The site works, its + // data store is down: 503 and an honest note. + LogSourceUnavailable(Logger, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); } } - private static string TopicLabel(string topic) => - TopicLabels.TryGetValue(topic, out var label) ? label : topic; + private static string TopicLabel(string topic) + => TopicLabels.GetValueOrDefault(topic, topic); } diff --git a/Elternbeirat.Web/Features/Home/Home.razor.cs b/Elternbeirat.Web/Features/Home/Home.razor.cs index 6753823..e4d7c4d 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor.cs +++ b/Elternbeirat.Web/Features/Home/Home.razor.cs @@ -8,8 +8,8 @@ public partial class Home { [LoggerMessage( Level = LogLevel.Warning, - Message = "Could not load the home page from PocketBase.")] - private static partial void LogLoadFailed( + Message = "Could not load the home page from PocketBase; embeds are omitted.")] + private static partial void LogSourceUnavailable( ILogger logger, Exception exception); @@ -19,6 +19,9 @@ public partial class Home [Inject] private ILogger Logger { get; set; } = null!; + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + // Which dynamic blocks the home page shows is editorial: it comes from the // "home" page's embed field in PocketBase, not from hard-coded markup. private IReadOnlyList _embed = []; @@ -27,14 +30,15 @@ public partial class Home { try { - var pages = await PocketBase.GetPagesAsync(); + var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default); var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug)); _embed = home?.Embed ?? []; } - catch (HttpRequestException exception) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> render the intro without embeds instead of a 500. - LogLoadFailed(Logger, exception); + // Only the embeds fail: render the static intro without them instead of a + // 500. The home page's own text is markup, so it stays intact. + LogSourceUnavailable(Logger, exception); } } } diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor b/Elternbeirat.Web/Features/Pages/ContentPage.razor index 69f6617..720419f 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor @@ -1,14 +1,18 @@ @page "/{Slug}" -@if (_page is null) +@if (_unavailable) { - Nicht gefunden + Vorübergehend nicht erreichbar +

Die Seite ist gerade nicht erreichbar. Bitte später erneut versuchen.

} -else +else if (_page is not null) { @_page.Title
+

@_page.Title

@Body
} +@* No page and not unavailable: the slug was unknown; NavigationManager.NotFound() + already ran in OnParametersSetAsync and renders the not-found page. *@ diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs index 8c187ce..1ed7a52 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs @@ -8,9 +8,9 @@ namespace Elternbeirat.Web.Features.Pages; public partial class ContentPage { [LoggerMessage( - Level = LogLevel.Warning, - Message = "Could not load page '{slug}' from PocketBase.")] - private static partial void LogLoadFailed( + Level = LogLevel.Error, + Message = "Could not load page '{Slug}' from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( ILogger logger, string slug, Exception exception); @@ -24,11 +24,19 @@ public partial class ContentPage [Inject] private ILogger Logger { get; set; } = null!; + [Inject] + private NavigationManager Navigation { get; set; } = null!; + [CascadingParameter] private HttpContext? HttpContext { get; set; } private Page? _page; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of a not-found page, matching + // the 503 this sets on the response. + private bool _unavailable; + // The page body rendered from Markdown to HTML, ready to emit as a // MarkupString in the markup. private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body); @@ -37,24 +45,23 @@ public partial class ContentPage { try { - var pages = await PocketBase.GetPagesAsync(); + var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? CancellationToken.None); _page = pages.FirstOrDefault( - p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase)); + page => string.Equals(page.Slug, Slug, StringComparison.OrdinalIgnoreCase)); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> treat as "not found" rather than a 500. The - // 404 branch below serves the friendly /not-found page. - LogLoadFailed(Logger, Slug, ex); - _page = null; + // The site works, its data store is down: 503, not 404. + LogSourceUnavailable(Logger, Slug, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); + return; } - // Unknown slug (or unreachable source) -> 404, so - // UseStatusCodePagesWithReExecute serves the /not-found page instead of an - // empty 200 response. - if (_page is null && HttpContext is not null) + if (_page is null) { - HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + // The source answered, but there is no page with this slug: a genuine 404. + Navigation.NotFound(); } } } diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor b/Elternbeirat.Web/Features/Posts/PostDetail.razor index 718f4c2..38b78e2 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor @@ -1,19 +1,25 @@ @page "/posts/{Slug}" -@if (_post is null) +@if (_unavailable) { - Nicht gefunden + Vorübergehend nicht erreichbar +

Der Beitrag ist gerade nicht erreichbar. Bitte später erneut versuchen.

} -else +else if (_post is not null) { @_post.Title +
-
+

@_post.Title

+ -

+
@Body
} +@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound() + already ran in OnParametersSetAsync and renders the not-found page. *@ diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs index b3a68d6..7eb1aef 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs @@ -1,33 +1,42 @@ -using System.Globalization; using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Http; namespace Elternbeirat.Web.Features.Posts; public partial class PostDetail { - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load post '{slug}' from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not load post '{Slug}' from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( + ILogger logger, + string slug, + Exception exception); [Parameter] public string Slug { get; set; } = string.Empty; [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [Inject] + private NavigationManager Navigation { get; set; } = null!; [CascadingParameter] private HttpContext? HttpContext { get; set; } private Post? _post; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of a not-found page, matching + // the 503 this sets on the response. + private bool _unavailable; + // The post body rendered from Markdown to HTML, ready to emit as a // MarkupString in the markup. private MarkupString Body => (MarkupString)Markdown.ToHtml(_post?.Body); @@ -36,24 +45,23 @@ public partial class PostDetail { try { - var posts = await PocketBase.GetPostsAsync(); + var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); _post = posts.FirstOrDefault( - p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase)); + post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase)); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> treat as "not found" rather than a 500. The - // 404 branch below serves the friendly /not-found page. - LogLoadFailed(Logger, Slug, ex); - _post = null; + // The site works, its data store is down: 503, not 404. + LogSourceUnavailable(Logger, Slug, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); + return; } - // Unknown slug (or unreachable source) -> 404, so - // UseStatusCodePagesWithReExecute serves the /not-found page instead of an - // empty 200 response. - if (_post is null && HttpContext is not null) + if (_post is null) { - HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + // The source answered, but there is no post with this slug: a genuine 404. + Navigation.NotFound(); } } } diff --git a/Elternbeirat.Web/Features/Posts/PostList.razor b/Elternbeirat.Web/Features/Posts/PostList.razor index c294cc4..29a9c39 100644 --- a/Elternbeirat.Web/Features/Posts/PostList.razor +++ b/Elternbeirat.Web/Features/Posts/PostList.razor @@ -4,7 +4,11 @@

Beiträge

-@if (_posts.Count == 0) +@if (_unavailable) +{ +

Die Beiträge sind gerade nicht erreichbar. Bitte später erneut versuchen.

+} +else if (_posts.Count == 0) {

Zurzeit gibt es keine Beiträge.

} @@ -15,8 +19,8 @@ else {
  • @post.Title -
  • } diff --git a/Elternbeirat.Web/Features/Posts/PostList.razor.cs b/Elternbeirat.Web/Features/Posts/PostList.razor.cs index 38d358b..979e6c4 100644 --- a/Elternbeirat.Web/Features/Posts/PostList.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostList.razor.cs @@ -1,36 +1,48 @@ -using System.Globalization; using Elternbeirat.Contracts; using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Posts; public partial class PostList { - [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts from PocketBase.")] - private static partial void LogLoadFailed(ILogger logger, Exception exception); - - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not load posts from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( + ILogger logger, + Exception exception); [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } private IReadOnlyList _posts = []; + // Set when the content source could not be reached or read. The markup then + // shows a "temporarily unavailable" note instead of "no posts", matching the + // 503 this sets on the response. + private bool _unavailable; + protected override async Task OnInitializedAsync() { try { - _posts = await PocketBase.GetPostsAsync(); + _posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); } - catch (HttpRequestException ex) + catch (PocketBaseUnavailableException exception) { - // Source unreachable -> show an empty list instead of a 500. The page - // stays up; the "keine Beiträge" note is rendered. - LogLoadFailed(Logger, ex); + // An empty list would claim there are no posts. The site works, its + // data store is down: 503 and an honest note. + LogSourceUnavailable(Logger, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); } } } diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index 49ac70e..185408c 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -13,7 +13,7 @@ builder.Services.AddRazorComponents(); // comes from configuration: appsettings for local dev, the PocketBase__BaseUrl // env var on the server (see compose.yaml). 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(client => client.BaseAddress = new Uri(pocketBaseUrl)); @@ -24,23 +24,26 @@ var app = builder.Build(); // as HTTP and with the proxy IP instead of the client IP. // KnownNetworks/KnownProxies are deliberately empty because only NPM reaches // the container. -app.UseForwardedHeaders(new ForwardedHeadersOptions -{ - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, - KnownIPNetworks = { }, - KnownProxies = { } -}); +app.UseForwardedHeaders( + new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto + }); // Configure the HTTP request pipeline. -if (!app.Environment.IsDevelopment()) +if (app.Environment.IsDevelopment() is false) { app.UseExceptionHandler("/Error", createScopeForErrors: true); // No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates // TLS. Both here would create a redirect loop behind the proxy. } -app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); - +// No UseStatusCodePagesWithReExecute: the Router renders the NotFoundPage +// (Pages.NotFound) in process for both an unmatched route and an explicit +// NavigationManager.NotFound() from a component (see Routes.razor). Re-executing +// 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.MapStaticAssets(); @@ -49,14 +52,14 @@ app.MapRazorComponents(); // Subscribable calendar feed of all events. A minimal API endpoint rather than // a Razor page because it returns text/calendar, not HTML. Reads from PocketBase // per request; an unreachable source yields an empty calendar, not a 500. -app.MapGet("/events.ics", async (PocketBaseClient pocketBase) => +app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) => { IReadOnlyList events; try { - events = await pocketBase.GetEventsAsync(); + events = await pocketBase.GetEventsAsync(token); } - catch (HttpRequestException) + catch (PocketBaseUnavailableException) { events = []; } diff --git a/Elternbeirat.Web/Shared/Cultures.cs b/Elternbeirat.Web/Shared/Cultures.cs new file mode 100644 index 0000000..800df49 --- /dev/null +++ b/Elternbeirat.Web/Shared/Cultures.cs @@ -0,0 +1,21 @@ +using System.Globalization; + +namespace Elternbeirat.Web.Shared; + +/// +/// Shared instances used to format visitor-facing +/// text. +/// +/// +/// Kept in one place so every component renders month and weekday names the +/// same way, rather than each holding its own de-DE lookup. This is only +/// for display text; machine-readable output (e.g. an HTML datetime +/// attribute) uses instead. +/// +internal static class Cultures +{ + /// + /// German culture, for the month and weekday names visitors read. + /// + public static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); +} diff --git a/Elternbeirat.Web/Shared/EventFormat.cs b/Elternbeirat.Web/Shared/EventFormat.cs index e9272cb..db6c5fe 100644 --- a/Elternbeirat.Web/Shared/EventFormat.cs +++ b/Elternbeirat.Web/Shared/EventFormat.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Elternbeirat.Contracts; namespace Elternbeirat.Web.Shared; @@ -22,11 +21,6 @@ public static class EventFormat ///
    private const string ShortDate = "d. MMMM yyyy"; - /// - /// The culture used for month and weekday names. - /// - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); - /// /// Formats the date range of an event. /// @@ -54,7 +48,7 @@ public static class EventFormat (var start, null, var hasTime) => Stamp(start, LongDate, hasTime), (var start, { } end, true) when end.Date == start.Date - => $"{start.ToString(LongDate, German)}, {start.ToString("HH:mm", German)}–{end.ToString("HH:mm", German)} Uhr", + => $"{start.ToString(LongDate, Cultures.German)}, {start.ToString("HH:mm", Cultures.German)}–{end.ToString("HH:mm", Cultures.German)} Uhr", (var start, { } end, false) when end.Date == start.Date => Stamp(start, LongDate, withTime: false), (var start, { } end, var hasTime) @@ -78,6 +72,6 @@ public static class EventFormat /// private static string Stamp(DateTime value, string dateFormat, bool withTime) => withTime - ? value.ToString(dateFormat + ", HH:mm", German) + " Uhr" - : value.ToString(dateFormat, German); + ? value.ToString(dateFormat + ", HH:mm", Cultures.German) + " Uhr" + : value.ToString(dateFormat, Cultures.German); } diff --git a/Elternbeirat.Web/Shared/SourceUnavailable.cs b/Elternbeirat.Web/Shared/SourceUnavailable.cs new file mode 100644 index 0000000..5ce0d12 --- /dev/null +++ b/Elternbeirat.Web/Shared/SourceUnavailable.cs @@ -0,0 +1,30 @@ +namespace Elternbeirat.Web.Shared; + +/// +/// Marks the current response as HTTP 503 when the content source is down. +/// +/// +/// Several components handle a +/// the same way: the site works but PocketBase does not, so the response is a +/// 503 and the component renders a "temporarily unavailable" note instead of +/// empty content that would read as 200 "there is nothing here". This puts the +/// status-setting in one place so those components do not each repeat it. +/// +internal static class SourceUnavailable +{ + /// + /// Sets the response status to 503 Service Unavailable, if there is a + /// response to set it on. + /// + /// + /// The current HTTP context, or when the component + /// renders outside a request (in which case there is nothing to do). + /// + public static void SetStatus(HttpContext? context) + { + if (context is not null) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + } + } +} diff --git a/Elternbeirat.Web/_Imports.razor b/Elternbeirat.Web/_Imports.razor index 403085e..0c04751 100644 --- a/Elternbeirat.Web/_Imports.razor +++ b/Elternbeirat.Web/_Imports.razor @@ -1,3 +1,4 @@ +@using System.Globalization @using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms