import { useEffect, useRef, useState } from 'react'; import { api, get } from '../lib/api'; import { PageHeader } from '../components/PageHeader'; import { Confirm } from '../components/Confirm'; import { useToast } from '../components/Toast'; import { HardDrive, RefreshCw } from '../components/Icons'; import { num } from '../lib/format'; // Wire shape returned by StorageBackfillController.BackfillStatus. interface BackfillStatus { running: boolean; dryRun: boolean; startedAt: string | null; finishedAt: string | null; total: number; uploaded: number; alreadyMirrored: number; skipped: number; failed: number; lastBlobName: string | null; elapsedMs: number | null; error: string | null; } export function Storage() { const [status, setStatus] = useState(null); const [err, setErr] = useState(null); const [pendingMode, setPendingMode] = useState(null); const pollRef = useRef(null); const toast = useToast(); // Polling cadence: // * 1 s while a run is active so the progress bar feels live. // * 5 s when idle so the page doesn't hammer the server. // Single source of truth — useEffect re-schedules itself based on // the latest status.running. useEffect(() => { const fetchOnce = async () => { try { const s = await get('/storage/backfill/status'); setStatus(s); setErr(null); } catch (e) { setErr((e as Error).message); } }; void fetchOnce(); const interval = status?.running ? 1000 : 5000; pollRef.current = window.setInterval(fetchOnce, interval); return () => { if (pollRef.current) window.clearInterval(pollRef.current); }; }, [status?.running]); const start = async (dryRun: boolean) => { setPendingMode(null); try { await api(`/storage/backfill${dryRun ? '?dryRun=true' : ''}`, { method: 'POST' }); toast.push(dryRun ? 'Dry-run started' : 'Backfill started', 'success'); // Force a status refresh so the UI flips to running immediately. const s = await get('/storage/backfill/status'); setStatus(s); } catch (e) { const msg = (e as Error).message; // 409 already-running is informational, not an error. if (msg.includes('already_running')) toast.push('A backfill is already in progress', 'error'); else toast.push(msg, 'error'); } }; const pct = status && status.total > 0 ? Math.round(((status.uploaded + status.alreadyMirrored + status.skipped + status.failed) / status.total) * 100) : 0; const done = status && !status.running && status.startedAt !== null; return (
} /> {err && (
{err}
)} {status === null && !err && (
Loading status…
)} {status && (
{status.running ? : status.failed > 0 ? Errors : done ? Idle · last run OK : Idle }
{status.running ? (status.dryRun ? 'Dry-run in progress…' : 'Backfill in progress…') : done ? (status.dryRun ? 'Last run: dry-run' : 'Last run: live') : 'No run yet' }
{status.running ? `${pct}%` : status.elapsedMs != null ? `${(status.elapsedMs / 1000).toFixed(1)}s` : ''}
0 ? 'bg-danger' : 'bg-brand-500'}`} style={{ width: `${pct}%` }} />
0 ? 'danger' : undefined} />
{status.lastBlobName && (
last: {status.lastBlobName}
)} {status.error && (
{status.error}
)}

What this does

  • Walks every RoomDataBlobs row whose Bytes column still holds data.
  • Routes each blob through BlobRouter.Route(BlobName) to its canonical S3 (bucket, key).
  • Uploads to S3, HEAD-verifies the object exists, then clears the row's Bytes.
  • A row's bytes are only dropped from the DB after S3 confirms the same key — no data loss path.

When to run

  • Once after the S3-only refactor deploy lands, to drain pre-cutover imports.
  • Idempotent — re-running picks up only rows whose Bytes hasn't been cleared yet.
  • Failed rows leave their DB bytes untouched and get retried on the next run.
)} setPendingMode(null)} title="Run dry-run backfill" body={<>Walks every row, logs the (bucket, key) it WOULD upload to, but doesn't write to S3 or clear the DB. Safe to run anytime — useful to verify the routing before a real run.} confirmLabel="Start dry-run" onConfirm={() => start(true)} /> setPendingMode(null)} title="Run live backfill" body={<>Uploads every row's bytes to S3 and clears the DB Bytes column for each row after HEAD-verifies the S3 object. Safe — re-runs are idempotent, and failures leave bytes intact in the DB. Continue?} confirmLabel="Start backfill" onConfirm={() => start(false)} />
); } function Counter({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'danger' }) { const color = tone === 'success' ? 'text-success' : tone === 'danger' ? 'text-danger' : 'text-ink-50'; return (
{label}
{value}
); }