mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(storage): Test on a mirrored phone storage row probes the backends on the form
On the phone storage panel, Test on a mirrored backend sent the mirror itself to POST /api/admin/storage/test. The server expands a mirror by looking its primary and replicas up by name in the SAVED storage config, so the phone tested the saved settings instead of what the operator had just edited, and a replica added but not saved yet made the whole test fail with "references unknown backend". The desktop probes the primary and each replica with their draft options and merges the results. Cause: the phone called admin.test with the mirror candidate, while the desktop called admin.testMirror with mirrorProbeTargets. The choice lived in each shell's markup. Fix: useStoragePanel now offers loaded.testRow(row), which tests a plain row as it is and a mirrored row through admin.testMirror with mirrorProbeTargets from the draft. Both shells call it. Red test: FE-MOB-MSTOR-018 (new, MAdminStoragePanel.test.tsx) failed with "AssertionError: expected [ 'mirror' ] to deeply equal [ 'backups-local', 'off-box' ]". FE-ADMIN-STOR-PANEL-017 (new, useStoragePanel.test.ts) covers testRow itself. No existing assertion changed.
This commit is contained in:
@@ -38,7 +38,7 @@ import {
|
|||||||
StatusPill,
|
StatusPill,
|
||||||
} from '../../Settings/settingsKit'
|
} from '../../Settings/settingsKit'
|
||||||
import BackendForm from './BackendForm'
|
import BackendForm from './BackendForm'
|
||||||
import { CACHE_CATEGORIES, mirrorProbeTargets, primaryNameOf } from './storageModel'
|
import { CACHE_CATEGORIES, primaryNameOf } from './storageModel'
|
||||||
import { categoryNames, migrationPromptLine, useStoragePanel } from './useStoragePanel'
|
import { categoryNames, migrationPromptLine, useStoragePanel } from './useStoragePanel'
|
||||||
|
|
||||||
/** The compact buttons of a backend row: white on a hairline, a size under the card's own. */
|
/** The compact buttons of a backend row: white on a hairline, a size under the card's own. */
|
||||||
@@ -123,7 +123,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
state, draft, migrationCandidates, backendNames, rows, degenerate, effective, usageSums, startAdd, startEdit,
|
state, draft, migrationCandidates, backendNames, rows, degenerate, effective, usageSums, startAdd, startEdit, testRow,
|
||||||
commitBackend, removeMessage, confirmRemoval, setCategory, save, moveAndSave, routeOnlySave,
|
commitBackend, removeMessage, confirmRemoval, setCategory, save, moveAndSave, routeOnlySave,
|
||||||
} = loaded
|
} = loaded
|
||||||
|
|
||||||
@@ -202,11 +202,6 @@ export default function AdminStoragePanel(): React.ReactElement {
|
|||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const resultKey = row.mirrorName ?? row.name
|
const resultKey = row.mirrorName ?? row.name
|
||||||
const result = testResultFor(resultKey)
|
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
|
|
||||||
const rowUsage = usageSums?.[row.name]
|
const rowUsage = usageSums?.[row.name]
|
||||||
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
||||||
const TypeIcon = TYPE_ICON[row.type] ?? HardDrive
|
const TypeIcon = TYPE_ICON[row.type] ?? HardDrive
|
||||||
@@ -234,11 +229,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
className={ROW_BUTTON}
|
className={ROW_BUTTON}
|
||||||
style={fs(12, 'body')}
|
style={fs(12, 'body')}
|
||||||
onClick={() =>
|
onClick={() => void testRow(row)}
|
||||||
row.mirrorName
|
|
||||||
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
|
|
||||||
: admin.test(testCandidate)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<PlugZap size={13} strokeWidth={2.2} />
|
<PlugZap size={13} strokeWidth={2.2} />
|
||||||
{t('storage.actions.test')}
|
{t('storage.actions.test')}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-ADMIN-STOR-PANEL-001 to -012: the storage panel logic behind both admin shells.
|
// FE-ADMIN-STOR-PANEL-001 to -017: the storage panel logic behind both admin shells.
|
||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
import { STORAGE_CATEGORIES, type StorageAdminState, type StorageBackend, type StorageConfig } from '@trek/shared';
|
import { STORAGE_CATEGORIES, type StorageAdminState, type StorageBackend, type StorageConfig } from '@trek/shared';
|
||||||
@@ -218,6 +218,27 @@ describe('useStoragePanel', () => {
|
|||||||
expect(fake.admin.startMigration).toHaveBeenCalledTimes(1);
|
expect(fake.admin.startMigration).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-STOR-PANEL-017: testRow probes a plain row as it is and a mirrored row per draft target', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const draft: StorageConfig = {
|
||||||
|
...settingsDocumentOf(state),
|
||||||
|
backends: [
|
||||||
|
...settingsDocumentOf(state).backends,
|
||||||
|
{ name: 'off-box-mirror', type: 'mirror', options: { primary: 'off-box', replicas: ['uploads-local'] } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
fake.admin = makeAdmin(state, draft);
|
||||||
|
const { result } = renderHook(() => useStoragePanel());
|
||||||
|
const rows = result.current.loaded!.rows;
|
||||||
|
void result.current.loaded!.testRow(rows.find((r) => r.name === 'uploads-local')!);
|
||||||
|
expect(fake.admin.test).toHaveBeenCalledWith(expect.objectContaining({ name: 'uploads-local' }));
|
||||||
|
void result.current.loaded!.testRow(rows.find((r) => r.name === 'off-box')!);
|
||||||
|
expect(fake.admin.testMirror).toHaveBeenCalledWith('off-box-mirror', [
|
||||||
|
expect.objectContaining({ name: 'off-box', type: 'local' }),
|
||||||
|
expect.objectContaining({ name: 'uploads-local', type: 'local' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-ADMIN-STOR-PANEL-013: backfill and stats actions toast the server error', async () => {
|
it('FE-ADMIN-STOR-PANEL-013: backfill and stats actions toast the server error', async () => {
|
||||||
fake.admin = makeAdmin(baseState());
|
fake.admin = makeAdmin(baseState());
|
||||||
vi.mocked(fake.admin.refreshStats).mockResolvedValueOnce('scan failed');
|
vi.mocked(fake.admin.refreshStats).mockResolvedValueOnce('scan failed');
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
computeMigrationCandidates,
|
computeMigrationCandidates,
|
||||||
effectiveCategoryMap,
|
effectiveCategoryMap,
|
||||||
foldBackends,
|
foldBackends,
|
||||||
|
mirrorProbeTargets,
|
||||||
removeBackend,
|
removeBackend,
|
||||||
removeBackendAndMirrors,
|
removeBackendAndMirrors,
|
||||||
renameBackendRefs,
|
renameBackendRefs,
|
||||||
@@ -225,6 +226,17 @@ export function useStoragePanel() {
|
|||||||
setEditing(null);
|
setEditing(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A mirrored row probes its primary and each replica one by one, with the draft
|
||||||
|
// options of each: the server would resolve a mirror stub against its SAVED
|
||||||
|
// backends by name, so unsaved edits (or a replica not saved yet) would be missed.
|
||||||
|
// row.mirrorName only exists when foldBackends adopted a draft mirror for this
|
||||||
|
// row, so the draft lookup cannot miss.
|
||||||
|
const testRow = (row: FoldedBackendRow) => {
|
||||||
|
if (!row.mirrorName) return admin.test(row.backend);
|
||||||
|
const mirror = draft.backends.find((b) => b.name === row.mirrorName)!;
|
||||||
|
return admin.testMirror(row.mirrorName, mirrorProbeTargets(draft, state, mirror));
|
||||||
|
};
|
||||||
|
|
||||||
const removeMessage = (name: string, isDegenerate: boolean): string => {
|
const removeMessage = (name: string, isDegenerate: boolean): string => {
|
||||||
const row = rows.find((r) => r.name === name);
|
const row = rows.find((r) => r.name === name);
|
||||||
const assigned = isDegenerate
|
const assigned = isDegenerate
|
||||||
@@ -333,6 +345,7 @@ export function useStoragePanel() {
|
|||||||
usageSums,
|
usageSums,
|
||||||
startAdd,
|
startAdd,
|
||||||
startEdit,
|
startEdit,
|
||||||
|
testRow,
|
||||||
commitBackend,
|
commitBackend,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
confirmRemoval,
|
confirmRemoval,
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
state, draft, backendNames, rows, degenerate, effective, usageSums, startAdd, startEdit, commitBackend,
|
state, draft, backendNames, rows, degenerate, effective, usageSums, startAdd, startEdit, testRow, commitBackend,
|
||||||
removeMessage, confirmRemoval, setCategory, save, moveAndSave, routeOnlySave,
|
removeMessage, confirmRemoval, setCategory, save, moveAndSave, routeOnlySave,
|
||||||
} = loaded
|
} = loaded
|
||||||
|
|
||||||
@@ -236,11 +236,6 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
|||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const resultKey = row.mirrorName ?? row.name
|
const resultKey = row.mirrorName ?? row.name
|
||||||
const result = admin.testResults[resultKey]
|
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
|
|
||||||
const rowUsage = usageSums?.[row.name]
|
const rowUsage = usageSums?.[row.name]
|
||||||
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
||||||
return (
|
return (
|
||||||
@@ -285,7 +280,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
|||||||
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">{t('storage.backends.envReadOnly')}</p>
|
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">{t('storage.backends.envReadOnly')}</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<MAdminButton variant="ghost" onClick={() => admin.test(testCandidate)}>
|
<MAdminButton variant="ghost" onClick={() => void testRow(row)}>
|
||||||
{t('storage.actions.test')}
|
{t('storage.actions.test')}
|
||||||
</MAdminButton>
|
</MAdminButton>
|
||||||
{row.source !== 'env' && (
|
{row.source !== 'env' && (
|
||||||
|
|||||||
@@ -441,4 +441,26 @@ describe('MAdminStoragePanel', () => {
|
|||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
expect(screen.queryByText(/Existing objects are not replicated yet/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Existing objects are not replicated yet/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-MOB-MSTOR-018: Test on a mirrored primary probes the draft primary and each replica, never the mirror stub', async () => {
|
||||||
|
const posted: Array<{ name: string; type: string; options: Record<string, unknown> }> = [];
|
||||||
|
await renderPanel(mirroredState());
|
||||||
|
server.use(
|
||||||
|
http.post('/api/admin/storage/test', async ({ request }) => {
|
||||||
|
const body = (await request.json()) as { backend: { name: string; type: string; options: Record<string, unknown> } };
|
||||||
|
posted.push(body.backend);
|
||||||
|
return HttpResponse.json({ ok: true, targets: [{ name: body.backend.name, ok: true }] });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// An unsaved endpoint change on the replica must be what the probe sees.
|
||||||
|
fireEvent.click(within(screen.getByTestId('m-storage-backend-off-box')).getByRole('button', { name: 'Edit' }));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('http://127.0.0.1:9000'), { target: { value: 'http://edited.example:9000' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||||
|
fireEvent.click(within(screen.getByTestId('m-storage-backend-backups-local')).getByRole('button', { name: 'Test' }));
|
||||||
|
await waitFor(() => expect(posted.length).toBeGreaterThan(0));
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(posted.map((b) => b.name).sort()).toEqual(['backups-local', 'off-box']);
|
||||||
|
expect(posted.every((b) => b.type !== 'mirror')).toBe(true);
|
||||||
|
expect(posted.find((b) => b.name === 'off-box')?.options.endpoint).toBe('http://edited.example:9000');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user