feat(maintenance): add a maintenance notification during backend migration

This commit is contained in:
Edouard Vanbelle
2026-08-01 18:01:10 +02:00
parent 142afecbbf
commit bbfb106a32
27 changed files with 537 additions and 23 deletions
+24 -1
View File
@@ -18,6 +18,15 @@
*/
import { getCsrfHeaders } from './csrf';
import { updateFromHeader } from '$lib/stores/serverStatus.svelte';
/**
* Name of the response header the server stamps while a
* maintenance event is live. Case-insensitive on the wire — the
* Fetch API's `Headers.get` matches irrespective of case, so this
* constant matches whatever axum emits.
*/
const SERVER_STATUS_HEADER = 'x-server-status';
const REFRESH_ENDPOINT = '/api/auth/refresh';
@@ -93,6 +102,18 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
const apiFetch: FetchFn = async (input, init) => {
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
const response = await rawFetch(input, init);
// Server-status header piggyback — the server stamps
// `x-server-status` on every response while a maintenance
// event is in progress (see middleware::server_status). Read
// it and update the reactive store; the AppShell banner
// subscribes and shows/hides itself. Absent header = nothing
// happening; the update fn resets the store to default in
// that case so a lingering banner disappears.
//
// Runs on EVERY response including a 401 (below) so a session
// refresh doesn't accidentally clear a live banner.
updateFromHeader(response.headers.get(SERVER_STATUS_HEADER));
if (response.status !== 401) return response;
const urlStr = urlString(input as RequestInfo | URL);
@@ -104,7 +125,9 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
onSessionExpired();
throw new Error('Session expired');
}
return rawFetch(input, init);
const retryResponse = await rawFetch(input, init);
updateFromHeader(retryResponse.headers.get(SERVER_STATUS_HEADER));
return retryResponse;
};
return apiFetch;
@@ -11,10 +11,12 @@
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import DrivePicker from '$lib/components/DrivePicker.svelte';
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { serverStatus } from '$lib/stores/serverStatus.svelte';
import { apiFetch } from '$lib/api/client';
import { dialogs } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
@@ -1025,6 +1027,21 @@
</div>
<div class="content-area">
<!-- Server-wide maintenance banner. Fed by the
`x-server-status` header read on every API response by
`apiFetch` — no polling. Shows for every logged-in user
while a storage migration is running so they know why
writes are being refused, with live progress if
available. Disappears automatically on the next API
round-trip after the server clears the flag.
Reuses `ReadOnlyBanner` (same component that renders a
drive-frozen notice) with `variant="maintenance"` so the
two banners are visually indistinguishable — just
different copy. -->
{#if serverStatus().readonly}
<ReadOnlyBanner variant="maintenance" progress={serverStatus().migration} />
{/if}
{@render children()}
</div>
</div>
@@ -1,6 +1,8 @@
<script lang="ts">
/**
* Read-only drive banner.
* Read-only banner — one component, two variants.
*
* ## `variant="drive"` (default) — drive-scoped freeze
*
* Rendered at the top of any page whose content lives in (or is scoped
* to) a drive whose `policies.read_only === true`. Members see the
@@ -19,33 +21,60 @@
* - `routes/files/[...path]/+page.svelte` — shown when the current
* folder's owning drive is frozen (parent looks up drive via
* `drives.findByRootFolderId`/`findById`).
* - Future: `/photos`, `/music`, and any other drive-scoped views.
*
* ## `variant="maintenance"` — server-wide freeze
*
* Rendered inside `AppShell` above `{children}` when the
* `x-server-status` header (see `middleware::server_status`) says
* the whole server is in read-only mode — typically during a
* storage-backend migration. Optional `progress` lets the banner
* show target + percentage.
*
* Shape / accent is identical between both variants — the design
* system reads them as the same family. Only the copy differs.
*/
import { t } from '$lib/i18n/index.svelte';
import Icon from '$lib/icons/Icon.svelte';
interface Props {
/** Drive-name shown in the body so members know which drive the
* freeze applies to. Optional — omit on pages where the drive is
* implicit from context (e.g. the drive's own config page). */
driveName?: string;
interface Progress {
target: string;
migrated: number;
total: number;
percent: number;
}
let { driveName }: Props = $props();
interface Props {
/**
* `"drive"` — a specific drive is frozen (default; back-compat
* with pre-migration call sites). `"maintenance"` — the whole
* server is in read-only mode.
*/
variant?: 'drive' | 'maintenance';
/** Drive-name shown in the body (variant="drive" only). */
driveName?: string;
/** Migration progress (variant="maintenance" only). */
progress?: Progress;
}
let { variant = 'drive', driveName, progress }: Props = $props();
</script>
<div
class="read-only-banner"
role="region"
aria-label={t('drive.read_only_banner.aria', 'This drive is read-only')}
data-testid="read-only-banner"
aria-label={variant === 'maintenance'
? t('server_status.readonly_banner_aria', 'Server maintenance in progress')
: t('drive.read_only_banner.aria', 'This drive is read-only')}
data-testid={variant === 'maintenance' ? 'server-status-banner' : 'read-only-banner'}
>
<div class="read-only-banner__icon" aria-hidden="true">
<Icon name="lock" />
</div>
<div class="read-only-banner__body">
<strong>
{#if driveName}
{#if variant === 'maintenance'}
{t('server_status.readonly_title', 'Server maintenance in progress')}
{:else if driveName}
{t(
'drive.read_only_banner.title_named',
{ name: driveName },
@@ -56,10 +85,30 @@
{/if}
</strong>
<span>
{t(
'drive.read_only_banner.body',
'Uploads, edits, deletes, renames, sharing and membership changes are refused. Reads and downloads keep working. Contact an administrator to un-freeze the drive.'
)}
{#if variant === 'maintenance'}
{#if progress}
{t(
'server_status.readonly_progress',
{
target: progress.target,
migrated: progress.migrated,
total: progress.total,
percent: progress.percent
},
'Migrating storage to `{{target}}` — {{percent}}% ({{migrated}} / {{total}} blobs). Uploads, renames, deletes, and shares are refused; reads and downloads work as normal.'
)}
{:else}
{t(
'server_status.readonly_body',
'Uploads, renames, deletes, and shares are refused temporarily. Reads and downloads work as normal.'
)}
{/if}
{:else}
{t(
'drive.read_only_banner.body',
'Uploads, edits, deletes, renames, sharing and membership changes are refused. Reads and downloads keep working. Contact an administrator to un-freeze the drive.'
)}
{/if}
</span>
</div>
</div>
@@ -0,0 +1,67 @@
/**
* Reactive server-status store.
*
* Populated by the `apiFetch` wrapper, which reads the
* `x-server-status` header off every API response and calls
* `updateFromHeader(...)`. When no migration is running the header
* is absent and the store stays at its default (readonly=false, no
* migration info). See `middleware::server_status` on the server
* for the header spec.
*
* The AppShell subscribes to this store to show/hide the
* maintenance banner without polling — the state travels back to
* the client on the piggyback of whatever API request the user was
* making anyway. Zero extra network cost.
*/
/**
* JSON shape emitted in the `x-server-status` header. Optional
* `migration` field is present only while a migration is running.
*/
export interface ServerStatus {
readonly: boolean;
migration?: {
target: string;
migrated: number;
total: number;
percent: number;
};
}
const DEFAULT: ServerStatus = { readonly: false };
// Rune-based reactive state — `$state` in a `.svelte.ts` module.
let current = $state<ServerStatus>(DEFAULT);
/** Current server status. Reactively updates when apiFetch sees a new header. */
export function serverStatus(): ServerStatus {
return current;
}
/**
* Parse the raw header value and update the store. Silently
* tolerates a missing header (resets to default: nothing to
* broadcast means nothing wrong) and a malformed one (keeps the
* previous value rather than surface a parse error to users).
*
* Called by `apiFetch` after every response — see `client.ts`.
*/
export function updateFromHeader(rawHeader: string | null): void {
if (rawHeader == null) {
// No header on this response = server not in maintenance
// mode = reset the store to the default so any lingering
// banner disappears. Cheap idempotent write.
if (current.readonly || current.migration) current = DEFAULT;
return;
}
try {
const parsed = JSON.parse(rawHeader) as ServerStatus;
// Basic shape validation — server should never send a
// missing `readonly`, but be defensive.
if (typeof parsed.readonly === 'boolean') {
current = parsed;
}
} catch {
// Malformed header — keep previous state rather than churn.
}
}