mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
feat(client): desktop storage panel folds mirrors into their primaries
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user