diff --git a/client/src/mobile/screens/admin/MAdminStoragePanel.tsx b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx index 7d8c42ad5..3684fcd70 100644 --- a/client/src/mobile/screens/admin/MAdminStoragePanel.tsx +++ b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx @@ -13,13 +13,19 @@ import { useTranslation } from '../../../i18n' import { useToast } from '../../../components/shared/Toast' import { relativeTime } from '../../../utils/relativeTime' import { - asWireBackend, - categoriesPointingAt, + CACHE_CATEGORIES, + adoptedMirrorFor, + effectiveCategoryMap, + foldBackends, hasPlaintextSecret, - mirrorsReferencing, + primaryNameOf, removeBackend, + removeBackendAndMirrors, + renameBackendRefs, + replicaOfPrimaries, + setMirrorTargets, upsertBackend, - type StateBackend, + type FoldedBackendRow, } from '../../../components/Admin/storage/storageModel' import { useStorageAdmin } from '../../../components/Admin/storage/useStorageAdmin' import MToggle from '../../components/MToggle' @@ -44,19 +50,22 @@ function MBackendForm({ initial, backendNames, encryptionReady, + mirror, onCommit, onCancel, }: { initial: StorageBackend | null backendNames: string[] encryptionReady: boolean - onCommit: (backend: StorageBackend) => void + mirror: { candidates: string[]; initialTargets: string[] } + onCommit: (backend: StorageBackend, mirrorTargets: string[]) => void onCancel: () => void }): React.ReactElement { const { t } = useTranslation() const [type, setType] = useState(initial?.type ?? 'local') const [name, setName] = useState(initial?.name ?? '') const [values, setValues] = useState(() => valuesOf(initial)) + const [targets, setTargets] = useState(mirror.initialTargets) const [picker, setPicker] = useState(null) const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[] @@ -84,7 +93,7 @@ function MBackendForm({ if (text === '' && !field.required) continue options[field.key] = field.kind === 'number' ? Number(text) : text } - onCommit({ name: name.trim(), type, options } as StorageBackend) + onCommit({ name: name.trim(), type, options } as StorageBackend, targets) } return ( @@ -109,7 +118,10 @@ function MBackendForm({ open={picker === 'type'} onClose={() => setPicker(null)} title={t('storage.form.type')} - options={STORAGE_BACKEND_TYPE_IDS.map((id) => ({ value: id, label: t(`storage.type.${id}`) }))} + options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({ + value: id, + label: t(`storage.type.${id}`), + }))} value={type} onSelect={(next) => { setType(next as StorageBackendTypeId) @@ -184,6 +196,30 @@ function MBackendForm({ ) })} + +
+ {mirror.candidates + .filter((candidate) => candidate !== name.trim()) + .map((candidate) => ( +
+ {candidate} + + setTargets(checked ? [...targets, candidate] : targets.filter((existing) => existing !== candidate)) + } + /> +
+ ))} +
+ {targets.length > 0 && ( +

+ {t('storage.mirror.latencyNote')} +

+ )} +
+
{blocked ? (

@@ -206,8 +242,12 @@ export default function MAdminStoragePanel(): React.ReactElement { const { t, locale } = useTranslation() const toast = useToast() const admin = useStorageAdmin(t('common.error')) - const [editing, setEditing] = useState<{ initial: StorageBackend | null; originalName: string | null } | null>(null) - const [confirmRemove, setConfirmRemove] = useState(null) + const [editing, setEditing] = useState<{ + initial: StorageBackend | null + originalName: string | null + mirror: { candidates: string[]; initialTargets: string[] } + } | null>(null) + const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null) const [categoryPicker, setCategoryPicker] = useState(null) if (admin.loading) { @@ -226,60 +266,55 @@ export default function MAdminStoragePanel(): React.ReactElement { } const { state, draft } = admin - const draftNames = draft.backends.map((b) => b.name) - const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draftNames])] - const stateByName = new Map(state.backends.map((b) => [b.name, b])) + 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) - interface Row { - name: string - type: StorageBackend['type'] - source: 'built-in' | 'env' | 'settings' - backend: StorageBackend - categories: readonly string[] - } - const rows: Row[] = [] - for (const b of state.backends) { - if (draftNames.includes(b.name)) continue - if (b.source === 'settings') continue - rows.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b), categories: b.categories }) - } - for (const b of draft.backends) { - const inState = stateByName.get(b.name) as StateBackend | undefined - rows.push({ - name: b.name, - type: b.type, - source: 'settings', - backend: b, - categories: inState?.categories ?? categoriesPointingAt(draft, b.name), + const startEdit = (row: FoldedBackendRow) => { + setEditing({ + initial: row.backend, + originalName: row.name, + mirror: { + candidates: rows.filter((r) => r.name !== row.name).map((r) => r.name), + initialTargets: row.mirrorTargets, + }, }) } - const commitBackend = (backend: StorageBackend) => { + const commitBackend = (backend: StorageBackend, mirrorTargets: string[]) => { const renamedFrom = editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null - const base = renamedFrom ? removeBackend(draft, renamedFrom) : draft - admin.setDraft(upsertBackend(base, backend)) + let next = draft + if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name) + next = upsertBackend(next, backend) + next = setMirrorTargets(state, next, backend.name, mirrorTargets) + admin.setDraft(next) setEditing(null) } - const removeMessage = (name: string): string => { - const assigned = categoriesPointingAt(draft, name) - const mirrors = mirrorsReferencing(draft, name) + 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: assigned.join(', ') }) : '', - mirrors.length > 0 ? t('storage.remove.referencedBy', { backends: mirrors.join(', ') }) : '', + usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '', ] .filter(Boolean) .join(' ') } - const setCategory = (category: StorageCategory, backendName: string) => { - const effective = state.categories[category] + const setCategory = (category: StorageCategory, primaryName: string) => { + 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 (effective.source === 'default' && backendName === effective.backend) { + if (stateEntry.source === 'default' && target === stateEntry.backend) { delete categories[category] } else { - categories[category] = backendName + categories[category] = target } admin.setDraft({ ...draft, categories }) } @@ -318,7 +353,11 @@ export default function MAdminStoragePanel(): React.ReactElement {

{rows.map((row) => { - const result = admin.testResults[row.name] + const resultKey = row.mirrorName ?? row.name + const result = admin.testResults[resultKey] + const testCandidate = row.mirrorName + ? draft.backends.find((b) => b.name === row.mirrorName)! + : row.backend return (
+ {row.mirrorTargets.length > 0 && ( +

+ {t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })} +

+ )} + {row.replicaOf.length > 0 && ( +

+ {t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })} +

+ )} {row.source === 'env' && (

{t('storage.backends.envReadOnly')}

)}
- admin.test(row.backend)}> + admin.test(testCandidate)}> {t('storage.actions.test')} {row.source !== 'env' && ( - setEditing({ initial: row.backend, originalName: row.name })}> + startEdit(row)}> {t('storage.actions.edit')} )} {row.source === 'settings' && ( - setConfirmRemove(row.name)}> + setConfirmRemove({ name: row.name, degenerate: false })}> {t('storage.actions.remove')} )} @@ -377,10 +426,65 @@ export default function MAdminStoragePanel(): React.ReactElement {
) })} + + {degenerate.map(({ backend, reason }) => { + const result = admin.testResults[backend.name] + const primary = backend.type === 'mirror' ? backend.options.primary : '' + return ( +
+
+ {backend.name} + + {t('storage.type.mirror')} + +
+

+ {t(`storage.mirror.degenerate.${reason}`, { primary })} +

+
+ admin.test(backend)}> + {t('storage.actions.test')} + + setConfirmRemove({ name: backend.name, degenerate: true })}> + {t('storage.actions.remove')} + +
+ {result === 'running' ? ( +

{t('storage.test.running')}

+ ) : ( + result && ( +
+

+ {result.ok ? t('storage.test.ok') : t('storage.test.failed')} +

+ {result.targets.map((target) => ( +

+ {target.ok ? '✓' : '✗'} {target.name} + {target.error ? ` — ${target.error}` : ''} +

+ ))} +
+ ) + )} +
+ ) + })}
{editing === null && (
- setEditing({ initial: null, originalName: null })}> + + setEditing({ + initial: null, + originalName: null, + mirror: { candidates: rows.map((r) => r.name), initialTargets: [] }, + }) + } + > {t('storage.backends.add')}
@@ -392,6 +496,7 @@ export default function MAdminStoragePanel(): React.ReactElement { initial={editing.initial} backendNames={backendNames} encryptionReady={state.encryptionReady} + mirror={editing.mirror} onCommit={commitBackend} onCancel={() => setEditing(null)} /> @@ -401,17 +506,19 @@ export default function MAdminStoragePanel(): React.ReactElement {
{STORAGE_CATEGORIES.map((category) => { - const effective = state.categories[category] - const selected = draft.categories[category] ?? effective.backend - const changed = selected !== effective.backend + // 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 (
} onClick={() => setCategoryPicker(category)} @@ -422,6 +529,11 @@ export default function MAdminStoragePanel(): React.ReactElement { {t('storage.categories.reassignWarning')}

)} + {viaMirror && CACHE_CATEGORIES.includes(category) && ( +

+ {t('storage.mirror.cacheWarning')} +

+ )} ) })} @@ -430,8 +542,8 @@ export default function MAdminStoragePanel(): React.ReactElement { open={categoryPicker !== null} onClose={() => setCategoryPicker(null)} title={categoryPicker ? t(`storage.category.${categoryPicker}`) : ''} - options={backendNames.map((name) => ({ value: name, label: name }))} - value={categoryPicker ? (draft.categories[categoryPicker] ?? state.categories[categoryPicker].backend) : ''} + options={rows.map((row) => ({ value: row.name, label: row.name }))} + value={categoryPicker ? primaryNameOf(state, draft, effective[categoryPicker]) : ''} onSelect={(name) => { if (categoryPicker) setCategory(categoryPicker, name) }} @@ -454,12 +566,18 @@ export default function MAdminStoragePanel(): React.ReactElement { open={confirmRemove !== null} onClose={() => setConfirmRemove(null)} title={t('storage.remove.title')} - message={confirmRemove ? removeMessage(confirmRemove) : ''} + message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''} confirmLabel={t('storage.actions.remove')} cancelLabel={t('storage.form.cancel')} danger onConfirm={() => { - if (confirmRemove) admin.setDraft(removeBackend(draft, confirmRemove)) + if (confirmRemove) { + admin.setDraft( + confirmRemove.degenerate + ? removeBackend(draft, confirmRemove.name) + : removeBackendAndMirrors(draft, confirmRemove.name), + ) + } setConfirmRemove(null) }} /> diff --git a/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx b/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx index 11c3f789e..a0ed078de 100644 --- a/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx @@ -35,6 +35,16 @@ function baseState(overrides: Partial = {}): StorageAdminStat }; } +function mirroredState(): StorageAdminState { + const state = baseState(); + state.backends.push({ + name: 'mirror', type: 'mirror', source: 'settings', + options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'], + }); + state.categories.backups = { backend: 'mirror', source: 'settings' }; + return state; +} + async function renderPanel(state: StorageAdminState = baseState()) { server.use(http.get('/api/admin/storage', () => HttpResponse.json(state))); render( @@ -99,4 +109,52 @@ describe('MAdminStoragePanel', () => { expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); }); + + it('FE-MOB-MSTOR-006: mirrors fold — no mirror card, primary and replica decorated', async () => { + await renderPanel(mirroredState()); + expect(screen.queryByTestId('m-storage-backend-mirror')).not.toBeInTheDocument(); + const primary = screen.getByTestId('m-storage-backend-backups-local'); + expect(within(primary).getByText('Mirrored to: off-box')).toBeInTheDocument(); + const replica = screen.getByTestId('m-storage-backend-off-box'); + expect(within(replica).getByText('Replica of: backups-local')).toBeInTheDocument(); + }); + + it('FE-MOB-MSTOR-007: toggling a target on a primary synthesizes the mirror in the PUT', async () => { + let putBody: unknown; + await renderPanel(); + server.use( + http.put('/api/admin/storage', async ({ request }) => { + putBody = await request.json(); + return HttpResponse.json(baseState()); + }), + ); + fireEvent.click(within(screen.getByTestId('m-storage-backend-backups-local')).getByRole('button', { name: 'Edit' })); + fireEvent.click(screen.getByRole('switch', { name: 'off-box' })); + expect(screen.getByText(/slows every upload/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByText('Storage configuration saved'); + const body = putBody as { backends: Array<{ name: string; type: string; options: unknown }>; categories: Record }; + expect(body.backends.find((b) => b.name === 'backups-local-mirror')!.options).toEqual({ + primary: 'backups-local', replicas: ['off-box'], + }); + expect(body.categories.backups).toBe('backups-local-mirror'); + }); + + it('FE-MOB-MSTOR-008: the category picker deals in primaries and warns on cache categories', async () => { + let putBody: unknown; + await renderPanel(mirroredState()); + server.use( + http.put('/api/admin/storage', async ({ request }) => { + putBody = await request.json(); + return HttpResponse.json(mirroredState()); + }), + ); + fireEvent.click(within(screen.getByTestId('m-storage-category-places')).getByRole('button')); + fireEvent.click(within(screen.getByRole('dialog', { name: 'Place images' })).getByRole('button', { name: 'backups-local' })); // picker option = primary name + expect(screen.getByText(/re-fetchable/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByText('Storage configuration saved'); + expect((putBody as { categories: Record }).categories.places).toBe('mirror'); + }); });