fix(client): strip removed backends from remaining mirror replicas — no phantom refs

This commit is contained in:
jubnl
2026-08-20 19:45:25 +02:00
parent 42b412eeb4
commit bbede7ab98
4 changed files with 53 additions and 8 deletions
@@ -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)
@@ -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
@@ -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. */
@@ -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)