feat(maintenance): add a maintenance notification during backend migration
This commit is contained in:
@@ -18,6 +18,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getCsrfHeaders } from './csrf';
|
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';
|
const REFRESH_ENDPOINT = '/api/auth/refresh';
|
||||||
|
|
||||||
@@ -93,6 +102,18 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|||||||
const apiFetch: FetchFn = async (input, init) => {
|
const apiFetch: FetchFn = async (input, init) => {
|
||||||
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
|
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
|
||||||
const response = await rawFetch(input, init);
|
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;
|
if (response.status !== 401) return response;
|
||||||
|
|
||||||
const urlStr = urlString(input as RequestInfo | URL);
|
const urlStr = urlString(input as RequestInfo | URL);
|
||||||
@@ -104,7 +125,9 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|||||||
onSessionExpired();
|
onSessionExpired();
|
||||||
throw new Error('Session expired');
|
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;
|
return apiFetch;
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
||||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||||
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
||||||
|
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
|
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
|
||||||
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
||||||
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
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 { apiFetch } from '$lib/api/client';
|
||||||
import { dialogs } from '$lib/stores/dialogs.svelte';
|
import { dialogs } from '$lib/stores/dialogs.svelte';
|
||||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
@@ -1025,6 +1027,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-area">
|
<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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<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
|
* 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
|
* to) a drive whose `policies.read_only === true`. Members see the
|
||||||
@@ -19,33 +21,60 @@
|
|||||||
* - `routes/files/[...path]/+page.svelte` — shown when the current
|
* - `routes/files/[...path]/+page.svelte` — shown when the current
|
||||||
* folder's owning drive is frozen (parent looks up drive via
|
* folder's owning drive is frozen (parent looks up drive via
|
||||||
* `drives.findByRootFolderId`/`findById`).
|
* `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 { t } from '$lib/i18n/index.svelte';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Progress {
|
||||||
/** Drive-name shown in the body so members know which drive the
|
target: string;
|
||||||
* freeze applies to. Optional — omit on pages where the drive is
|
migrated: number;
|
||||||
* implicit from context (e.g. the drive's own config page). */
|
total: number;
|
||||||
driveName?: string;
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="read-only-banner"
|
class="read-only-banner"
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('drive.read_only_banner.aria', 'This drive is read-only')}
|
aria-label={variant === 'maintenance'
|
||||||
data-testid="read-only-banner"
|
? 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">
|
<div class="read-only-banner__icon" aria-hidden="true">
|
||||||
<Icon name="lock" />
|
<Icon name="lock" />
|
||||||
</div>
|
</div>
|
||||||
<div class="read-only-banner__body">
|
<div class="read-only-banner__body">
|
||||||
<strong>
|
<strong>
|
||||||
{#if driveName}
|
{#if variant === 'maintenance'}
|
||||||
|
{t('server_status.readonly_title', 'Server maintenance in progress')}
|
||||||
|
{:else if driveName}
|
||||||
{t(
|
{t(
|
||||||
'drive.read_only_banner.title_named',
|
'drive.read_only_banner.title_named',
|
||||||
{ name: driveName },
|
{ name: driveName },
|
||||||
@@ -56,10 +85,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</strong>
|
</strong>
|
||||||
<span>
|
<span>
|
||||||
{t(
|
{#if variant === 'maintenance'}
|
||||||
'drive.read_only_banner.body',
|
{#if progress}
|
||||||
'Uploads, edits, deletes, renames, sharing and membership changes are refused. Reads and downloads keep working. Contact an administrator to un-freeze the drive.'
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server_status": {
|
||||||
|
"readonly_banner_aria": "صيانة الخادم جارية",
|
||||||
|
"readonly_title": "صيانة الخادم جارية",
|
||||||
|
"readonly_progress": "جارٍ نقل التخزين إلى `{{target}}` — {{percent}}٪ ({{migrated}} / {{total}} كتلة). الرفع وإعادة التسمية والحذف والمشاركة مرفوضة؛ القراءة والتنزيل تعملان بشكل طبيعي.",
|
||||||
|
"readonly_body": "الرفع وإعادة التسمية والحذف والمشاركة مرفوضة مؤقتًا. القراءة والتنزيل يعملان بشكل طبيعي."
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "نظام تخزين سحابي بسيط"
|
"description": "نظام تخزين سحابي بسيط"
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "Minimalistisches Cloud-Speichersystem"
|
"description": "Minimalistisches Cloud-Speichersystem"
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "Minimalist cloud storage system"
|
"description": "Minimalist cloud storage system"
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "Sistema de almacenamiento en la nube minimalista"
|
"description": "Sistema de almacenamiento en la nube minimalista"
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server_status": {
|
||||||
|
"readonly_banner_aria": "نگهداری سرور در حال انجام است",
|
||||||
|
"readonly_title": "نگهداری سرور در حال انجام است",
|
||||||
|
"readonly_progress": "در حال انتقال حافظه به `{{target}}` — {{percent}}٪ ({{migrated}} / {{total}} بلاک). آپلود، تغییر نام، حذف و اشتراکگذاری رد میشوند؛ خواندن و دانلود عادی کار میکنند.",
|
||||||
|
"readonly_body": "آپلود، تغییر نام، حذف و اشتراکگذاری موقتاً رد میشوند. خواندن و دانلود عادی کار میکنند."
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "سیستم ذخیرهسازی ابری سادهگرا"
|
"description": "سیستم ذخیرهسازی ابری سادهگرا"
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "Système de stockage cloud minimaliste"
|
"description": "Système de stockage cloud minimaliste"
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server_status": {
|
||||||
|
"readonly_banner_aria": "सर्वर रखरखाव प्रगति पर है",
|
||||||
|
"readonly_title": "सर्वर रखरखाव प्रगति पर है",
|
||||||
|
"readonly_progress": "स्टोरेज को `{{target}}` पर माइग्रेट किया जा रहा है — {{percent}}% ({{migrated}} / {{total}} ब्लॉब्स)। अपलोड, नाम बदलना, हटाना और साझा करना अस्वीकृत हैं; पढ़ना और डाउनलोड सामान्य रूप से काम करते हैं।",
|
||||||
|
"readonly_body": "अपलोड, नाम बदलना, हटाना और साझा करना अस्थायी रूप से अस्वीकृत हैं। पढ़ना और डाउनलोड सामान्य रूप से काम करते हैं।"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "न्यूनतम क्लाउड स्टोरेज सिस्टम"
|
"description": "न्यूनतम क्लाउड स्टोरेज सिस्टम"
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "Sistema di archiviazione cloud minimalista"
|
"description": "Sistema di archiviazione cloud minimalista"
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server_status": {
|
||||||
|
"readonly_banner_aria": "サーバーメンテナンス中",
|
||||||
|
"readonly_title": "サーバーメンテナンス中",
|
||||||
|
"readonly_progress": "ストレージを `{{target}}` に移行中 — {{percent}}%({{migrated}} / {{total}} ブロブ)。アップロード、名前変更、削除、共有は拒否されます。読み取りとダウンロードは通常どおり動作します。",
|
||||||
|
"readonly_body": "アップロード、名前変更、削除、共有は一時的に拒否されます。読み取りとダウンロードは通常どおり動作します。"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "ミニマリストクラウドストレージシステム"
|
"description": "ミニマリストクラウドストレージシステム"
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server_status": {
|
||||||
|
"readonly_banner_aria": "서버 유지 관리 진행 중",
|
||||||
|
"readonly_title": "서버 유지 관리 진행 중",
|
||||||
|
"readonly_progress": "저장소를 `{{target}}`(으)로 마이그레이션 중 — {{percent}}% ({{migrated}} / {{total}} 블롭). 업로드, 이름 변경, 삭제 및 공유가 거부됩니다. 읽기 및 다운로드는 정상적으로 작동합니다.",
|
||||||
|
"readonly_body": "업로드, 이름 변경, 삭제 및 공유가 일시적으로 거부됩니다. 읽기 및 다운로드는 정상적으로 작동합니다."
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "미니멀리스트 클라우드 스토리지 시스템"
|
"description": "미니멀리스트 클라우드 스토리지 시스템"
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "Minimalistisch cloudopslagsysteem"
|
"description": "Minimalistisch cloudopslagsysteem"
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "Minimalistyczny cloud storage"
|
"description": "Minimalistyczny cloud storage"
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "Sistema de armazenamento em nuvem minimalista"
|
"description": "Sistema de armazenamento em nuvem minimalista"
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server_status": {
|
||||||
|
"readonly_banner_aria": "Идёт обслуживание сервера",
|
||||||
|
"readonly_title": "Идёт обслуживание сервера",
|
||||||
|
"readonly_progress": "Миграция хранилища на `{{target}}` — {{percent}}% ({{migrated}} / {{total}} блобов). Загрузки, переименования, удаления и общий доступ отклоняются; чтение и скачивание работают как обычно.",
|
||||||
|
"readonly_body": "Загрузки, переименования, удаления и общий доступ временно отклоняются. Чтение и скачивание работают как обычно."
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "Минималистичная система облачного хранения"
|
"description": "Минималистичная система облачного хранения"
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server_status": {
|
||||||
|
"readonly_banner_aria": "伺服器維護進行中",
|
||||||
|
"readonly_title": "伺服器維護進行中",
|
||||||
|
"readonly_progress": "正在將儲存遷移至 `{{target}}` — {{percent}}%({{migrated}} / {{total}} 個 blob)。上傳、重新命名、刪除和分享會被拒絕;讀取和下載正常運作。",
|
||||||
|
"readonly_body": "上傳、重新命名、刪除和分享暫時被拒絕。讀取和下載正常運作。"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "極簡雲端儲存系統"
|
"description": "極簡雲端儲存系統"
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"server_status": {
|
||||||
|
"readonly_banner_aria": "服务器维护进行中",
|
||||||
|
"readonly_title": "服务器维护进行中",
|
||||||
|
"readonly_progress": "正在将存储迁移到 `{{target}}` — {{percent}}%({{migrated}} / {{total}} 个 blob)。上传、重命名、删除和共享被拒绝;读取和下载正常工作。",
|
||||||
|
"readonly_body": "上传、重命名、删除和共享暂时被拒绝。读取和下载正常工作。"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"title": "OxiCloud",
|
"title": "OxiCloud",
|
||||||
"description": "极简云存储系统"
|
"description": "极简云存储系统"
|
||||||
|
|||||||
@@ -2028,6 +2028,7 @@ impl AppServiceFactory {
|
|||||||
crate::infrastructure::services::webdav_dead_property_store::create_dead_property_store(pool.clone()),
|
crate::infrastructure::services::webdav_dead_property_store::create_dead_property_store(pool.clone()),
|
||||||
authorization: authorization.clone(),
|
authorization: authorization.clone(),
|
||||||
migration_readonly: migration_readonly.clone(),
|
migration_readonly: migration_readonly.clone(),
|
||||||
|
migration_progress: Arc::new(std::sync::RwLock::new(None)),
|
||||||
drive_repo: drive_repo.clone(),
|
drive_repo: drive_repo.clone(),
|
||||||
drive_management_service: Arc::new(
|
drive_management_service: Arc::new(
|
||||||
crate::application::services::drive_management_service::DriveManagementService::new(
|
crate::application::services::drive_management_service::DriveManagementService::new(
|
||||||
@@ -2254,6 +2255,7 @@ impl AppServiceFactory {
|
|||||||
self.storage_path.clone(),
|
self.storage_path.clone(),
|
||||||
app_state.migration_readonly.clone(),
|
app_state.migration_readonly.clone(),
|
||||||
app_state.core.blob_backend_hot_swap.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)
|
.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`
|
/// memory in sync. See `docs/plan/storage-multi-entry.md`
|
||||||
/// §"Read-only mode".
|
/// §"Read-only mode".
|
||||||
pub migration_readonly: Arc<std::sync::atomic::AtomicBool>,
|
pub migration_readonly: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
/// 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<std::sync::RwLock<Option<crate::common::migration_progress::MigrationProgress>>>,
|
||||||
/// Drive entity repository — `GET /api/drives`, the personal-drive
|
/// Drive entity repository — `GET /api/drives`, the personal-drive
|
||||||
/// lifecycle hook, and (post-D2) shared-drive creation flow all read
|
/// lifecycle hook, and (post-D2) shared-drive creation flow all read
|
||||||
/// through this. Backing table is `storage.drives`; membership is
|
/// through this. Backing table is `storage.drives`; membership is
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod di;
|
|||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod fmt;
|
pub mod fmt;
|
||||||
pub mod locale;
|
pub mod locale;
|
||||||
|
pub mod migration_progress;
|
||||||
pub mod mime_detect;
|
pub mod mime_detect;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod stubs;
|
pub mod stubs;
|
||||||
|
|||||||
@@ -121,6 +121,12 @@ pub struct StorageMigrationService {
|
|||||||
/// delegates through.
|
/// delegates through.
|
||||||
blob_backend_hot_swap:
|
blob_backend_hot_swap:
|
||||||
Arc<crate::infrastructure::services::swappable_blob_backend::SwappableBlobBackend>,
|
Arc<crate::infrastructure::services::swappable_blob_backend::SwappableBlobBackend>,
|
||||||
|
/// 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<std::sync::RwLock<Option<crate::common::migration_progress::MigrationProgress>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StorageMigrationService {
|
impl StorageMigrationService {
|
||||||
@@ -135,6 +141,9 @@ impl StorageMigrationService {
|
|||||||
blob_backend_hot_swap: Arc<
|
blob_backend_hot_swap: Arc<
|
||||||
crate::infrastructure::services::swappable_blob_backend::SwappableBlobBackend,
|
crate::infrastructure::services::swappable_blob_backend::SwappableBlobBackend,
|
||||||
>,
|
>,
|
||||||
|
migration_progress: Arc<
|
||||||
|
std::sync::RwLock<Option<crate::common::migration_progress::MigrationProgress>>,
|
||||||
|
>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
@@ -144,6 +153,7 @@ impl StorageMigrationService {
|
|||||||
storage_path_fallback,
|
storage_path_fallback,
|
||||||
migration_readonly,
|
migration_readonly,
|
||||||
blob_backend_hot_swap,
|
blob_backend_hot_swap,
|
||||||
|
migration_progress,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +394,27 @@ impl RecoverableJobHandler for StorageMigrationService {
|
|||||||
cutover hot-swap completes"
|
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 source_kind = self.source.backend_type();
|
||||||
let target_kind = target.backend_type();
|
let target_kind = target.backend_type();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -613,6 +644,19 @@ impl RecoverableJobHandler for StorageMigrationService {
|
|||||||
message: format!("checkpoint: {e}"),
|
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 {
|
if (rows.len() as i64) < BATCH_SIZE {
|
||||||
return self
|
return self
|
||||||
@@ -703,6 +747,16 @@ impl StorageMigrationService {
|
|||||||
.await
|
.await
|
||||||
.is_ok();
|
.is_ok();
|
||||||
self.migration_readonly.store(false, Ordering::Relaxed);
|
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 {
|
if !readonly_persisted {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|||||||
@@ -687,13 +687,24 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
// them on every overlapping request.
|
// them on every overlapping request.
|
||||||
router = router.route("/{*rest}", any(api_not_found));
|
router = router.route("/{*rest}", any(api_not_found));
|
||||||
|
|
||||||
// No per-router layers: the global `TraceLayer` + request-id stack in
|
// Server-status header. Stamps `X-Server-Status` on every
|
||||||
// `main.rs` wraps the whole app (this `/api` router is nested into it),
|
// response so the frontend's fetch wrapper can update a
|
||||||
// so a second `TraceLayer` here just double-wrapped every `/api`
|
// reactive store — banner shows/hides without polling.
|
||||||
// request in a redundant span + response-future poll (benches/ROUND13.md
|
// Sub-nanosecond on the hot path (single atomic load), a few
|
||||||
// §H1). Compression is likewise the global layer's job — re-applying it
|
// µs on the cold path (only during a running migration). See
|
||||||
// here (no predicate) would compress media downloads, burning CPU for
|
// `middleware::server_status`.
|
||||||
// ~0 gain and stripping `Content-Length`.
|
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
|
router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod auth;
|
|||||||
pub mod csrf;
|
pub mod csrf;
|
||||||
pub mod locale;
|
pub mod locale;
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
|
pub mod server_status;
|
||||||
pub mod trace_span;
|
pub mod trace_span;
|
||||||
pub mod trusted_proxy;
|
pub mod trusted_proxy;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
@@ -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<MigrationHeader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Arc<AppState>>,
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user