diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index f388550c..d9024c67 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -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; diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 934610e8..d669b4dd 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -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 @@
+ + {#if serverStatus().readonly} + + {/if} {@render children()}
diff --git a/frontend/src/lib/components/ReadOnlyBanner.svelte b/frontend/src/lib/components/ReadOnlyBanner.svelte index cfee3dae..d7fd5491 100644 --- a/frontend/src/lib/components/ReadOnlyBanner.svelte +++ b/frontend/src/lib/components/ReadOnlyBanner.svelte @@ -1,6 +1,8 @@
- {#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} - {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}
diff --git a/frontend/src/lib/stores/serverStatus.svelte.ts b/frontend/src/lib/stores/serverStatus.svelte.ts new file mode 100644 index 00000000..a16d449b --- /dev/null +++ b/frontend/src/lib/stores/serverStatus.svelte.ts @@ -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(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. + } +} diff --git a/frontend/static/locales/ar.json b/frontend/static/locales/ar.json index ca126d05..ed4f3d50 100644 --- a/frontend/static/locales/ar.json +++ b/frontend/static/locales/ar.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "صيانة الخادم جارية", + "readonly_title": "صيانة الخادم جارية", + "readonly_progress": "جارٍ نقل التخزين إلى `{{target}}` — {{percent}}٪ ({{migrated}} / {{total}} كتلة). الرفع وإعادة التسمية والحذف والمشاركة مرفوضة؛ القراءة والتنزيل تعملان بشكل طبيعي.", + "readonly_body": "الرفع وإعادة التسمية والحذف والمشاركة مرفوضة مؤقتًا. القراءة والتنزيل يعملان بشكل طبيعي." + }, "app": { "title": "OxiCloud", "description": "نظام تخزين سحابي بسيط" diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 8ee18eb0..a635472d 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "Serverwartung läuft", + "readonly_title": "Serverwartung läuft", + "readonly_progress": "Speicher wird auf `{{target}}` migriert — {{percent}} % ({{migrated}} / {{total}} Blöcke). Uploads, Umbenennungen, Löschungen und Freigaben werden abgelehnt; Lesen und Herunterladen funktionieren normal.", + "readonly_body": "Uploads, Umbenennungen, Löschungen und Freigaben werden vorübergehend abgelehnt. Lesen und Herunterladen funktionieren normal." + }, "app": { "title": "OxiCloud", "description": "Minimalistisches Cloud-Speichersystem" diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 78189299..ed307588 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "Server maintenance in progress", + "readonly_title": "Server maintenance in progress", + "readonly_progress": "Migrating storage to `{{target}}` — {{percent}}% ({{migrated}} / {{total}} blobs). Uploads, renames, deletes, and shares are refused; reads and downloads work as normal.", + "readonly_body": "Uploads, renames, deletes, and shares are refused temporarily. Reads and downloads work as normal." + }, "app": { "title": "OxiCloud", "description": "Minimalist cloud storage system" diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index f72df3f0..84d0de60 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "Mantenimiento del servidor en curso", + "readonly_title": "Mantenimiento del servidor en curso", + "readonly_progress": "Migrando el almacenamiento a `{{target}}` — {{percent}} % ({{migrated}} / {{total}} bloques). Las subidas, renombres, eliminaciones y comparticiones se rechazan; las lecturas y descargas funcionan con normalidad.", + "readonly_body": "Las subidas, renombres, eliminaciones y comparticiones se rechazan temporalmente. Las lecturas y descargas funcionan con normalidad." + }, "app": { "title": "OxiCloud", "description": "Sistema de almacenamiento en la nube minimalista" diff --git a/frontend/static/locales/fa.json b/frontend/static/locales/fa.json index fdabf73f..6b6b467b 100644 --- a/frontend/static/locales/fa.json +++ b/frontend/static/locales/fa.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "نگهداری سرور در حال انجام است", + "readonly_title": "نگهداری سرور در حال انجام است", + "readonly_progress": "در حال انتقال حافظه به `{{target}}` — {{percent}}٪ ({{migrated}} / {{total}} بلاک). آپلود، تغییر نام، حذف و اشتراک‌گذاری رد می‌شوند؛ خواندن و دانلود عادی کار می‌کنند.", + "readonly_body": "آپلود، تغییر نام، حذف و اشتراک‌گذاری موقتاً رد می‌شوند. خواندن و دانلود عادی کار می‌کنند." + }, "app": { "title": "OxiCloud", "description": "سیستم ذخیره‌سازی ابری ساده‌گرا" diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index b406e717..100ce288 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "Maintenance du serveur en cours", + "readonly_title": "Maintenance du serveur en cours", + "readonly_progress": "Migration du stockage vers `{{target}}` — {{percent}} % ({{migrated}} / {{total}} blocs). Les téléversements, renommages, suppressions et partages sont refusés ; la lecture et le téléchargement continuent normalement.", + "readonly_body": "Les téléversements, renommages, suppressions et partages sont temporairement refusés. La lecture et le téléchargement fonctionnent normalement." + }, "app": { "title": "OxiCloud", "description": "Système de stockage cloud minimaliste" diff --git a/frontend/static/locales/hi.json b/frontend/static/locales/hi.json index 84b3af75..f079ddaf 100644 --- a/frontend/static/locales/hi.json +++ b/frontend/static/locales/hi.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "सर्वर रखरखाव प्रगति पर है", + "readonly_title": "सर्वर रखरखाव प्रगति पर है", + "readonly_progress": "स्टोरेज को `{{target}}` पर माइग्रेट किया जा रहा है — {{percent}}% ({{migrated}} / {{total}} ब्लॉब्स)। अपलोड, नाम बदलना, हटाना और साझा करना अस्वीकृत हैं; पढ़ना और डाउनलोड सामान्य रूप से काम करते हैं।", + "readonly_body": "अपलोड, नाम बदलना, हटाना और साझा करना अस्थायी रूप से अस्वीकृत हैं। पढ़ना और डाउनलोड सामान्य रूप से काम करते हैं।" + }, "app": { "title": "OxiCloud", "description": "न्यूनतम क्लाउड स्टोरेज सिस्टम" diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index 25ae6019..92ed7dca 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "Manutenzione del server in corso", + "readonly_title": "Manutenzione del server in corso", + "readonly_progress": "Migrazione dello storage verso `{{target}}` — {{percent}}% ({{migrated}} / {{total}} blob). Caricamenti, rinomine, eliminazioni e condivisioni sono rifiutati; le letture e i download funzionano normalmente.", + "readonly_body": "Caricamenti, rinomine, eliminazioni e condivisioni sono temporaneamente rifiutati. Le letture e i download funzionano normalmente." + }, "app": { "title": "OxiCloud", "description": "Sistema di archiviazione cloud minimalista" diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index 000379b7..6a4e8206 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "サーバーメンテナンス中", + "readonly_title": "サーバーメンテナンス中", + "readonly_progress": "ストレージを `{{target}}` に移行中 — {{percent}}%({{migrated}} / {{total}} ブロブ)。アップロード、名前変更、削除、共有は拒否されます。読み取りとダウンロードは通常どおり動作します。", + "readonly_body": "アップロード、名前変更、削除、共有は一時的に拒否されます。読み取りとダウンロードは通常どおり動作します。" + }, "app": { "title": "OxiCloud", "description": "ミニマリストクラウドストレージシステム" diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index 916b843b..56b1a5d3 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "서버 유지 관리 진행 중", + "readonly_title": "서버 유지 관리 진행 중", + "readonly_progress": "저장소를 `{{target}}`(으)로 마이그레이션 중 — {{percent}}% ({{migrated}} / {{total}} 블롭). 업로드, 이름 변경, 삭제 및 공유가 거부됩니다. 읽기 및 다운로드는 정상적으로 작동합니다.", + "readonly_body": "업로드, 이름 변경, 삭제 및 공유가 일시적으로 거부됩니다. 읽기 및 다운로드는 정상적으로 작동합니다." + }, "app": { "title": "OxiCloud", "description": "미니멀리스트 클라우드 스토리지 시스템" diff --git a/frontend/static/locales/nl.json b/frontend/static/locales/nl.json index 4cfc5da7..c62bc1d5 100644 --- a/frontend/static/locales/nl.json +++ b/frontend/static/locales/nl.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "Serveronderhoud bezig", + "readonly_title": "Serveronderhoud bezig", + "readonly_progress": "Opslag wordt gemigreerd naar `{{target}}` — {{percent}}% ({{migrated}} / {{total}} blobs). Uploads, hernoemingen, verwijderingen en delen worden geweigerd; lezen en downloaden werken normaal.", + "readonly_body": "Uploads, hernoemingen, verwijderingen en delen worden tijdelijk geweigerd. Lezen en downloaden werken normaal." + }, "app": { "title": "OxiCloud", "description": "Minimalistisch cloudopslagsysteem" diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index 7dc8fec3..7b3ab098 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "Trwa konserwacja serwera", + "readonly_title": "Trwa konserwacja serwera", + "readonly_progress": "Migracja pamięci do `{{target}}` — {{percent}}% ({{migrated}} / {{total}} blobów). Przesyłanie, zmiana nazwy, usuwanie i udostępnianie są odrzucane; odczyt i pobieranie działają normalnie.", + "readonly_body": "Przesyłanie, zmiana nazwy, usuwanie i udostępnianie są tymczasowo odrzucane. Odczyt i pobieranie działają normalnie." + }, "app": { "title": "OxiCloud", "description": "Minimalistyczny cloud storage" diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index 0b9c4ade..c7670445 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "Manutenção do servidor em curso", + "readonly_title": "Manutenção do servidor em curso", + "readonly_progress": "A migrar o armazenamento para `{{target}}` — {{percent}}% ({{migrated}} / {{total}} blobs). Envios, renomeações, eliminações e partilhas são recusados; leituras e transferências funcionam normalmente.", + "readonly_body": "Envios, renomeações, eliminações e partilhas são temporariamente recusados. Leituras e transferências funcionam normalmente." + }, "app": { "title": "OxiCloud", "description": "Sistema de armazenamento em nuvem minimalista" diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index adf05ad2..87f760fc 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "Идёт обслуживание сервера", + "readonly_title": "Идёт обслуживание сервера", + "readonly_progress": "Миграция хранилища на `{{target}}` — {{percent}}% ({{migrated}} / {{total}} блобов). Загрузки, переименования, удаления и общий доступ отклоняются; чтение и скачивание работают как обычно.", + "readonly_body": "Загрузки, переименования, удаления и общий доступ временно отклоняются. Чтение и скачивание работают как обычно." + }, "app": { "title": "OxiCloud", "description": "Минималистичная система облачного хранения" diff --git a/frontend/static/locales/zh-TW.json b/frontend/static/locales/zh-TW.json index a11e7ba5..b57dc99f 100644 --- a/frontend/static/locales/zh-TW.json +++ b/frontend/static/locales/zh-TW.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "伺服器維護進行中", + "readonly_title": "伺服器維護進行中", + "readonly_progress": "正在將儲存遷移至 `{{target}}` — {{percent}}%({{migrated}} / {{total}} 個 blob)。上傳、重新命名、刪除和分享會被拒絕;讀取和下載正常運作。", + "readonly_body": "上傳、重新命名、刪除和分享暫時被拒絕。讀取和下載正常運作。" + }, "app": { "title": "OxiCloud", "description": "極簡雲端儲存系統" diff --git a/frontend/static/locales/zh.json b/frontend/static/locales/zh.json index 90c2effb..5cc129f8 100644 --- a/frontend/static/locales/zh.json +++ b/frontend/static/locales/zh.json @@ -38,6 +38,12 @@ } } }, + "server_status": { + "readonly_banner_aria": "服务器维护进行中", + "readonly_title": "服务器维护进行中", + "readonly_progress": "正在将存储迁移到 `{{target}}` — {{percent}}%({{migrated}} / {{total}} 个 blob)。上传、重命名、删除和共享被拒绝;读取和下载正常工作。", + "readonly_body": "上传、重命名、删除和共享暂时被拒绝。读取和下载正常工作。" + }, "app": { "title": "OxiCloud", "description": "极简云存储系统" diff --git a/src/common/di.rs b/src/common/di.rs index acc79189..ca555373 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -2028,6 +2028,7 @@ impl AppServiceFactory { crate::infrastructure::services::webdav_dead_property_store::create_dead_property_store(pool.clone()), authorization: authorization.clone(), migration_readonly: migration_readonly.clone(), + migration_progress: Arc::new(std::sync::RwLock::new(None)), drive_repo: drive_repo.clone(), drive_management_service: Arc::new( crate::application::services::drive_management_service::DriveManagementService::new( @@ -2254,6 +2255,7 @@ impl AppServiceFactory { self.storage_path.clone(), app_state.migration_readonly.clone(), app_state.core.blob_backend_hot_swap.clone(), + app_state.migration_progress.clone(), ), ) .register_recoverable_job(&app_state.core.job_registry, &job_store_provider_dyn) @@ -2785,6 +2787,14 @@ pub struct AppState { /// memory in sync. See `docs/plan/storage-multi-entry.md` /// §"Read-only mode". pub migration_readonly: Arc, + /// Live progress snapshot for the storage-migration handler. + /// `Some(_)` while a migration is running; `None` otherwise. + /// Updated by the handler on every batch checkpoint (cheap + /// in-memory write, no DB read on the request path). The + /// server-status header middleware reads it to inform every + /// user's session banner about maintenance progress without + /// polling. See `MigrationProgress` for the field shape. + pub migration_progress: Arc>>, /// Drive entity repository — `GET /api/drives`, the personal-drive /// lifecycle hook, and (post-D2) shared-drive creation flow all read /// through this. Backing table is `storage.drives`; membership is diff --git a/src/common/migration_progress.rs b/src/common/migration_progress.rs new file mode 100644 index 00000000..9f78b7ff --- /dev/null +++ b/src/common/migration_progress.rs @@ -0,0 +1,66 @@ +//! Shared in-memory snapshot of the running storage-migration. +//! +//! Consumed by the server-status middleware to build the +//! `X-Server-Status` response header on every authenticated +//! request. That header lets every logged-in user's session banner +//! show current maintenance progress without any polling — +//! the state travels back on the piggyback of whatever API call +//! the user was going to make anyway. +//! +//! Written by the migration handler on each batch checkpoint (a +//! cheap `RwLock::write` + a small struct copy — no DB access on +//! the request path). Cleared on `RunOutcome::Completed` / +//! `Paused` / `Failed`. `None` means "no migration is running"; +//! middleware omits the header entirely in that case. + +use serde::Serialize; + +/// One snapshot of a running migration. Every field is a scalar so +/// the whole struct copies cheaply under the `RwLock::write` guard. +#[derive(Debug, Clone, Serialize)] +pub struct MigrationProgress { + /// The entry name blobs are being copied INTO. Used by the + /// user-facing banner text so admins/users know what the + /// server is switching to. + pub target_name: String, + /// Blobs migrated so far this run. Starts at 0 on a Fresh + /// open; on Resume the checkpointed value is loaded from the + /// run row's `stats.scanned_count`. + pub migrated_blobs: u64, + /// Total blobs in the current DB snapshot. Captured once at + /// run start via `SELECT COUNT(*) FROM storage.blobs`. Doesn't + /// change during the run — new uploads are refused while + /// read-only is engaged, so the denominator stays honest. + pub total_blobs: u64, + /// Convenience: `migrated_blobs * 100 / total_blobs`, clamped + /// to 0..=100. Middleware could compute it but it's tiny and + /// makes the JSON payload obvious. + pub percent: u8, +} + +impl MigrationProgress { + pub fn new(target_name: String, total_blobs: u64) -> Self { + Self { + target_name, + migrated_blobs: 0, + total_blobs, + percent: 0, + } + } + + /// Update the counter + recompute `percent`. Called by the + /// migration handler after each batch checkpoint. + pub fn bump(&mut self, migrated_delta: u64) { + self.migrated_blobs = self.migrated_blobs.saturating_add(migrated_delta); + self.recompute_percent(); + } + + fn recompute_percent(&mut self) { + self.percent = if self.total_blobs == 0 { + 0 + } else { + ((self.migrated_blobs.min(self.total_blobs) as u128 * 100) / self.total_blobs as u128) + as u8 + }; + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index b581e78a..ed8cc6a5 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -3,6 +3,7 @@ pub mod di; pub mod errors; pub mod fmt; pub mod locale; +pub mod migration_progress; pub mod mime_detect; pub mod runtime; pub mod stubs; diff --git a/src/infrastructure/services/storage_migration_service.rs b/src/infrastructure/services/storage_migration_service.rs index 5a5f45aa..6436ed47 100644 --- a/src/infrastructure/services/storage_migration_service.rs +++ b/src/infrastructure/services/storage_migration_service.rs @@ -121,6 +121,12 @@ pub struct StorageMigrationService { /// delegates through. blob_backend_hot_swap: Arc, + /// Shared in-memory progress snapshot. `Some(_)` during a + /// running/paused migration, `None` otherwise. Read by the + /// server-status header middleware to broadcast maintenance + /// state to every user's session without polling. + migration_progress: + Arc>>, } impl StorageMigrationService { @@ -135,6 +141,9 @@ impl StorageMigrationService { blob_backend_hot_swap: Arc< crate::infrastructure::services::swappable_blob_backend::SwappableBlobBackend, >, + migration_progress: Arc< + std::sync::RwLock>, + >, ) -> Self { Self { pool, @@ -144,6 +153,7 @@ impl StorageMigrationService { storage_path_fallback, migration_readonly, blob_backend_hot_swap, + migration_progress, } } @@ -384,6 +394,27 @@ impl RecoverableJobHandler for StorageMigrationService { cutover hot-swap completes" ); + // Seed the shared progress snapshot for the header + // middleware. Total blob count is a one-shot SELECT COUNT(*) + // — best-effort; if it fails we still push a snapshot with + // total=0 so the banner at least shows *something* is + // happening. + let total_blobs: u64 = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM storage.blobs") + .fetch_one(self.pool.as_ref()) + .await + .map(|n| n.max(0) as u64) + .unwrap_or(0); + { + let mut guard = self + .migration_progress + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *guard = Some(crate::common::migration_progress::MigrationProgress::new( + target_name.clone(), + total_blobs, + )); + } + let source_kind = self.source.backend_type(); let target_kind = target.backend_type(); tracing::info!( @@ -613,6 +644,19 @@ impl RecoverableJobHandler for StorageMigrationService { message: format!("checkpoint: {e}"), }; } + // Bump the shared progress snapshot so the server-status + // header middleware surfaces fresh numbers on every + // user's next API call. Guard is held only for a struct + // update — microseconds. + { + let mut guard = self + .migration_progress + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(progress) = guard.as_mut() { + progress.bump(batch_len); + } + } if (rows.len() as i64) < BATCH_SIZE { return self @@ -703,6 +747,16 @@ impl StorageMigrationService { .await .is_ok(); self.migration_readonly.store(false, Ordering::Relaxed); + // Clear the shared progress snapshot so the server-status + // header stops emitting on subsequent requests. Guard held + // only for the assignment. + { + let mut guard = self + .migration_progress + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *guard = None; + } if !readonly_persisted { tracing::warn!( diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 8a72d582..10ad9281 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -687,13 +687,24 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // them on every overlapping request. router = router.route("/{*rest}", any(api_not_found)); - // No per-router layers: the global `TraceLayer` + request-id stack in - // `main.rs` wraps the whole app (this `/api` router is nested into it), - // so a second `TraceLayer` here just double-wrapped every `/api` - // request in a redundant span + response-future poll (benches/ROUND13.md - // §H1). Compression is likewise the global layer's job — re-applying it - // here (no predicate) would compress media downloads, burning CPU for - // ~0 gain and stripping `Content-Length`. + // Server-status header. Stamps `X-Server-Status` on every + // response so the frontend's fetch wrapper can update a + // reactive store — banner shows/hides without polling. + // Sub-nanosecond on the hot path (single atomic load), a few + // µs on the cold path (only during a running migration). See + // `middleware::server_status`. + let router = router.layer(axum::middleware::from_fn_with_state( + app_state.clone(), + crate::interfaces::middleware::server_status::server_status_middleware, + )); + + // No per-router layers beyond that: the global `TraceLayer` + request-id + // stack in `main.rs` wraps the whole app (this `/api` router is nested + // into it), so a second `TraceLayer` here just double-wrapped every + // `/api` request in a redundant span + response-future poll + // (benches/ROUND13.md §H1). Compression is likewise the global layer's + // job — re-applying it here (no predicate) would compress media + // downloads, burning CPU for ~0 gain and stripping `Content-Length`. router } diff --git a/src/interfaces/middleware/mod.rs b/src/interfaces/middleware/mod.rs index 2ef51d39..8dbe66e3 100644 --- a/src/interfaces/middleware/mod.rs +++ b/src/interfaces/middleware/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod csrf; pub mod locale; pub mod rate_limit; +pub mod server_status; pub mod trace_span; pub mod trusted_proxy; pub mod user; diff --git a/src/interfaces/middleware/server_status.rs b/src/interfaces/middleware/server_status.rs new file mode 100644 index 00000000..2df6c57a --- /dev/null +++ b/src/interfaces/middleware/server_status.rs @@ -0,0 +1,119 @@ +//! Middleware that stamps `X-Server-Status` on every response. +//! +//! Consumed by the frontend `apiFetch` wrapper — every API round-trip +//! carries the current server maintenance state back to the client +//! (no polling, no dedicated endpoint). The banner in the app shell +//! subscribes to a store the wrapper updates and shows/hides itself +//! reactively. See `docs/plan/storage-multi-entry.md` §"Read-only mode" +//! for the broader design. +//! +//! ## Cost model +//! +//! On the *hot path* (no migration running — the ~100% case in normal +//! operation) this middleware does: +//! 1. one `AtomicBool::load(Relaxed)` — sub-nanosecond; +//! 2. an early return when `false`. +//! +//! No allocation, no lock, no formatting. Adds no measurable latency +//! at any user count. +//! +//! On the *cold path* (migration in progress) this middleware does: +//! 1. the atomic load above; +//! 2. one `RwLock::read` (uncontended — writers are the migration +//! handler, one per batch every ~100 blobs); +//! 3. one small `serde_json::to_string` call on a 4-field struct +//! (a few dozen bytes); +//! 4. one header insertion. +//! +//! Total per-request work in this branch: microseconds. + +use axum::extract::Request; +use axum::extract::State; +use axum::http::HeaderValue; +use axum::middleware::Next; +use axum::response::Response; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use crate::common::di::AppState; + +/// Name of the response header the frontend reads. Kept short — an +/// admin browser session may keep this header around in every open +/// tab's dev-tools network view during a migration; the value is +/// small JSON but the name should not add bloat. +pub const SERVER_STATUS_HEADER: &str = "x-server-status"; + +/// Compact JSON shape written into the header. Fields are documented +/// in `common::migration_progress::MigrationProgress`. +/// +/// Kept internal so the wire format can evolve. Frontend treats the +/// header as opaque JSON and pattern-matches on the fields it +/// currently understands. +#[derive(serde::Serialize)] +struct HeaderPayload { + readonly: bool, + #[serde(skip_serializing_if = "Option::is_none")] + migration: Option, +} + +#[derive(serde::Serialize)] +struct MigrationHeader { + // `target` is owned here — the RwLock guard is released before + // serialisation, so a borrowed slice wouldn't survive. Names + // are small (`[a-z0-9_-]{1,32}`) so the copy is trivial. + target: String, + migrated: u64, + total: u64, + percent: u8, +} + +pub async fn server_status_middleware( + State(state): State>, + request: Request, + next: Next, +) -> Response { + // Hot-path fast return. When no migration is running the flag is + // false and there's nothing to emit — a bare atomic load and out. + let readonly = state.migration_readonly.load(Ordering::Relaxed); + let mut response = next.run(request).await; + if !readonly { + return response; + } + + // Cold path — build the payload from the shared progress + // snapshot. If the snapshot is absent (readonly is true but the + // handler hasn't seeded progress yet, or a restart-during- + // migration scenario) we still emit `readonly: true` so the + // banner shows — the frontend renders a "maintenance in progress" + // message even when specific numbers aren't available. + let payload = { + let guard = state + .migration_progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + HeaderPayload { + readonly: true, + migration: guard.as_ref().map(|p| MigrationHeader { + target: p.target_name.clone(), + migrated: p.migrated_blobs, + total: p.total_blobs, + percent: p.percent, + }), + } + }; + + // `serde_json::to_string` on this 4-field struct is a few + // dozen-byte allocation — negligible against the response body. + // A serialize failure here would be a programming bug (all + // fields are trivially serializable), so we degrade to a + // minimal `readonly: true` string rather than skipping the + // header entirely. + let value = + serde_json::to_string(&payload).unwrap_or_else(|_| r#"{"readonly":true}"#.to_string()); + if let Ok(header_value) = HeaderValue::from_str(&value) { + response + .headers_mut() + .insert(SERVER_STATUS_HEADER, header_value); + } + response +}