feat(client): desktop storage panel folds mirrors into their primaries

This commit is contained in:
jubnl
2026-08-20 19:17:04 +02:00
parent 6e02433b30
commit 30a72f86ee
2 changed files with 309 additions and 120 deletions
@@ -37,6 +37,16 @@ function baseState(overrides: Partial<StorageAdminState> = {}): 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(<AdminStoragePanel />);
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();
});
});
@@ -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<string | null>(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 <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
@@ -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 (
<div>
<Section title={t('storage.health.title')} icon={Activity}>
@@ -157,48 +159,99 @@ export default function AdminStoragePanel(): React.ReactElement {
<Section title={t('storage.backends.title')} icon={HardDrive}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
<div className="space-y-3">
{rows.map((row) => (
<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 className="text-xs underline text-content-secondary" style={{ background: 'none', border: 'none', cursor: 'pointer' }} onClick={() => admin.test(row.backend)}>
{t('storage.actions.test')}
</button>
{row.source !== 'env' && (
<button className="text-xs underline text-content-secondary" style={{ background: 'none', border: 'none', cursor: 'pointer' }} onClick={() => startEdit(row)}>
{t('storage.actions.edit')}
{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
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 className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(testCandidate)}>
{t('storage.actions.test')}
</button>
{row.source !== 'env' && (
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
{t('storage.actions.edit')}
</button>
)}
{row.source === 'settings' && (
<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: row.categories.join(', ') })
: t('storage.backends.unused')}
</p>
{row.mirrorTargets.length > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
</p>
)}
{row.source === 'settings' && (
<button className="text-xs underline text-content-secondary" style={{ background: 'none', border: 'none', cursor: 'pointer' }} onClick={() => setConfirmRemove(row.name)}>
{t('storage.actions.remove')}
</button>
{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')}
/>
)}
</div>
<p className="text-xs mt-1 text-content-faint">
{row.categories.length > 0
? t('storage.backends.usedBy', { categories: row.categories.join(', ') })
: t('storage.backends.unused')}
</p>
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
{admin.testResults[row.name] === 'running' ? (
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
) : (
<TestResult
result={admin.testResults[row.name] as StorageTestResponse | undefined}
okLabel={t('storage.test.ok')}
failedLabel={t('storage.test.failed')}
/>
)}
</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 className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
{t('storage.actions.test')}
</button>
<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>
{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)}
/>
) : (
<button
onClick={() => setEditing({ initial: null, originalName: null })}
onClick={() =>
setEditing({
initial: null,
originalName: null,
mirror: { candidates: rows.map((r) => r.name), initialTargets: [] },
})
}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
@@ -226,24 +286,25 @@ export default function AdminStoragePanel(): React.ReactElement {
<Section title={t('storage.categories.title')} icon={FolderTree}>
<div className="space-y-4">
{STORAGE_CATEGORIES.map((category) => {
const effective = state.categories[category]!
// Exhaustive record — see the setCategory comment above.
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 (
<div key={category} data-testid={`storage-category-${category}`}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t(`storage.category.${category}`)}
</label>
<CustomSelect
value={selected}
value={selectedPrimary}
onChange={(value) => setCategory(category, String(value))}
options={backendNames.map((name) => ({
value: name,
options={rows.map((row) => ({
value: row.name,
label:
effective.source === 'default' && name === effective.backend
? `${name} (${t('storage.categories.default')})`
: name,
stateEntry.source === 'default' && row.name === stateEntry.backend
? `${row.name} (${t('storage.categories.default')})`
: row.name,
}))}
size="sm"
style={{ maxWidth: 320 }}
@@ -253,6 +314,11 @@ export default function AdminStoragePanel(): React.ReactElement {
{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>
)
})}
@@ -284,11 +350,17 @@ export default function AdminStoragePanel(): React.ReactElement {
isOpen={confirmRemove !== null}
onClose={() => setConfirmRemove(null)}
onConfirm={() => {
if (confirmRemove) admin.setDraft(removeBackend(draft, confirmRemove))
if (confirmRemove) {
admin.setDraft(
confirmRemove.degenerate
? removeBackend(draft, confirmRemove.name)
: removeBackendAndMirrors(draft, confirmRemove.name),
)
}
setConfirmRemove(null)
}}
title={t('storage.remove.title')}
message={confirmRemove ? removeMessage(confirmRemove) : ''}
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
confirmLabel={t('storage.remove.title')}
/>
</div>