diff --git a/client/src/components/Admin/storage/AdminStoragePanel.test.tsx b/client/src/components/Admin/storage/AdminStoragePanel.test.tsx index a12e024d9..e769d6270 100644 --- a/client/src/components/Admin/storage/AdminStoragePanel.test.tsx +++ b/client/src/components/Admin/storage/AdminStoragePanel.test.tsx @@ -37,6 +37,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; +} + function stubGet(state: StorageAdminState) { server.use(http.get('/api/admin/storage', () => HttpResponse.json(state))); } @@ -153,28 +163,25 @@ describe('AdminStoragePanel', () => { expect((putBody as StorageConfig).backends).toEqual([]); }); - it('FE-ADMIN-STOR-007: Test shows per-target results for a mirror', async () => { - const state = baseState(); - state.backends.push({ - name: 'backup-mirror', type: 'mirror', source: 'settings', - options: { primary: 'backups-local', replicas: ['off-box'] }, categories: [], - }); - await renderPanel(state); + it('FE-ADMIN-STOR-007: Test on a mirrored primary probes the composed mirror per target', async () => { + let postBody: unknown; + await renderPanel(mirroredState()); server.use( - http.post('/api/admin/storage/test', () => - HttpResponse.json({ + http.post('/api/admin/storage/test', async ({ request }) => { + postBody = await request.json(); + return HttpResponse.json({ ok: false, targets: [ { name: 'backups-local', ok: true }, { name: 'off-box', ok: false, error: 'connect ECONNREFUSED' }, ], - }), - ), + }); + }), ); - fireEvent.click(within(backendRow('backup-mirror')).getByRole('button', { name: 'Test' })); - await within(backendRow('backup-mirror')).findByText('Test failed'); - expect(within(backendRow('backup-mirror')).getByText(/backups-local/)).toBeInTheDocument(); - expect(within(backendRow('backup-mirror')).getByText(/connect ECONNREFUSED/)).toBeInTheDocument(); + fireEvent.click(within(backendRow('backups-local')).getByRole('button', { name: 'Test' })); + await within(backendRow('backups-local')).findByText('Test failed'); + expect(within(backendRow('backups-local')).getByText(/connect ECONNREFUSED/)).toBeInTheDocument(); + expect((postBody as { backend: { name: string; type: string } }).backend).toMatchObject({ name: 'mirror', type: 'mirror' }); }); it('FE-ADMIN-STOR-008: the health strip lists replica failures with a relative age; all-clear otherwise', async () => { @@ -207,4 +214,114 @@ describe('AdminStoragePanel', () => { render(); expect(await screen.findByText('This is configured by the operator of this instance.')).toBeInTheDocument(); }); + + it('FE-ADMIN-STOR-011: mirrors fold — no mirror row, primary and replica are decorated, categories union', async () => { + await renderPanel(mirroredState()); + expect(screen.queryByTestId('storage-backend-mirror')).not.toBeInTheDocument(); + const primary = backendRow('backups-local'); + expect(within(primary).getByText('Mirrored to: off-box')).toBeInTheDocument(); + expect(within(primary).getByText(/Used by: .*backups/)).toBeInTheDocument(); + const replica = backendRow('off-box'); + expect(within(replica).getByText('Replica of: backups-local')).toBeInTheDocument(); + }); + + it('FE-ADMIN-STOR-012: checking a target on an unmirrored primary synthesizes the mirror and reroutes its categories', 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(backendRow('backups-local')).getByRole('button', { name: 'Edit' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'off-box' })); + 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 StorageConfig; + expect(body.backends.map((b) => b.name)).toEqual( + expect.arrayContaining(['off-box', 'backups-local', 'backups-local-mirror']), + ); + 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'); // default-sourced category rewritten + }); + + it('FE-ADMIN-STOR-013: editing an already-mirrored primary adopts the foreign-named mirror in place', 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(backendRow('backups-local')).getByRole('button', { name: 'Edit' })); + expect(screen.getByRole('checkbox', { name: 'off-box' })).toBeChecked(); // initialTargets from the fold + fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' })); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByText('Storage configuration saved'); + const mirrors = (putBody as StorageConfig).backends.filter((b) => b.type === 'mirror'); + expect(mirrors).toHaveLength(1); + expect(mirrors[0]!.name).toBe('mirror'); // adopted, not renamed + expect(mirrors[0]!.options).toEqual({ primary: 'backups-local', replicas: ['off-box', 'uploads-local'] }); + }); + + it('FE-ADMIN-STOR-014: unchecking every target dissolves the mirror and re-points its categories', async () => { + let putBody: unknown; + await renderPanel(mirroredState()); + server.use( + http.put('/api/admin/storage', async ({ request }) => { + putBody = await request.json(); + return HttpResponse.json(baseState()); + }), + ); + fireEvent.click(within(backendRow('backups-local')).getByRole('button', { name: 'Edit' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'off-box' })); // uncheck the only target + 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 StorageConfig; + expect(body.backends.some((b) => b.type === 'mirror')).toBe(false); + // backups was settings-sourced at the mirror → re-pointed at the primary, not dropped. + expect(body.categories.backups).toBe('backups-local'); + }); + + it('FE-ADMIN-STOR-015: category selects deal in primaries — picking a mirrored primary writes its mirror, caches get the advisory', async () => { + let putBody: unknown; + await renderPanel(mirroredState()); + server.use( + http.put('/api/admin/storage', async ({ request }) => { + putBody = await request.json(); + return HttpResponse.json(mirroredState()); + }), + ); + // The backups row displays the PRIMARY name, never 'mirror'. + expect(within(categoryRow('backups')).getByText('backups-local')).toBeInTheDocument(); + // Route the places cache through the mirrored primary. + fireEvent.click(within(categoryRow('places')).getByText('place-photos-local (default)')); + const choices = screen.getAllByText('backups-local'); + fireEvent.click(choices[choices.length - 1]!); + expect(within(categoryRow('places')).getByText(/re-fetchable/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByText('Storage configuration saved'); + expect((putBody as StorageConfig).categories.places).toBe('mirror'); // the adopted mirror, under the hood + }); + + it('FE-ADMIN-STOR-016: a second mirror on the same primary renders unfolded with the degenerate note, Test+Remove only', async () => { + const state = mirroredState(); + state.backends.push({ + name: 'mirror2', type: 'mirror', source: 'settings', + options: { primary: 'backups-local', replicas: ['off-box'] }, categories: [], + }); + await renderPanel(state); + const row = screen.getByTestId('storage-backend-mirror2'); + expect(within(row).getByText(/A second mirror wraps backups-local/)).toBeInTheDocument(); + expect(within(row).getByRole('button', { name: 'Test' })).toBeInTheDocument(); + expect(within(row).getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + expect(within(row).queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Admin/storage/AdminStoragePanel.tsx b/client/src/components/Admin/storage/AdminStoragePanel.tsx index 563058e3b..305e56e51 100644 --- a/client/src/components/Admin/storage/AdminStoragePanel.tsx +++ b/client/src/components/Admin/storage/AdminStoragePanel.tsx @@ -7,25 +7,23 @@ import ConfirmDialog from '../../shared/ConfirmDialog' import CustomSelect from '../../shared/CustomSelect' import { useToast } from '../../shared/Toast' import Section from '../../Settings/Section' -import BackendForm from './BackendForm' +import BackendForm, { type BackendFormMirrorProps } from './BackendForm' import { - asWireBackend, - categoriesPointingAt, - mirrorsReferencing, + CACHE_CATEGORIES, + adoptedMirrorFor, + effectiveCategoryMap, + foldBackends, + primaryNameOf, removeBackend, + removeBackendAndMirrors, + renameBackendRefs, + replicaOfPrimaries, + setMirrorTargets, upsertBackend, - type StateBackend, + type FoldedBackendRow, } from './storageModel' import { useStorageAdmin } from './useStorageAdmin' -interface Row { - name: string - type: StorageBackend['type'] - source: 'built-in' | 'env' | 'settings' - backend: StorageBackend - categories: readonly string[] -} - function TestResult({ result, failedLabel, okLabel }: { result: StorageTestResponse | undefined failedLabel: string @@ -45,12 +43,18 @@ function TestResult({ result, failedLabel, okLabel }: { ) } +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')) - 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: BackendFormMirrorProps + } | null>(null) + const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null) if (admin.loading) { return

{t('storage.loading')}

@@ -64,65 +68,61 @@ export default function AdminStoragePanel(): 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])] + // 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) - // The list shows the DRAFT world: built-ins/env from the state (unless a - // draft row overrides the name), settings rows from the draft (unsaved - // edits, additions and removals included). - const stateByName = new Map(state.backends.map((b) => [b.name, b])) - const rows: Row[] = [] - for (const b of state.backends) { - if (draftNames.includes(b.name)) continue // the draft copy renders below - if (b.source === 'settings') continue // removed from the draft, pending save - 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) => { + // 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: rows.filter((r) => r.name !== row.name).map((r) => r.name), + initialTargets: row.mirrorTargets, + }, }) } - const startEdit = (row: Row) => { - // 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 }) - } - - 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) + if (mirrorTargets !== undefined) 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]! - // Exhaustive record: storageAdminStateSchema's `categories` maps all nine - // STORAGE_CATEGORIES entries (server-enforced), so this index is total. + 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 (effective.source === 'default' && backendName === effective.backend) { + if (stateEntry.source === 'default' && target === stateEntry.backend) { delete categories[category] // back to the default → no longer settings-owned } else { - categories[category] = backendName + categories[category] = target } admin.setDraft({ ...draft, categories }) } @@ -131,6 +131,8 @@ export default function AdminStoragePanel(): React.ReactElement { if (await admin.save()) toast.success(t('storage.saved')) } + const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key] + return (
@@ -157,48 +159,99 @@ export default function AdminStoragePanel(): React.ReactElement {

{t('storage.description')}

- {rows.map((row) => ( -
-
- {row.name} - - {t(`storage.type.${row.type}`)} - - - {t(`storage.source.${row.source}`)} - - - - {row.source !== 'env' && ( - + {row.source !== 'env' && ( + + )} + {row.source === 'settings' && ( + + )} +
+

+ {row.categories.length > 0 + ? t('storage.backends.usedBy', { categories: row.categories.join(', ') }) + : t('storage.backends.unused')} +

+ {row.mirrorTargets.length > 0 && ( +

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

)} - {row.source === 'settings' && ( - + {row.replicaOf.length > 0 && ( +

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

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

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

} + {result === 'running' ? ( +

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

+ ) : ( + )}
-

- {row.categories.length > 0 - ? t('storage.backends.usedBy', { categories: row.categories.join(', ') }) - : t('storage.backends.unused')} -

- {row.source === 'env' &&

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

} - {admin.testResults[row.name] === 'running' ? ( -

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

- ) : ( - - )} -
- ))} + ) + })} + + {degenerate.map(({ backend, reason }) => { + const result = testResultFor(backend.name) + const primary = backend.type === 'mirror' ? backend.options.primary : '' + return ( +
+
+ {backend.name} + + {t('storage.type.mirror')} + + + + +
+

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

+ {result === 'running' ? ( +

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

+ ) : ( + + )} +
+ ) + })}
{editing ? ( @@ -206,12 +259,19 @@ export default function AdminStoragePanel(): React.ReactElement { initial={editing.initial} backendNames={backendNames} encryptionReady={state.encryptionReady} + mirror={editing.mirror} onCommit={commitBackend} onCancel={() => setEditing(null)} /> ) : (