From bbede7ab986704655931adbe5cf965ff87eb6971 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 20 Aug 2026 19:45:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(client):=20strip=20removed=20backends=20fro?= =?UTF-8?q?m=20remaining=20mirror=20replicas=20=E2=80=94=20no=20phantom=20?= =?UTF-8?q?refs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/storage/AdminStoragePanel.tsx | 2 +- .../Admin/storage/storageModel.test.ts | 17 ++++++++- .../components/Admin/storage/storageModel.ts | 38 ++++++++++++++++--- .../screens/admin/MAdminStoragePanel.tsx | 4 +- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/client/src/components/Admin/storage/AdminStoragePanel.tsx b/client/src/components/Admin/storage/AdminStoragePanel.tsx index 305e56e51..efd23b7ce 100644 --- a/client/src/components/Admin/storage/AdminStoragePanel.tsx +++ b/client/src/components/Admin/storage/AdminStoragePanel.tsx @@ -354,7 +354,7 @@ export default function AdminStoragePanel(): React.ReactElement { admin.setDraft( confirmRemove.degenerate ? removeBackend(draft, confirmRemove.name) - : removeBackendAndMirrors(draft, confirmRemove.name), + : removeBackendAndMirrors(state, draft, confirmRemove.name), ) } setConfirmRemove(null) diff --git a/client/src/components/Admin/storage/storageModel.test.ts b/client/src/components/Admin/storage/storageModel.test.ts index b6e2ee699..56d145eee 100644 --- a/client/src/components/Admin/storage/storageModel.test.ts +++ b/client/src/components/Admin/storage/storageModel.test.ts @@ -210,11 +210,26 @@ describe('mirror fold/synthesize (replicas-on-primary)', () => { it('FE-ADMIN-STORM-013: removeBackendAndMirrors cascades; replicaOfPrimaries phrases in primary names', () => { const draft = mirroredDraft(); expect(replicaOfPrimaries(draft, 'off-box')).toEqual(['backups-local']); - const removed = removeBackendAndMirrors(draft, 'backups-local'); + const removed = removeBackendAndMirrors(MIRRORED_STATE, draft, 'backups-local'); expect(removed.backends.some((b) => b.name === 'mirror')).toBe(false); expect(removed.backends.some((b) => b.name === 'backups-local')).toBe(false); }); + it('FE-ADMIN-STORM-015: removing a replica strips it from remaining mirrors; a mirror stripped empty dissolves', () => { + // Single-replica mirror: stripping off-box empties it → dissolve + re-point + // (backups is settings-sourced at the mirror in MIRRORED_STATE, so it re-points, not drops). + const dissolved = removeBackendAndMirrors(MIRRORED_STATE, mirroredDraft(), 'off-box'); + expect(dissolved.backends.some((b) => b.name === 'off-box')).toBe(false); + expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false); + expect(dissolved.categories.backups).toBe('backups-local'); + // Multi-replica mirror: stripping one target keeps the mirror alive with the rest. + const twoTargets = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']); + const survived = removeBackendAndMirrors(MIRRORED_STATE, twoTargets, 'off-box'); + const mirror = survived.backends.find((b) => b.type === 'mirror')!; + expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['uploads-local'] }); + expect(survived.categories.backups).toBe('mirror'); // still routed via the surviving mirror + }); + it('FE-ADMIN-STORM-014: primaryNameOf maps mirror names (draft or state) to their primary; others pass through', () => { expect(primaryNameOf(MIRRORED_STATE, mirroredDraft(), 'mirror')).toBe('backups-local'); expect(primaryNameOf(MIRRORED_STATE, { backends: [], categories: {} }, 'mirror')).toBe('backups-local'); // state fallback diff --git a/client/src/components/Admin/storage/storageModel.ts b/client/src/components/Admin/storage/storageModel.ts index 1dc90d6fb..0e1e850ac 100644 --- a/client/src/components/Admin/storage/storageModel.ts +++ b/client/src/components/Admin/storage/storageModel.ts @@ -246,12 +246,40 @@ export function renameBackendRefs(draft: StorageConfig, oldName: string, newName } } -/** Remove a backend AND every mirror wrapping it as primary (they would dangle). */ -export function removeBackendAndMirrors(draft: StorageConfig, name: string): StorageConfig { - return { - ...draft, - backends: draft.backends.filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name)), +/** + * Remove a backend, every mirror wrapping it as primary, and every replica + * reference to it in the remaining mirrors — a mirror stripped of its last + * replica dissolves (with the same category re-pointing as an explicit + * dissolve), so the draft never carries a phantom reference the composer + * cannot render or repair. + */ +export function removeBackendAndMirrors( + state: StorageAdminState, + draft: StorageConfig, + name: string, +): StorageConfig { + const stripped = draft.backends + .filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name)) + .map((b) => + isMirror(b) && b.options.replicas.includes(name) + ? { ...b, options: { ...b.options, replicas: b.options.replicas.filter((r) => r !== name) } } + : b, + ) + let next: StorageConfig = { ...draft, backends: stripped } + for (const mirror of stripped.filter(isMirror)) { + if (mirror.options.replicas.length > 0) continue + if (adoptedMirrorFor(next, mirror.options.primary)?.name === mirror.name) { + next = setMirrorTargets(state, next, mirror.options.primary, []) + } else { + // A degenerate duplicate stripped empty: drop it and re-point its categories. + const categories = { ...next.categories } + for (const category of STORAGE_CATEGORIES) { + if (categories[category] === mirror.name) categories[category] = mirror.options.primary + } + next = { backends: next.backends.filter((b) => b.name !== mirror.name), categories } + } } + return next } /** Primaries whose mirrors use the named backend as a replica — pre-check copy stays in primary names. */ diff --git a/client/src/mobile/screens/admin/MAdminStoragePanel.tsx b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx index 3684fcd70..b9acc228b 100644 --- a/client/src/mobile/screens/admin/MAdminStoragePanel.tsx +++ b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx @@ -355,6 +355,8 @@ export default function MAdminStoragePanel(): React.ReactElement { {rows.map((row) => { const resultKey = row.mirrorName ?? row.name const result = admin.testResults[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 @@ -575,7 +577,7 @@ export default function MAdminStoragePanel(): React.ReactElement { admin.setDraft( confirmRemove.degenerate ? removeBackend(draft, confirmRemove.name) - : removeBackendAndMirrors(draft, confirmRemove.name), + : removeBackendAndMirrors(state, draft, confirmRemove.name), ) } setConfirmRemove(null)