From 74e729fd010b29fcdfaff5eab319c89711c68ecb Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 10 Apr 2026 11:02:38 +0700 Subject: [PATCH 1/2] Add maintenance-mode message to Lexbox UI Will be enabled by setting an ASP.NET config option, e.g. with an environment variable. Config has two properties, the message which will be displayed in the UI (and set in a header returned with any API calls, so keep it short) and a ReadOnlyMode boolean which will determine whether non-read-only HTTP requests (POST, PUT, PATCH, and DELETE) will be rejected by the server. Setting a message *without* setting ReadOnlyMode is useful for giving advance notice of planned maintenance windows. --- backend/FwHeadless/FwHeadlessKernel.cs | 4 ++ backend/FwHeadless/MaintenanceModeConfig.cs | 7 +++ .../FwHeadless/MaintenanceModeMiddleware.cs | 49 ++++++++++++++++++ backend/FwHeadless/Program.cs | 3 ++ .../FwHeadless/appsettings.Development.json | 4 ++ backend/FwHeadless/appsettings.json | 4 ++ .../LexBoxApi/Config/MaintenanceModeConfig.cs | 7 +++ backend/LexBoxApi/LexBoxKernel.cs | 4 ++ .../Middleware/MaintenanceModeMiddleware.cs | 50 +++++++++++++++++++ backend/LexBoxApi/Program.cs | 2 + .../LexBoxApi/appsettings.Development.json | 4 ++ backend/LexBoxApi/appsettings.json | 4 ++ frontend/src/hooks.client.ts | 7 +++ frontend/src/hooks.server.ts | 7 +++ frontend/src/lib/layout/AppBar.svelte | 6 +++ 15 files changed, 162 insertions(+) create mode 100644 backend/FwHeadless/MaintenanceModeConfig.cs create mode 100644 backend/FwHeadless/MaintenanceModeMiddleware.cs create mode 100644 backend/LexBoxApi/Config/MaintenanceModeConfig.cs create mode 100644 backend/LexBoxApi/Middleware/MaintenanceModeMiddleware.cs diff --git a/backend/FwHeadless/FwHeadlessKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs index 51700d8f53..442ebe1f74 100644 --- a/backend/FwHeadless/FwHeadlessKernel.cs +++ b/backend/FwHeadless/FwHeadlessKernel.cs @@ -21,6 +21,10 @@ public static IServiceCollection AddFwHeadless(this IServiceCollection services) .BindConfiguration("FwHeadlessConfig") .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .BindConfiguration("MaintenanceModeConfig") + .ValidateDataAnnotations() + .ValidateOnStart(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/backend/FwHeadless/MaintenanceModeConfig.cs b/backend/FwHeadless/MaintenanceModeConfig.cs new file mode 100644 index 0000000000..361e364251 --- /dev/null +++ b/backend/FwHeadless/MaintenanceModeConfig.cs @@ -0,0 +1,7 @@ +namespace FwHeadless; + +public class MaintenanceModeConfig +{ + public bool ReadOnlyMode { get; init; } = false; + public string? MaintenanceMessage { get; set; } = null; +} diff --git a/backend/FwHeadless/MaintenanceModeMiddleware.cs b/backend/FwHeadless/MaintenanceModeMiddleware.cs new file mode 100644 index 0000000000..790c69c996 --- /dev/null +++ b/backend/FwHeadless/MaintenanceModeMiddleware.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Options; + +namespace FwHeadless; + +public class MaintenanceModeMiddleware(RequestDelegate next, IOptions config) +{ + public const string DefaultMaintenanceMessage = "Lexbox is in read-only mode for scheduled maintenance, please try again in an hour or two"; + +#pragma warning disable IDE0022 + public static bool IsWriteMethod(string httpMethod) => httpMethod switch + { + // Sadly, readonly static vars do not count as consts for switch expressions + var m when m == HttpMethods.Post => true, + var m when m == HttpMethods.Put => true, + var m when m == HttpMethods.Patch => true, + var m when m == HttpMethods.Delete => true, + _ => false + }; +#pragma warning restore IDE0022 + + public async Task Invoke(HttpContext context) + { + var readOnly = config.Value.ReadOnlyMode; + var message = config.Value.MaintenanceMessage; + // If read-only mode is set without an explicit message, use the default + if (readOnly) message ??= DefaultMaintenanceMessage; + // But if not in read-only mode, then an empty message should not set the header + + // If we get here without a maintenance message, exit fast + if (string.IsNullOrEmpty(message)) + { + await next(context); + return; + } + + // Non-empty maintenance messages should be set on all requests + context.Response.Headers["maintenance-message"] = message; + + // But request filtering should only happen if we're in read-only mode + if (readOnly && IsWriteMethod(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsync(message); + return; + } + + await next(context); + } +} diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs index c17c1878ff..0d89db46b9 100644 --- a/backend/FwHeadless/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -63,6 +63,9 @@ await next(); }); +// Allow read-only mode during maintenance windows +app.UseMiddleware(); + // Load project ID from request app.Use((context, next) => { diff --git a/backend/FwHeadless/appsettings.Development.json b/backend/FwHeadless/appsettings.Development.json index d98c6306a1..30fca17d94 100644 --- a/backend/FwHeadless/appsettings.Development.json +++ b/backend/FwHeadless/appsettings.Development.json @@ -15,5 +15,9 @@ "Default": "Information" } }, + "MaintenanceMode": { + "ReadOnlyMode": false, + "MaintenanceMessage": null + }, "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317" } diff --git a/backend/FwHeadless/appsettings.json b/backend/FwHeadless/appsettings.json index 6fd1c295aa..d0c8e66136 100644 --- a/backend/FwHeadless/appsettings.json +++ b/backend/FwHeadless/appsettings.json @@ -9,6 +9,10 @@ "SendReceive": "Information" } }, + "MaintenanceMode": { + "ReadOnlyMode": false, + "MaintenanceMessage": null + }, "LcmCrdt": { // This value is explicitly referenced in SnapshotAtCommitService when preserving FieldWorks commits "DefaultAuthorForCommits": "FieldWorks", diff --git a/backend/LexBoxApi/Config/MaintenanceModeConfig.cs b/backend/LexBoxApi/Config/MaintenanceModeConfig.cs new file mode 100644 index 0000000000..ae46dd8e93 --- /dev/null +++ b/backend/LexBoxApi/Config/MaintenanceModeConfig.cs @@ -0,0 +1,7 @@ +namespace LexBoxApi.Config; + +public class MaintenanceModeConfig +{ + public bool ReadOnlyMode { get; init; } = false; + public string? MaintenanceMessage { get; set; } = null; +} diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index 25033c4023..c838ca2c54 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -58,6 +58,10 @@ public static void AddLexBoxApi(this IServiceCollection services, .BindConfiguration("FwLiteRelease") .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .BindConfiguration("MaintenanceModeConfig") + .ValidateDataAnnotations() + .ValidateOnStart(); services.AddHttpClient(); services.AddServiceDiscovery(); services.AddHttpClient(client => client.BaseAddress = new ("http://fwHeadless")) diff --git a/backend/LexBoxApi/Middleware/MaintenanceModeMiddleware.cs b/backend/LexBoxApi/Middleware/MaintenanceModeMiddleware.cs new file mode 100644 index 0000000000..22d1785c58 --- /dev/null +++ b/backend/LexBoxApi/Middleware/MaintenanceModeMiddleware.cs @@ -0,0 +1,50 @@ +using LexBoxApi.Config; +using Microsoft.Extensions.Options; + +namespace LexBoxApi.Middleware; + +public class MaintenanceModeMiddleware(RequestDelegate next, IOptions config) +{ + public const string DefaultMaintenanceMessage = "Lexbox is in read-only mode for scheduled maintenance, please try again in an hour or two"; + +#pragma warning disable IDE0022 + public static bool IsWriteMethod(string httpMethod) => httpMethod switch + { + // Sadly, readonly static vars do not count as consts for switch expressions + var m when m == HttpMethods.Post => true, + var m when m == HttpMethods.Put => true, + var m when m == HttpMethods.Patch => true, + var m when m == HttpMethods.Delete => true, + _ => false + }; +#pragma warning restore IDE0022 + + public async Task Invoke(HttpContext context) + { + var readOnly = config.Value.ReadOnlyMode; + var message = config.Value.MaintenanceMessage; + // If read-only mode is set without an explicit message, use the default + if (readOnly) message ??= DefaultMaintenanceMessage; + // But if not in read-only mode, then an empty message should not set the header + + // If we get here without a maintenance message, exit fast + if (string.IsNullOrEmpty(message)) + { + await next(context); + return; + } + + // Non-empty maintenance messages should be set on all requests + context.Response.Headers["maintenance-message"] = message; + + // But request filtering should only happen if we're in read-only mode + if (readOnly && IsWriteMethod(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsync(message); + return; + } + + await next(context); + } +} diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 9898ef873d..c88f7e58d5 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -7,6 +7,7 @@ using LexBoxApi.Auth.Attributes; using LexBoxApi.ErrorHandling; using LexBoxApi.Hub; +using LexBoxApi.Middleware; using LexBoxApi.Otel; using LexBoxApi.Proxies; using LexBoxApi.Services; @@ -157,6 +158,7 @@ if (!app.Environment.IsDevelopment()) app.UseExceptionHandler(); app.UseHealthChecks("/api/healthz"); +app.UseMiddleware(); // Configure the HTTP request pipeline. //for now allow this to run in prod, maybe later we want to disable it. { diff --git a/backend/LexBoxApi/appsettings.Development.json b/backend/LexBoxApi/appsettings.Development.json index deeb8a6f99..37c52850b6 100644 --- a/backend/LexBoxApi/appsettings.Development.json +++ b/backend/LexBoxApi/appsettings.Development.json @@ -52,6 +52,10 @@ "LfMergeTrustToken": "lf-merge-dev-trust-token", "AutoUpdateLexEntryCountOnSendReceive": true }, + "MaintenanceMode": { + "ReadOnlyMode": false, + "MaintenanceMessage": null + }, "HealthChecks": { "RequireFwHeadlessContainerVersionMatch": false, "RequireHealthyFwHeadlessContainer": false diff --git a/backend/LexBoxApi/appsettings.json b/backend/LexBoxApi/appsettings.json index 95904c48a6..597c30aa69 100644 --- a/backend/LexBoxApi/appsettings.json +++ b/backend/LexBoxApi/appsettings.json @@ -46,6 +46,10 @@ "HgConfig": { "RepoPath": null }, + "MaintenanceMode": { + "ReadOnlyMode": false, + "MaintenanceMessage": null + }, "Authentication": { "Jwt": { "Secret": null, diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index bbb5fab445..4a550a3fd9 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -1,6 +1,7 @@ import { ensureErrorIsTraced, traceFetch } from '$lib/otel/otel.client'; import { getErrorMessage, validateFetchResponse } from './hooks.shared'; +import { maintenanceMessage } from '$lib/util/maintenance'; import { APP_VERSION } from '$lib/util/version'; import type { HandleClientError } from '@sveltejs/kit'; import { USER_LOAD_KEY } from '$lib/user'; @@ -68,6 +69,12 @@ handleFetch(async ({ fetch, args }) => { return response; }); + if (response.headers.has('maintenance-message')) { + maintenanceMessage.value = response.headers.get('maintenance-message'); + } else { + maintenanceMessage.value = null; + } + // invalidateUserOnJwtRefresh is considered true by default, so only skip if the value is false if (args[1]?.lexboxResponseHandlingConfig?.invalidateUserOnJwtRefresh !== false && response.headers.get('lexbox-jwt-updated') === 'all') { await invalidate(USER_LOAD_KEY); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 9a8ffaea61..6a23849bbe 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,5 +1,6 @@ import {loadI18n, pickBestLocale} from '$lib/i18n'; import {AUTH_COOKIE_NAME, getUser, isAuthn} from '$lib/user'; +import {maintenanceMessage} from '$lib/util/maintenance'; import {apiVersion} from '$lib/util/version'; import {redirect, type Handle, type HandleFetch, type HandleServerError, type RequestEvent, type ResolveOptions} from '@sveltejs/kit'; import {ensureErrorIsTraced, traceRequest, traceFetch} from '$lib/otel/otel.server'; @@ -88,6 +89,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { apiVersion.value = response.headers.get('lexbox-version'); } + if (response.headers.has('maintenance-message')) { + maintenanceMessage.value = response.headers.get('maintenance-message'); + } else { + maintenanceMessage.value = null; + } + const lexBoxSetAuthCookieHeader = response.headers.getSetCookie() .find(h => h.startsWith(`${AUTH_COOKIE_NAME}=`)); diff --git a/frontend/src/lib/layout/AppBar.svelte b/frontend/src/lib/layout/AppBar.svelte index 1076a2b5a1..c443e6e64f 100644 --- a/frontend/src/lib/layout/AppBar.svelte +++ b/frontend/src/lib/layout/AppBar.svelte @@ -5,6 +5,7 @@ import {AuthenticatedUserIcon, UserAddOutline, Icon} from '$lib/icons'; import {onMount} from 'svelte'; import type {LexAuthUser} from '$lib/user'; + import {maintenanceMessage} from '$lib/util/maintenance'; import {page} from '$app/state'; import AppLogo from '$lib/icons/AppLogo.svelte'; import DevContent from './DevContent.svelte'; @@ -25,6 +26,11 @@
+ {#if maintenanceMessage.value} + + {maintenanceMessage.value} + + {/if} {#if environmentName !== 'production'} {$t('environment_warning', { environmentName })} From c89b35362ed1de53aa1846ed3360c74ac8a1a6ff Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 10 Apr 2026 12:54:48 +0700 Subject: [PATCH 2/2] Fix incorrect config binding strings --- backend/FwHeadless/FwHeadlessKernel.cs | 2 +- backend/LexBoxApi/LexBoxKernel.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwHeadless/FwHeadlessKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs index 442ebe1f74..6c442d3b40 100644 --- a/backend/FwHeadless/FwHeadlessKernel.cs +++ b/backend/FwHeadless/FwHeadlessKernel.cs @@ -22,7 +22,7 @@ public static IServiceCollection AddFwHeadless(this IServiceCollection services) .ValidateDataAnnotations() .ValidateOnStart(); services.AddOptions() - .BindConfiguration("MaintenanceModeConfig") + .BindConfiguration("MaintenanceMode") .ValidateDataAnnotations() .ValidateOnStart(); services.AddSingleton(); diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index c838ca2c54..593a46f07e 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -59,7 +59,7 @@ public static void AddLexBoxApi(this IServiceCollection services, .ValidateDataAnnotations() .ValidateOnStart(); services.AddOptions() - .BindConfiguration("MaintenanceModeConfig") + .BindConfiguration("MaintenanceMode") .ValidateDataAnnotations() .ValidateOnStart(); services.AddHttpClient();