- {#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