Files
TREK/client/src/components/Admin/storage/AdminStoragePanel.tsx
T
Maurice 922f37ab52 fix(storage): ask the hook whether a storage job is running, not a stale closure
Every push to dev has been red since the 4.0.0 bump, always on the same test:
FE-MOB-MSTOR-015, where the second queued migration POSTs while the first is
still running. It passes on the PR runner and fails on the slower push one.

Instrumenting the queue effect logs the whole story:

  DEQUEUE files stateMigrations []
  APPLY refresh ["running"]
  START RESOLVED files error null
  DEQUEUE journey stateMigrations []

The refresh had already landed the running migration before the single-flight
lock was released, exactly as intended. What fires afterwards is the effect of
a commit scheduled BEFORE the POST, so its captured admin.state is the pre-POST
world, and it runs late enough that the lock is gone. The lock is a ref and is
read live; the "is one already running" test was read off that dead closure.
Under load the two drift apart and a second migration starts on top of a
running one.

So the hook answers the question instead. It keeps its world in a ref as well,
written where the response is applied rather than when the render lands, and
storageBusy() reads that. The queue effect still WAKES on admin.state, it just
no longer DECIDES on it, and the server's one-job-at-a-time rule now lives with
the hook that talks to the server rather than twice in two shells. Same shape as
the dirtyRef/savingRef the hook already keeps for the same reason.

An earlier attempt made the panels refresh again after startMigration, which was
both redundant (startMigration has awaited its own refresh since #1949) and no
fix at all: the stale read is on the next effect run, not on that promise.
2026-08-28 00:22:57 +02:00

779 lines
35 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react'
import { Activity, FolderTree, HardDrive } from 'lucide-react'
import {
STORAGE_CATEGORIES,
type StorageBackend,
type StorageCategory,
type StorageConfig,
type StorageMigrationStatus,
type StorageTestResponse,
} from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { formatBytes } from '../../../utils/formatBytes'
import { relativeTime } from '../../../utils/relativeTime'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import { useToast } from '../../shared/Toast'
import Section from '../../Settings/Section'
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
import {
CACHE_CATEGORIES,
adoptedMirrorFor,
computeMigrationCandidates,
effectiveCategoryMap,
foldBackends,
mirrorProbeTargets,
primaryNameOf,
removeBackend,
removeBackendAndMirrors,
renameBackendRefs,
replicaCandidates,
replicaOfPrimaries,
settingsDocumentOf,
setMirrorTargets,
stripCategories,
upsertBackend,
usageByBackend,
type FoldedBackendRow,
type MigrationCandidate,
} from './storageModel'
import { useStorageAdmin } from './useStorageAdmin'
/** Display-name mapper for joined category lists — the raw id renders only in the badge. */
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
ids.map((id) => t(`storage.category.${id}`)).join(', ')
function TestResult({ result, failedLabel, okLabel }: {
result: StorageTestResponse | undefined
failedLabel: string
okLabel: string
}): React.ReactElement | null {
if (result === undefined) return null
return (
<div className="mt-2 space-y-0.5">
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
{result.targets.map((target) => (
<p key={target.name} className="text-xs text-content-faint">
{target.ok ? '✓' : '✗'} {target.name}
{target.error ? ` — ${target.error}` : ''}
</p>
))}
</div>
)
}
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
export default function AdminStoragePanel(): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict'))
const [editing, setEditing] = useState<{
initial: StorageBackend | null
originalName: string | null
mirror: BackendFormMirrorProps
} | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null)
const [syncPrompt, setSyncPrompt] = useState<string | null>(null)
// Open/closed only — never a snapshot of candidates. Storing the candidate
// array here would go stale the moment the operator edits a category while
// the dialog is open; the render below and moveAndSave both recompute
// computeMigrationCandidates(draft, state) fresh instead.
const [migratePromptOpen, setMigratePromptOpen] = useState(false)
const [migrationQueue, setMigrationQueue] = useState<MigrationCandidate[]>([])
// Set by `save` right before it calls admin.save(): the pre-save mirror
// target count per row name. Consumed (and cleared) by the effect below the
// first time `admin.state` changes afterward — never touched otherwise, so
// unrelated state changes (the backfill poll included) are no-ops here.
const pendingPromptCheck = useRef<Map<string, number> | null>(null)
// Synchronous single-flight lock for the queue effect below: `admin.state`
// only reflects a just-started migration once startMigration's awaited
// refreshState() resolves, so `setMigrationQueue(rest)` re-firing the
// effect synchronously (still against the pre-POST `admin.state`) would
// otherwise dequeue and POST the next candidate before the server has
// confirmed the first — a ref (not state) so the guard is visible on that
// very next synchronous re-render, not just after a state-driven one.
const migrationStartInFlight = useRef(false)
useEffect(() => {
if (!pendingPromptCheck.current || !admin.state) return
const before = pendingPromptCheck.current
pendingPromptCheck.current = null
const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state))
const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0))
if (grown) setSyncPrompt(grown.name)
}, [admin.state])
// Queued category migrations run strictly sequentially: once a slot opens
// (no migration currently running), dequeue the next candidate and start
// it. Depends on admin.state so a poll landing a terminal status re-fires
// this without any user interaction. The in-flight lock closes the race
// where setMigrationQueue(rest) re-fires this effect synchronously while
// admin.state still shows the pre-POST world — without it, the next
// candidate could dequeue and POST before the first is server-confirmed.
// Also waits out a running BACKFILL — the server's one-storage-job-at-a-time
// rule spans backfills and migrations alike, so starting while one runs
// would 409, and the queued candidate would be lost (no retry).
//
// A failed startMigration POST would otherwise strand `rest`: it was
// already dequeued into local state before the POST, so on failure nothing
// ever changes `migrationQueue` or `admin.state` again — the effect never
// re-fires and the remaining candidates sit invisibly forever, silently
// never migrated. On failure the whole remaining queue is explicitly
// cleared (never retried — the operator re-triggers via Save), named in a
// toast, and admin.state is refreshed so the panel reflects whatever the
// failed attempt's category ended up as server-side.
useEffect(() => {
if (migrationQueue.length === 0 || !admin.state) return
if (migrationStartInFlight.current) return
if (admin.storageBusy()) return
const [next, ...rest] = migrationQueue
migrationStartInFlight.current = true
setMigrationQueue(rest)
void admin.startMigration(next!.category, next!.toWire).then((error) => {
migrationStartInFlight.current = false
if (error) {
toast.error(error)
if (rest.length > 0) {
toast.error(t('storage.migrate.queueDropped', { categories: categoryNames(t, rest.map((c) => c.category)) }))
setMigrationQueue([])
}
void admin.refreshState().catch(() => {})
}
})
}, [migrationQueue, admin.state])
if (admin.loading) {
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
}
if (!admin.state || !admin.draft) {
return (
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
{admin.loadError || t('common.error')}
</div>
)
}
const { state, draft } = admin
// Pure/cheap — recomputed every render so the migrate-prompt dialog (below)
// never shows a stale candidate set while it's open (fix: migration prompt
// staleness). moveAndSave recomputes independently at confirm time rather
// than reading this render-scoped value, since the click handler and the
// render that produced it are not guaranteed to be the same one.
const migrationCandidates = computeMigrationCandidates(draft, state)
// Duplicate pre-check needs EVERY wire name (hidden mirror names included);
// mirror-target candidates are the visible primaries only.
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draft.backends.map((b) => b.name)])]
const { rows, degenerate } = foldBackends(state, draft)
const effective = effectiveCategoryMap(state, draft)
const usageSums = usageByBackend(state, draft)
const startEdit = (row: FoldedBackendRow) => {
// Editing a built-in creates a settings override row bearing its name —
// the first-class relocation path (merge-by-name).
setEditing({
initial: row.backend,
originalName: row.name,
mirror: {
candidates: replicaCandidates(rows, row.name, row.mirrorTargets),
initialTargets: row.mirrorTargets,
},
})
}
const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => {
const renamedFrom =
editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null
// Widened to StorageConfig: these edit helpers are version-blind (they
// return plain StorageConfig), and admin.setDraft re-attaches the
// draft's own `version` regardless of what shape it's handed.
let next: StorageConfig | null = draft
if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name)
next = upsertBackend(next, backend)
if (mirrorTargets !== undefined) next = setMirrorTargets(state, next, backend.name, mirrorTargets)
admin.setDraft(next)
setEditing(null)
}
const removeMessage = (name: string, isDegenerate: boolean): string => {
const row = rows.find((r) => r.name === name)
const assigned = isDegenerate
? degenerate.find((d) => d.backend.name === name)?.categories ?? []
: row?.categories ?? []
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
return [
t('storage.remove.body', { name }),
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
]
.filter(Boolean)
.join(' ')
}
const setCategory = (category: StorageCategory, primaryName: string) => {
// Picking a mirrored primary routes through its adopted mirror silently.
const target = adoptedMirrorFor(draft, primaryName)?.name ?? primaryName
// The admin state's category record is exhaustive by schema contract.
const stateEntry = state.categories[category]!
const categories = { ...draft.categories }
if (stateEntry.source === 'default' && target === stateEntry.backend) {
delete categories[category] // back to the default → no longer settings-owned
} else {
categories[category] = target
}
admin.setDraft({ ...draft, categories })
}
// Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the
// in-progress `draft`) mirror target counts; the effect above compares
// them against the fresh post-save state to detect newly-added
// replicas. Folding `draft` here would already include the unsaved
// edit and mask the very growth this is meant to detect.
const snapshotMirrorTargets = (): Map<string, number> => {
const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state))
return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length]))
}
const doPlainSave = async () => {
pendingPromptCheck.current = snapshotMirrorTargets()
if (await admin.save()) toast.success(t('storage.saved'))
else pendingPromptCheck.current = null
}
// Recomputes the candidate set at confirm time — never trusts whatever was
// true when the dialog opened. The operator can edit categories (or a
// background poll can land a fresher usage scan) while the dialog is on
// screen; the recomputed set is what actually drives both stripCategories
// (what gets reverted from the PUT body) and the queue (what gets POSTed),
// so the two can never diverge from what the dialog most recently showed.
const moveAndSave = async () => {
const candidates = computeMigrationCandidates(draft, state)
pendingPromptCheck.current = snapshotMirrorTargets()
const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category)))
if (ok) {
setMigrationQueue(candidates)
toast.success(t('storage.saved'))
} else {
pendingPromptCheck.current = null
}
setMigratePromptOpen(false)
}
const routeOnlySave = async () => {
setMigratePromptOpen(false)
await doPlainSave()
}
const save = async () => {
if (migrationCandidates.length > 0) {
setMigratePromptOpen(true)
return
}
await doPlainSave()
}
const handleCancelMigration = async (m: StorageMigrationStatus) => {
const error = await admin.cancelMigration(m.category)
if (error) toast.error(error)
}
const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key]
const handleRefreshStats = async () => {
const error = await admin.refreshStats()
if (error) toast.error(error)
}
const handleStartBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return
setSyncPrompt((prev) => (prev === row.name ? null : prev))
const error = await admin.startBackfill(row.mirrorName)
if (error) toast.error(error)
}
const handleCancelBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return
const error = await admin.cancelBackfill(row.mirrorName)
if (error) toast.error(error)
}
return (
<div>
{state.configError && (
<div
role="alert"
className="rounded-xl border p-3 mb-4 text-sm"
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
>
{t('storage.configError.banner', { error: state.configError })}
</div>
)}
<Section title={t('storage.health.title')} icon={Activity}>
{state.health.replicaFailures.length === 0 ? (
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
) : (
<ul className="space-y-1">
{state.health.replicaFailures.map((failure) => (
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
{t('storage.health.failureLine', {
op: failure.op,
key: failure.key,
backend: failure.backend,
error: failure.error,
})}
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
</li>
))}
</ul>
)}
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
</Section>
<Section title={t('storage.backends.title')} icon={HardDrive}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
<p className="text-xs mb-2 text-content-faint">
{state.usage
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
: t('storage.usage.never')}
{' '}
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
</button>
</p>
<div className="space-y-3">
{rows.map((row) => {
const resultKey = row.mirrorName ?? row.name
const result = testResultFor(resultKey)
// row.mirrorName only exists when foldBackends adopted a draft mirror
// for this row, so the draft lookup below cannot miss.
const testCandidate = row.mirrorName
? draft.backends.find((b) => b.name === row.mirrorName)!
: row.backend
const rowUsage = usageSums?.[row.name]
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
return (
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content">{row.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
{t(`storage.type.${row.type}`)}
</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
{t(`storage.source.${row.source}`)}
</span>
<span className="flex-1" />
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() =>
row.mirrorName
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
: admin.test(testCandidate)
}
>
{t('storage.actions.test')}
</button>
{row.source !== 'env' && (
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
{t('storage.actions.edit')}
</button>
)}
{row.source === 'settings' && (
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
{t('storage.actions.remove')}
</button>
)}
</div>
<p className="text-xs mt-1 text-content-faint">
{row.categories.length > 0
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
: t('storage.backends.unused')}
</p>
{rowUsage && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
? ` (${t('storage.usage.legacyNote')})`
: ''}
</p>
)}
{row.mirrorTargets.length > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
</p>
)}
{row.replicaOf.length > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
</p>
)}
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
{result === 'running' ? (
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
) : (
<TestResult
result={result as StorageTestResponse | undefined}
okLabel={t('storage.test.ok')}
failedLabel={t('storage.test.failed')}
/>
)}
{row.mirrorTargets.length > 0 && (
<div className="mt-2">
{backfill?.status === 'running' ? (
<>
<p className="text-xs text-content-faint">
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
</p>
<p className="text-xs text-content-faint">
{t('storage.sync.counts', {
copied: String(backfill.copied),
skipped: String(backfill.skipped),
failed: String(backfill.failed),
})}
</p>
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => handleCancelBackfill(row)}
>
{t('storage.sync.cancel')}
</button>
</>
) : (
<>
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
a completed/cancelled/errored backfill must stay re-runnable
without a page reload. */}
{backfill?.status === 'done' && (
<p className="text-xs text-content-faint">
{t('storage.sync.done', {
copied: String(backfill.copied),
deleted: String(backfill.deleted),
failed: String(backfill.failed),
})}
</p>
)}
{backfill?.status === 'cancelled' && (
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
)}
{backfill?.status === 'error' && (
<p className="text-xs text-content-faint">
{t('storage.sync.error', { error: backfill.error ?? '' })}
</p>
)}
{syncPrompt !== row.name && (
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
)}
</>
)}
</div>
)}
{syncPrompt === row.name && (
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
<div className="flex items-center gap-3 mt-1">
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setSyncPrompt(null)}
>
{t('storage.sync.dismiss')}
</button>
</div>
</div>
)}
</div>
)
})}
{degenerate.map(({ backend, reason }) => {
const result = testResultFor(backend.name)
const primary = backend.type === 'mirror' ? backend.options.primary : ''
return (
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content">{backend.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
{t('storage.type.mirror')}
</span>
<span className="flex-1" />
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
{t('storage.actions.test')}
</button>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
{t('storage.actions.remove')}
</button>
</div>
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
{result === 'running' ? (
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
) : (
<TestResult
result={result as StorageTestResponse | undefined}
okLabel={t('storage.test.ok')}
failedLabel={t('storage.test.failed')}
/>
)}
</div>
)
})}
</div>
{state.migrations.length > 0 && (
<div className="space-y-3 mt-3">
{state.migrations.map((m) => (
<div
key={m.category}
data-testid={`storage-migration-${m.category}`}
className="rounded-xl border p-4 border-edge-secondary"
>
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
{m.status === 'running' ? (
<>
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.running', {
category: t(`storage.category.${m.category}`),
done: String(m.done),
total: String(m.total),
})}
</p>
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void handleCancelMigration(m)}
>
{t('storage.migrate.cancel')}
</button>
</>
) : m.status === 'done' ? (
<>
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
</p>
{m.failed > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
</p>
)}
{m.reclaimable && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.reclaimable', {
objects: String(m.reclaimable.objects),
size: formatBytes(m.reclaimable.bytes),
from: m.from,
})}
</p>
)}
</>
) : m.status === 'failed' ? (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.failed', { error: m.error ?? '' })}
</p>
) : (
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
)}
</div>
))}
</div>
)}
{editing ? (
<BackendForm
initial={editing.initial}
backendNames={backendNames}
mirror={editing.mirror}
onCommit={commitBackend}
onCancel={() => setEditing(null)}
/>
) : (
<button type="button"
onClick={() =>
setEditing({
initial: null,
originalName: null,
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
})
}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
}}
>
{t('storage.backends.add')}
</button>
)}
</Section>
<Section title={t('storage.categories.title')} icon={FolderTree}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
{STORAGE_CATEGORIES.map((category) => {
// The admin state's category record is exhaustive by schema contract.
const stateEntry = state.categories[category]!
const selectedPrimary = primaryNameOf(state, draft, effective[category])
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
const viaMirror = effective[category] !== selectedPrimary
return (
<div key={category} data-testid={`storage-category-${category}`}>
<label className="block text-sm font-medium mb-1 text-content-secondary">
{t(`storage.category.${category}`)}
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
{category}
</span>
</label>
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
{state.usage?.categories[category] && (
<p className="text-xs mb-1.5 text-content-faint">
{t('storage.usage.line', {
objects: String(state.usage.categories[category]!.objects),
size: formatBytes(state.usage.categories[category]!.bytes),
})}
</p>
)}
<CustomSelect
value={selectedPrimary}
onChange={(value) => setCategory(category, String(value))}
options={rows.map((row) => ({
value: row.name,
label:
stateEntry.source === 'default' && row.name === stateEntry.backend
? `${row.name} (${t('storage.categories.default')})`
: row.name,
}))}
size="sm"
style={{ maxWidth: 320 }}
/>
{changed && (
<p role="alert" className="text-xs mt-1 text-content-faint">
{t('storage.categories.reassignWarning')}
</p>
)}
{viaMirror && CACHE_CATEGORIES.includes(category) && (
<p role="note" className="text-xs mt-1 text-content-faint">
{t('storage.mirror.cacheWarning')}
</p>
)}
</div>
)
})}
</div>
</Section>
<div className="flex items-center gap-3">
<button type="button"
onClick={save}
disabled={!admin.dirty || admin.saving}
style={{
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
}}
>
{t('storage.save')}
</button>
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
</div>
{migrationQueue.length > 0 && (
<p className="text-xs mt-2 text-content-faint">
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
</p>
)}
{migratePromptOpen && (
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
{migrationCandidates.map((c) => (
<p key={c.category} className="text-xs mt-1 text-content-secondary">
{c.objects === null
? t('storage.migrate.promptLineUnknown', {
category: t(`storage.category.${c.category}`),
from: c.from,
to: c.to,
})
: t('storage.migrate.promptLine', {
category: t(`storage.category.${c.category}`),
objects: String(c.objects),
size: formatBytes(c.bytes ?? 0),
from: c.from,
to: c.to,
})}
</p>
))}
<div className="flex items-center gap-3 mt-2">
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void moveAndSave()}
>
{t('storage.migrate.move')}
</button>
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void routeOnlySave()}
>
{t('storage.migrate.routeOnly')}
</button>
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setMigratePromptOpen(false)}
>
{t('storage.migrate.promptCancel')}
</button>
</div>
</div>
)}
{admin.saveError && (
<div role="alert" className="text-sm mt-2 text-content">
<p>{admin.saveError}</p>
{/* A 409 leaves the draft pinned to a version the server has moved
past; "save again" can never clear it, so the banner offers the
one action that can — see useStorageAdmin.discardDraft. */}
{admin.saveConflict && (
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void admin.discardDraft()}
>
{t('storage.discardAndReload')}
</button>
)}
</div>
)}
<ConfirmDialog
isOpen={confirmRemove !== null}
onClose={() => setConfirmRemove(null)}
onConfirm={() => {
if (confirmRemove) {
admin.setDraft(
confirmRemove.degenerate
? removeBackend(draft, confirmRemove.name)
: removeBackendAndMirrors(state, draft, confirmRemove.name),
)
}
setConfirmRemove(null)
}}
title={t('storage.remove.title')}
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
confirmLabel={t('storage.remove.title')}
/>
</div>
)
}