From a99b0c5b328323293f9c876240527b534de227b3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:29:57 +0200 Subject: [PATCH] refactor(storage): share the storage panel and backend editor logic between desktop and phone useStoragePanel holds the editor, removal and sync prompts, the save with its migrate prompt and the sequential migration queue; useBackendForm holds the backend draft, its checks and the backend it builds. The desktop keeps dropping the rest of the queue when a migration fails to start, and keeps recomputing the candidates at confirm time; the phone keeps moving the candidates its prompt opened with. --- .../Admin/storage/AdminStoragePanel.tsx | 310 ++------------- .../components/Admin/storage/BackendForm.tsx | 76 +--- .../Admin/storage/useBackendForm.test.ts | 92 +++++ .../Admin/storage/useBackendForm.ts | 105 +++++ .../Admin/storage/useStoragePanel.test.ts | 263 ++++++++++++ .../Admin/storage/useStoragePanel.ts | 375 ++++++++++++++++++ .../screens/admin/MAdminStoragePanel.tsx | 323 ++------------- 7 files changed, 893 insertions(+), 651 deletions(-) create mode 100644 client/src/components/Admin/storage/useBackendForm.test.ts create mode 100644 client/src/components/Admin/storage/useBackendForm.ts create mode 100644 client/src/components/Admin/storage/useStoragePanel.test.ts create mode 100644 client/src/components/Admin/storage/useStoragePanel.ts diff --git a/client/src/components/Admin/storage/AdminStoragePanel.tsx b/client/src/components/Admin/storage/AdminStoragePanel.tsx index acae89b84..275af1c5a 100644 --- a/client/src/components/Admin/storage/AdminStoragePanel.tsx +++ b/client/src/components/Admin/storage/AdminStoragePanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useId, useRef, useState } from 'react' +import React, { useId } from 'react' import { Activity, AlertTriangle, @@ -20,20 +20,12 @@ import { X, type LucideIcon, } from 'lucide-react' -import { - STORAGE_CATEGORIES, - type StorageBackend, - type StorageCategory, - type StorageConfig, - type StorageMigrationStatus, - type StorageTestResponse, -} from '@trek/shared' +import { STORAGE_CATEGORIES, type StorageTestResponse } from '@trek/shared' import { useTranslation } from '../../../i18n' import { formatBytes } from '../../../utils/formatBytes' import { relativeTime } from '../../../utils/relativeTime' import ConfirmDialog from '../../shared/ConfirmDialog' import CustomSelect from '../../shared/CustomSelect' -import { useToast } from '../../shared/Toast' import { Tooltip } from '../../shared/Tooltip' import { NEUTRAL_TINT, fs } from '../../shared/DialogShell' import Section from '../../Settings/Section' @@ -45,33 +37,9 @@ import { SettingsHint, StatusPill, } from '../../Settings/settingsKit' -import BackendForm, { type BackendFormMirrorProps } from './BackendForm' -import { - CACHE_CATEGORIES, - adoptedMirrorFor, - computeMigrationCandidates, - effectiveCategoryMap, - foldBackends, - mirrorProbeTargets, - primaryNameOf, - removeBackend, - removeBackendAndMirrors, - renameBackendRefs, - replicaCandidates, - replicaOfPrimaries, - settingsDocumentOf, - setMirrorTargets, - stripCategories, - upsertBackend, - usageByBackend, - type FoldedBackendRow, - type MigrationCandidate, -} from './storageModel' -import { useStorageAdmin } from './useStorageAdmin' - -/** Display-name mapper for joined category lists — the raw id renders only in the badge. */ -const categoryNames = (t: (key: string) => string, ids: readonly string[]): string => - ids.map((id) => t(`storage.category.${id}`)).join(', ') +import BackendForm from './BackendForm' +import { CACHE_CATEGORIES, mirrorProbeTargets, primaryNameOf } from './storageModel' +import { categoryNames, migrationPromptLine, useStoragePanel } from './useStoragePanel' /** The compact buttons of a backend row: white on a hairline, a size under the card's own. */ const ROW_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50' @@ -125,84 +93,17 @@ function Progress({ done, total }: { done: number; total: number }): React.React export default function AdminStoragePanel(): React.ReactElement { const { t, locale } = useTranslation() - const toast = useToast() - const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict')) - const [editing, setEditing] = useState<{ - initial: StorageBackend | null - originalName: string | null - mirror: BackendFormMirrorProps - } | null>(null) - const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null) - const [syncPrompt, setSyncPrompt] = useState(null) - // Open/closed only — never a snapshot of candidates. Storing the candidate - // array here would go stale the moment the operator edits a category while - // the dialog is open; the render below and moveAndSave both recompute - // computeMigrationCandidates(draft, state) fresh instead. - const [migratePromptOpen, setMigratePromptOpen] = useState(false) - const [migrationQueue, setMigrationQueue] = useState([]) - // Set by `save` right before it calls admin.save(): the pre-save mirror - // target count per row name. Consumed (and cleared) by the effect below the - // first time `admin.state` changes afterward — never touched otherwise, so - // unrelated state changes (the backfill poll included) are no-ops here. - const pendingPromptCheck = useRef | null>(null) - // Synchronous single-flight lock for the queue effect below: `admin.state` - // only reflects a just-started migration once startMigration's awaited - // refreshState() resolves, so `setMigrationQueue(rest)` re-firing the - // effect synchronously (still against the pre-POST `admin.state`) would - // otherwise dequeue and POST the next candidate before the server has - // confirmed the first — a ref (not state) so the guard is visible on that - // very next synchronous re-render, not just after a state-driven one. - const migrationStartInFlight = useRef(false) + const { + admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt, + closeMigratePrompt, migrationQueue, handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded, + } = useStoragePanel({ dropQueueOnFailure: true }) + // Open/closed only: the render below and moveAndSave both recompute + // computeMigrationCandidates(draft, state) fresh rather than reading the + // candidates the prompt opened with, which go stale the moment the operator + // edits a category while the dialog is open. + const migratePromptOpen = migratePrompt !== null const migrateTitleId = useId() - useEffect(() => { - if (!pendingPromptCheck.current || !admin.state) return - const before = pendingPromptCheck.current - pendingPromptCheck.current = null - const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state)) - const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0)) - if (grown) setSyncPrompt(grown.name) - }, [admin.state]) - - // Queued category migrations run strictly sequentially: once a slot opens - // (no migration currently running), dequeue the next candidate and start - // it. Depends on admin.state so a poll landing a terminal status re-fires - // this without any user interaction. The in-flight lock closes the race - // where setMigrationQueue(rest) re-fires this effect synchronously while - // admin.state still shows the pre-POST world — without it, the next - // candidate could dequeue and POST before the first is server-confirmed. - // Also waits out a running BACKFILL — the server's one-storage-job-at-a-time - // rule spans backfills and migrations alike, so starting while one runs - // would 409, and the queued candidate would be lost (no retry). - // - // A failed startMigration POST would otherwise strand `rest`: it was - // already dequeued into local state before the POST, so on failure nothing - // ever changes `migrationQueue` or `admin.state` again — the effect never - // re-fires and the remaining candidates sit invisibly forever, silently - // never migrated. On failure the whole remaining queue is explicitly - // cleared (never retried — the operator re-triggers via Save), named in a - // toast, and admin.state is refreshed so the panel reflects whatever the - // failed attempt's category ended up as server-side. - useEffect(() => { - if (migrationQueue.length === 0 || !admin.state) return - if (migrationStartInFlight.current) return - if (admin.storageBusy()) return - const [next, ...rest] = migrationQueue - migrationStartInFlight.current = true - setMigrationQueue(rest) - void admin.startMigration(next!.category, next!.toWire).then((error) => { - migrationStartInFlight.current = false - if (error) { - toast.error(error) - if (rest.length > 0) { - toast.error(t('storage.migrate.queueDropped', { categories: categoryNames(t, rest.map((c) => c.category)) })) - setMigrationQueue([]) - } - void admin.refreshState().catch(() => {}) - } - }) - }, [migrationQueue, admin.state]) - if (admin.loading) { return (
@@ -211,7 +112,7 @@ export default function AdminStoragePanel(): React.ReactElement {
) } - if (!admin.state || !admin.draft) { + if (!loaded) { return (
@@ -221,151 +122,13 @@ export default function AdminStoragePanel(): React.ReactElement {
) } - const { state, draft } = admin - - // Pure/cheap — recomputed every render so the migrate-prompt dialog (below) - // never shows a stale candidate set while it's open (fix: migration prompt - // staleness). moveAndSave recomputes independently at confirm time rather - // than reading this render-scoped value, since the click handler and the - // render that produced it are not guaranteed to be the same one. - const migrationCandidates = computeMigrationCandidates(draft, state) - - // 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) - const usageSums = usageByBackend(state, draft) - - 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: replicaCandidates(rows, row.name, row.mirrorTargets), - initialTargets: row.mirrorTargets, - }, - }) - } - - const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => { - const renamedFrom = - editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null - // Widened to StorageConfig: these edit helpers are version-blind (they - // return plain StorageConfig), and admin.setDraft re-attaches the - // draft's own `version` regardless of what shape it's handed. - let next: StorageConfig | null = 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, 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: categoryNames(t, assigned) }) : '', - usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '', - ] - .filter(Boolean) - .join(' ') - } - - 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 (stateEntry.source === 'default' && target === stateEntry.backend) { - delete categories[category] // back to the default → no longer settings-owned - } else { - categories[category] = target - } - admin.setDraft({ ...draft, categories }) - } - - // Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the - // in-progress `draft`) mirror target counts; the effect above compares - // them against the fresh post-save state to detect newly-added - // replicas. Folding `draft` here would already include the unsaved - // edit and mask the very growth this is meant to detect. - const snapshotMirrorTargets = (): Map => { - const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state)) - return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length])) - } - - const doPlainSave = async () => { - pendingPromptCheck.current = snapshotMirrorTargets() - if (await admin.save()) toast.success(t('storage.saved')) - else pendingPromptCheck.current = null - } - - // Recomputes the candidate set at confirm time — never trusts whatever was - // true when the dialog opened. The operator can edit categories (or a - // background poll can land a fresher usage scan) while the dialog is on - // screen; the recomputed set is what actually drives both stripCategories - // (what gets reverted from the PUT body) and the queue (what gets POSTed), - // so the two can never diverge from what the dialog most recently showed. - const moveAndSave = async () => { - const candidates = computeMigrationCandidates(draft, state) - pendingPromptCheck.current = snapshotMirrorTargets() - const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category))) - if (ok) { - setMigrationQueue(candidates) - toast.success(t('storage.saved')) - } else { - pendingPromptCheck.current = null - } - setMigratePromptOpen(false) - } - - const routeOnlySave = async () => { - setMigratePromptOpen(false) - await doPlainSave() - } - - const save = async () => { - if (migrationCandidates.length > 0) { - setMigratePromptOpen(true) - return - } - await doPlainSave() - } - - const handleCancelMigration = async (m: StorageMigrationStatus) => { - const error = await admin.cancelMigration(m.category) - if (error) toast.error(error) - } + const { + state, draft, migrationCandidates, backendNames, rows, degenerate, effective, usageSums, startAdd, startEdit, + commitBackend, removeMessage, confirmRemoval, setCategory, save, moveAndSave, routeOnlySave, + } = loaded const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key] - const handleRefreshStats = async () => { - const error = await admin.refreshStats() - if (error) toast.error(error) - } - - const handleStartBackfill = async (row: FoldedBackendRow) => { - if (!row.mirrorName) return - setSyncPrompt((prev) => (prev === row.name ? null : prev)) - const error = await admin.startBackfill(row.mirrorName) - if (error) toast.error(error) - } - - const handleCancelBackfill = async (row: FoldedBackendRow) => { - if (!row.mirrorName) return - const error = await admin.cancelBackfill(row.mirrorName) - if (error) toast.error(error) - } - return (
{state.configError && ( @@ -413,13 +176,7 @@ export default function AdminStoragePanel(): React.ReactElement { action={ editing ? undefined : ( @@ -904,16 +649,7 @@ export default function AdminStoragePanel(): React.ReactElement { setConfirmRemove(null)} - onConfirm={() => { - if (confirmRemove) { - admin.setDraft( - confirmRemove.degenerate - ? removeBackend(draft, confirmRemove.name) - : removeBackendAndMirrors(state, draft, confirmRemove.name), - ) - } - setConfirmRemove(null) - }} + onConfirm={confirmRemoval} title={t('storage.remove.title')} message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''} confirmLabel={t('storage.remove.title')} diff --git a/client/src/components/Admin/storage/BackendForm.tsx b/client/src/components/Admin/storage/BackendForm.tsx index 4728c7429..e05e13f12 100644 --- a/client/src/components/Admin/storage/BackendForm.tsx +++ b/client/src/components/Admin/storage/BackendForm.tsx @@ -1,7 +1,6 @@ -import React, { useId, useState } from 'react' +import React, { useId } from 'react' import { HardDrive } from 'lucide-react' import { - STORAGE_BACKEND_TYPES, STORAGE_BACKEND_TYPE_IDS, type StorageBackend, type StorageBackendFieldDef, @@ -21,17 +20,7 @@ import { fs, } from '../../shared/DialogShell' import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts' - -type FieldValues = Record - -function valuesOf(backend: StorageBackend | null): FieldValues { - if (!backend) return {} - const values: FieldValues = {} - for (const [key, value] of Object.entries(backend.options)) { - values[key] = Array.isArray(value) ? value : String(value) - } - return values -} +import { useBackendForm } from './useBackendForm' export interface BackendFormMirrorProps { /** Selectable replica targets (the form excludes its own name at render). */ @@ -76,40 +65,13 @@ export default function BackendForm({ const { t } = useTranslation() const labelId = useId() const fieldId = (key: string) => `${labelId}-${key}` - const [type, setType] = useState(initial?.type ?? 'local') - const [name, setName] = useState(initial?.name ?? '') - const [values, setValues] = useState(() => valuesOf(initial)) - const [targets, setTargets] = useState(mirror?.initialTargets ?? []) - - const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[] - const refOptions = backendNames.filter((candidate) => candidate !== name.trim()) - const setValue = (key: string, value: string | string[]) => - setValues((prev) => ({ ...prev, [key]: value })) - - const filled = (field: StorageBackendFieldDef): boolean => { - const value = values[field.key] - if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0 - return typeof value === 'string' && value.trim() !== '' - } - const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim()) - const canApply = - name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f)) + const { + type, changeType, name, setName, values, setValue, toggleRef, targets, toggleTarget, fields, refOptions, + duplicate, canApply, buildBackend, visibleCandidates, + } = useBackendForm({ initial, backendNames, mirror }) const apply = () => { - const options: Record = {} - for (const field of fields) { - const value = values[field.key] - if (field.kind === 'backend-ref-list') { - options[field.key] = Array.isArray(value) ? value : [] - continue - } - const text = typeof value === 'string' ? value : '' - if (text === '' && !field.required) continue // omitted → the shared schema default applies - options[field.key] = field.kind === 'number' ? Number(text) : text - } - // The options were built from the same field defs the schema is generated - // from — this is the same sanctioned cast storageModel.asWireBackend makes. - const payload = { name: name.trim(), type, options } as StorageBackend + const payload = buildBackend() // Arity matters: the landed tests pin single-argument calls when no mirror // block is supplied (toHaveBeenCalledWith treats a trailing undefined as a // mismatch), so the second argument exists only when the composer does. @@ -143,14 +105,7 @@ export default function BackendForm({ type="checkbox" className={CHECKBOX} checked={selected.includes(candidate)} - onChange={(e) => - setValue( - field.key, - e.target.checked - ? [...selected, candidate] - : selected.filter((existing) => existing !== candidate), - ) - } + onChange={(e) => toggleRef(field.key, candidate, e.target.checked)} /> {candidate} @@ -181,8 +136,6 @@ export default function BackendForm({ ) } - const visibleCandidates = mirror ? mirror.candidates.filter((candidate) => candidate !== name.trim()) : [] - return ( { - setType(next as StorageBackendTypeId) - setValues({}) // a different type has different fields - }} + onChange={(next) => changeType(next as StorageBackendTypeId)} options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({ value: id, label: t(`storage.type.${id}`), @@ -259,13 +209,7 @@ export default function BackendForm({ type="checkbox" className={CHECKBOX} checked={targets.includes(candidate)} - onChange={(e) => - setTargets( - e.target.checked - ? [...targets, candidate] - : targets.filter((existing) => existing !== candidate), - ) - } + onChange={(e) => toggleTarget(candidate, e.target.checked)} /> {candidate} diff --git a/client/src/components/Admin/storage/useBackendForm.test.ts b/client/src/components/Admin/storage/useBackendForm.test.ts new file mode 100644 index 000000000..fb1402598 --- /dev/null +++ b/client/src/components/Admin/storage/useBackendForm.test.ts @@ -0,0 +1,92 @@ +// FE-ADMIN-STOR-FORM-HOOK-001 to -007: the backend editor logic behind both admin shells. +import { act, renderHook } from '@testing-library/react'; + +import { STORAGE_BACKEND_TYPES, type StorageBackend, type StorageBackendFieldDef } from '@trek/shared'; + +import { useBackendForm } from './useBackendForm'; + +const NAMES = ['uploads-local', 'off-box']; +const LOCAL: StorageBackend = { name: 'off-box', type: 'local', options: { root: '/mnt' } }; + +function fieldsOf(type: keyof typeof STORAGE_BACKEND_TYPES) { + return STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]; +} + +describe('useBackendForm', () => { + it('FE-ADMIN-STOR-FORM-HOOK-001: a new backend starts empty on the local type', () => { + const { result } = renderHook(() => useBackendForm({ initial: null, backendNames: NAMES })); + expect(result.current.type).toBe('local'); + expect(result.current.name).toBe(''); + expect(result.current.values).toEqual({}); + expect(result.current.targets).toEqual([]); + expect(result.current.visibleCandidates).toEqual([]); + expect(result.current.canApply).toBe(false); + }); + + it('FE-ADMIN-STOR-FORM-HOOK-002: an existing backend seeds its options as strings', () => { + const initial: StorageBackend = { name: 'off-box', type: 'local', options: { root: '/mnt' } }; + const { result } = renderHook(() => useBackendForm({ initial, backendNames: NAMES })); + expect(result.current.name).toBe('off-box'); + expect(result.current.values).toEqual({ root: '/mnt' }); + expect(result.current.duplicate).toBe(false); + expect(result.current.refOptions).toEqual(['uploads-local']); + }); + + it('FE-ADMIN-STOR-FORM-HOOK-003: another backend name is a duplicate and blocks apply', () => { + const { result } = renderHook(() => useBackendForm({ initial: LOCAL, backendNames: NAMES })); + act(() => result.current.setName(' uploads-local ')); + expect(result.current.duplicate).toBe(true); + expect(result.current.canApply).toBe(false); + }); + + it('FE-ADMIN-STOR-FORM-HOOK-004: changing the type clears the option values', () => { + const { result } = renderHook(() => useBackendForm({ initial: null, backendNames: NAMES })); + act(() => result.current.setValue('root', '/x')); + act(() => result.current.changeType('s3')); + expect(result.current.type).toBe('s3'); + expect(result.current.values).toEqual({}); + expect(result.current.fields).toBe(fieldsOf('s3')); + }); + + it('FE-ADMIN-STOR-FORM-HOOK-005: buildBackend trims the name, skips empty optional fields and reads numbers', () => { + const { result } = renderHook(() => useBackendForm({ initial: null, backendNames: NAMES })); + const required = fieldsOf('local').filter((f) => f.required); + act(() => result.current.setName(' fresh ')); + act(() => { + for (const field of required) result.current.setValue(field.key, '/srv'); + }); + expect(result.current.canApply).toBe(true); + const built = result.current.buildBackend(); + expect(built.name).toBe('fresh'); + expect(built.type).toBe('local'); + expect(Object.keys(built.options).sort()).toEqual(required.map((f) => f.key).sort()); + + act(() => result.current.changeType('s3')); + act(() => result.current.setValue('retries', '42')); + expect((result.current.buildBackend().options as Record).retries).toBe(42); + }); + + it('FE-ADMIN-STOR-FORM-HOOK-006: toggleRef adds and drops one backend of a list field', () => { + const { result } = renderHook(() => useBackendForm({ initial: null, backendNames: NAMES })); + act(() => result.current.toggleRef('replicas', 'off-box', true)); + expect(result.current.values.replicas).toEqual(['off-box']); + act(() => result.current.toggleRef('replicas', 'uploads-local', true)); + act(() => result.current.toggleRef('replicas', 'off-box', false)); + expect(result.current.values.replicas).toEqual(['uploads-local']); + }); + + it('FE-ADMIN-STOR-FORM-HOOK-007: mirror targets start from the composer and hide the own name', () => { + const { result } = renderHook(() => + useBackendForm({ + initial: LOCAL, + backendNames: NAMES, + mirror: { candidates: ['off-box', 'uploads-local', 'cold'], initialTargets: ['cold'] }, + }) + ); + expect(result.current.targets).toEqual(['cold']); + expect(result.current.visibleCandidates).toEqual(['uploads-local', 'cold']); + act(() => result.current.toggleTarget('uploads-local', true)); + act(() => result.current.toggleTarget('cold', false)); + expect(result.current.targets).toEqual(['uploads-local']); + }); +}); diff --git a/client/src/components/Admin/storage/useBackendForm.ts b/client/src/components/Admin/storage/useBackendForm.ts new file mode 100644 index 000000000..58efa4e4d --- /dev/null +++ b/client/src/components/Admin/storage/useBackendForm.ts @@ -0,0 +1,105 @@ +import { useState } from 'react'; + +import { + STORAGE_BACKEND_TYPES, + type StorageBackend, + type StorageBackendFieldDef, + type StorageBackendTypeId, +} from '@trek/shared'; + +type FieldValues = Record; + +function valuesOf(backend: StorageBackend | null): FieldValues { + if (!backend) return {}; + const values: FieldValues = {}; + for (const [key, value] of Object.entries(backend.options)) { + values[key] = Array.isArray(value) ? value : String(value); + } + return values; +} + +interface UseBackendFormOptions { + /** null = new backend */ + initial: StorageBackend | null; + /** Every defined backend name: mirror-target options and the duplicate pre-check. */ + backendNames: string[]; + /** The Mirror-targets composer's inputs, when the shell shows one. */ + mirror?: { candidates: string[]; initialTargets: string[] }; +} + +/** + * The storage backend editor behind both admin shells (the desktop dialog and the phone + * card render their own fields over it). Holds the draft name, type, option values and + * mirror targets, checks the draft against the type's declared fields, and builds the + * backend the shell commits. Which arguments the commit gets stays with each shell. + */ +export function useBackendForm({ initial, backendNames, mirror }: UseBackendFormOptions) { + const [type, setType] = useState(initial?.type ?? 'local'); + const [name, setName] = useState(initial?.name ?? ''); + const [values, setValues] = useState(() => valuesOf(initial)); + const [targets, setTargets] = useState(mirror?.initialTargets ?? []); + + const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]; + const refOptions = backendNames.filter((candidate) => candidate !== name.trim()); + const setValue = (key: string, value: string | string[]) => setValues((prev) => ({ ...prev, [key]: value })); + + const changeType = (next: StorageBackendTypeId) => { + setType(next); + setValues({}); // a different type has different fields + }; + + /** Adds or drops one backend from a `backend-ref-list` field. */ + const toggleRef = (key: string, candidate: string, checked: boolean) => { + const value = values[key]; + const selected = Array.isArray(value) ? value : []; + setValue(key, checked ? [...selected, candidate] : selected.filter((existing) => existing !== candidate)); + }; + + const toggleTarget = (candidate: string, checked: boolean) => + setTargets(checked ? [...targets, candidate] : targets.filter((existing) => existing !== candidate)); + + const filled = (field: StorageBackendFieldDef): boolean => { + const value = values[field.key]; + if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0; + return typeof value === 'string' && value.trim() !== ''; + }; + const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim()); + const canApply = name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f)); + + const buildBackend = (): StorageBackend => { + const options: Record = {}; + for (const field of fields) { + const value = values[field.key]; + if (field.kind === 'backend-ref-list') { + options[field.key] = Array.isArray(value) ? value : []; + continue; + } + const text = typeof value === 'string' ? value : ''; + if (text === '' && !field.required) continue; // omitted, so the shared schema default applies + options[field.key] = field.kind === 'number' ? Number(text) : text; + } + // The options were built from the same field defs the schema is generated + // from, the same sanctioned cast storageModel.asWireBackend makes. + return { name: name.trim(), type, options } as StorageBackend; + }; + + const visibleCandidates = mirror ? mirror.candidates.filter((candidate) => candidate !== name.trim()) : []; + + return { + type, + changeType, + name, + setName, + values, + setValue, + toggleRef, + targets, + toggleTarget, + fields, + refOptions, + duplicate, + canApply, + buildBackend, + visibleCandidates, + }; +} diff --git a/client/src/components/Admin/storage/useStoragePanel.test.ts b/client/src/components/Admin/storage/useStoragePanel.test.ts new file mode 100644 index 000000000..b446e18e6 --- /dev/null +++ b/client/src/components/Admin/storage/useStoragePanel.test.ts @@ -0,0 +1,263 @@ +// FE-ADMIN-STOR-PANEL-001 to -012: the storage panel logic behind both admin shells. +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { STORAGE_CATEGORIES, type StorageAdminState, type StorageBackend, type StorageConfig } from '@trek/shared'; + +import { removeBackend, settingsDocumentOf } from './storageModel'; +import type { StorageAdmin } from './useStorageAdmin'; +import { migrationPromptLine, useStoragePanel } from './useStoragePanel'; + +const fake = vi.hoisted(() => ({ admin: null as unknown as StorageAdmin })); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('./useStorageAdmin', () => ({ useStorageAdmin: () => fake.admin })); +vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../../i18n', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => (params ? `${key} ${JSON.stringify(params)}` : key), + }), +})); + +function baseState(): StorageAdminState { + return { + backends: [ + { name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data' }, categories: [] }, + { name: 'off-box', type: 'local', source: 'settings', options: { root: '/mnt' }, categories: [] }, + ], + categories: Object.fromEntries( + STORAGE_CATEGORIES.map((category) => [category, { backend: 'uploads-local', source: 'default' }]) + ) as StorageAdminState['categories'], + health: { replicaFailures: [] }, + seedFilePresent: false, + usage: null, + backfills: [], + migrations: [], + version: 3, + configError: null, + }; +} + +function makeAdmin(state: StorageAdminState | null, draft?: StorageConfig): StorageAdmin { + return { + state, + storageBusy: vi.fn(() => false), + draft: state ? { ...(draft ?? settingsDocumentOf(state)), version: state.version } : null, + dirty: false, + loading: false, + loadError: null, + saveError: null, + saveConflict: false, + saving: false, + testResults: {}, + setDraft: vi.fn(), + save: vi.fn(async () => true), + test: vi.fn(async () => {}), + testMirror: vi.fn(async () => {}), + startBackfill: vi.fn(async () => null), + cancelBackfill: vi.fn(async () => null), + startMigration: vi.fn(async () => null), + cancelMigration: vi.fn(async () => null), + refreshStats: vi.fn(async () => null), + refreshState: vi.fn(async () => {}), + discardDraft: vi.fn(async () => {}), + }; +} + +/** A draft that moves the given categories onto `off-box`. */ +function movingDraft(...categories: string[]): StorageConfig { + const state = baseState(); + return { + ...settingsDocumentOf(state), + categories: Object.fromEntries(categories.map((c) => [c, 'off-box'])) as StorageConfig['categories'], + }; +} + +beforeEach(() => { + toast.success.mockReset(); + toast.error.mockReset(); +}); + +describe('useStoragePanel', () => { + it('FE-ADMIN-STOR-PANEL-001: nothing is loaded until the state and the draft exist', () => { + fake.admin = makeAdmin(null); + const { result } = renderHook(() => useStoragePanel()); + expect(result.current.loaded).toBeNull(); + }); + + it('FE-ADMIN-STOR-PANEL-002: a loaded panel folds the rows and lists every backend name', () => { + fake.admin = makeAdmin(baseState()); + const { result } = renderHook(() => useStoragePanel()); + expect(result.current.loaded?.rows.map((r) => r.name)).toEqual(['uploads-local', 'off-box']); + expect(result.current.loaded?.backendNames).toEqual(['uploads-local', 'off-box']); + expect(result.current.loaded?.migrationCandidates).toEqual([]); + }); + + it('FE-ADMIN-STOR-PANEL-003: add and edit open the editor with the right mirror candidates', () => { + fake.admin = makeAdmin(baseState()); + const { result } = renderHook(() => useStoragePanel()); + act(() => result.current.loaded!.startAdd()); + expect(result.current.editing).toMatchObject({ initial: null, originalName: null, mirror: { initialTargets: [] } }); + const row = result.current.loaded!.rows[1]!; + act(() => result.current.loaded!.startEdit(row)); + expect(result.current.editing).toMatchObject({ initial: row.backend, originalName: 'off-box' }); + }); + + it('FE-ADMIN-STOR-PANEL-004: a rename commits the renamed backend and closes the editor', () => { + fake.admin = makeAdmin(baseState()); + const { result } = renderHook(() => useStoragePanel()); + act(() => result.current.loaded!.startEdit(result.current.loaded!.rows[1]!)); + const renamed: StorageBackend = { name: 'off-site', type: 'local', options: { root: '/mnt' } }; + act(() => result.current.loaded!.commitBackend(renamed)); + const next = vi.mocked(fake.admin.setDraft).mock.calls[0]![0]; + expect(next.backends.map((b) => b.name)).toEqual(['off-site']); + expect(result.current.editing).toBeNull(); + }); + + it('FE-ADMIN-STOR-PANEL-005: a category back on its default drops the override, any other sets it', () => { + fake.admin = makeAdmin(baseState(), movingDraft('files')); + const { result } = renderHook(() => useStoragePanel()); + act(() => result.current.loaded!.setCategory('files', 'uploads-local')); + expect(vi.mocked(fake.admin.setDraft).mock.calls[0]![0].categories).toEqual({}); + act(() => result.current.loaded!.setCategory('covers', 'off-box')); + expect(vi.mocked(fake.admin.setDraft).mock.calls[1]![0].categories).toEqual({ + files: 'off-box', + covers: 'off-box', + }); + }); + + it('FE-ADMIN-STOR-PANEL-006: confirming a removal drops the backend and closes the confirm', () => { + fake.admin = makeAdmin(baseState()); + const { result } = renderHook(() => useStoragePanel()); + act(() => result.current.setConfirmRemove({ name: 'off-box', degenerate: true })); + expect(result.current.loaded!.removeMessage('off-box', true)).toBe('storage.remove.body {"name":"off-box"}'); + act(() => result.current.loaded!.confirmRemoval()); + expect(fake.admin.setDraft).toHaveBeenCalledWith(removeBackend(fake.admin.draft!, 'off-box')); + expect(result.current.confirmRemove).toBeNull(); + }); + + it('FE-ADMIN-STOR-PANEL-007: a save with nothing to move saves straight away and says so', async () => { + fake.admin = makeAdmin(baseState()); + const { result } = renderHook(() => useStoragePanel()); + await act(async () => { + await result.current.loaded!.save(); + }); + expect(fake.admin.save).toHaveBeenCalledWith(); + expect(toast.success).toHaveBeenCalledWith('storage.saved'); + expect(result.current.migratePrompt).toBeNull(); + }); + + it('FE-ADMIN-STOR-PANEL-008: a save that moves a category asks first, then saves without it and queues the move', async () => { + fake.admin = makeAdmin(baseState(), movingDraft('files')); + const { result } = renderHook(() => useStoragePanel()); + await act(async () => { + await result.current.loaded!.save(); + }); + expect(fake.admin.save).not.toHaveBeenCalled(); + expect(result.current.migratePrompt?.map((c) => c.category)).toEqual(['files']); + await act(async () => { + await result.current.loaded!.moveAndSave(); + }); + expect(vi.mocked(fake.admin.save).mock.calls[0]![0]!.categories).toEqual({}); + expect(result.current.migratePrompt).toBeNull(); + await waitFor(() => expect(fake.admin.startMigration).toHaveBeenCalledWith('files', 'off-box')); + expect(toast.success).toHaveBeenCalledWith('storage.saved'); + }); + + it('FE-ADMIN-STOR-PANEL-009: moveAndSave moves exactly the candidates a shell hands it', async () => { + fake.admin = makeAdmin(baseState(), movingDraft('files', 'covers')); + const { result } = renderHook(() => useStoragePanel()); + const [files] = result.current.loaded!.migrationCandidates; + await act(async () => { + await result.current.loaded!.moveAndSave([files!]); + }); + expect(vi.mocked(fake.admin.save).mock.calls[0]![0]!.categories).toEqual({ covers: 'off-box' }); + await waitFor(() => expect(fake.admin.startMigration).toHaveBeenCalledTimes(1)); + }); + + it('FE-ADMIN-STOR-PANEL-010: route only closes the prompt and saves the draft as it is', async () => { + fake.admin = makeAdmin(baseState(), movingDraft('files')); + const { result } = renderHook(() => useStoragePanel()); + await act(async () => { + await result.current.loaded!.save(); + }); + await act(async () => { + await result.current.loaded!.routeOnlySave(); + }); + expect(fake.admin.save).toHaveBeenCalledWith(); + expect(result.current.migratePrompt).toBeNull(); + act(() => result.current.closeMigratePrompt()); + expect(result.current.migratePrompt).toBeNull(); + }); + + it('FE-ADMIN-STOR-PANEL-011: by default a failed migration start only toasts and keeps the rest queued', async () => { + fake.admin = makeAdmin(baseState(), movingDraft('files', 'covers')); + vi.mocked(fake.admin.startMigration).mockResolvedValueOnce('busy'); + const { result } = renderHook(() => useStoragePanel()); + await act(async () => { + await result.current.loaded!.moveAndSave(); + }); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('busy')); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(result.current.migrationQueue).toHaveLength(1); + expect(fake.admin.refreshState).not.toHaveBeenCalled(); + }); + + it('FE-ADMIN-STOR-PANEL-012: with dropQueueOnFailure a failed start drops the rest, names it, and refreshes', async () => { + fake.admin = makeAdmin(baseState(), movingDraft('files', 'covers')); + vi.mocked(fake.admin.startMigration).mockResolvedValueOnce('busy'); + const { result } = renderHook(() => useStoragePanel({ dropQueueOnFailure: true })); + await act(async () => { + await result.current.loaded!.moveAndSave(); + }); + await waitFor(() => expect(fake.admin.refreshState).toHaveBeenCalled()); + expect(toast.error).toHaveBeenNthCalledWith(1, 'busy'); + expect(toast.error).toHaveBeenNthCalledWith( + 2, + 'storage.migrate.queueDropped {"categories":"storage.category.covers"}' + ); + expect(result.current.migrationQueue).toEqual([]); + expect(fake.admin.startMigration).toHaveBeenCalledTimes(1); + }); + + it('FE-ADMIN-STOR-PANEL-013: backfill and stats actions toast the server error', async () => { + fake.admin = makeAdmin(baseState()); + vi.mocked(fake.admin.refreshStats).mockResolvedValueOnce('scan failed'); + vi.mocked(fake.admin.startBackfill).mockResolvedValueOnce('no mirror'); + const { result } = renderHook(() => useStoragePanel()); + const row = result.current.loaded!.rows[0]!; + await act(async () => { + await result.current.handleRefreshStats(); + await result.current.handleStartBackfill(row); + await result.current.handleStartBackfill({ ...row, mirrorName: 'm' }); + }); + expect(fake.admin.startBackfill).toHaveBeenCalledTimes(1); + expect(fake.admin.startBackfill).toHaveBeenCalledWith('m'); + expect(toast.error.mock.calls).toEqual([['scan failed'], ['no mirror']]); + }); +}); + +describe('migrationPromptLine', () => { + const t = (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key; + + it('FE-ADMIN-STOR-PANEL-014: a counted category names its object count and size', () => { + expect( + migrationPromptLine(t, { category: 'covers', from: 'a', to: 'b', toWire: 'b', objects: 3, bytes: 2048 }) + ).toBe( + 'storage.migrate.promptLine {"category":"storage.category.covers","count":3,"size":"2.0 KB","from":"a","to":"b"}' + ); + }); + + it('FE-ADMIN-STOR-PANEL-015: a counted category with unknown bytes reports zero bytes', () => { + expect( + migrationPromptLine(t, { category: 'covers', from: 'a', to: 'b', toWire: 'b', objects: 0, bytes: null }) + ).toBe( + 'storage.migrate.promptLine {"category":"storage.category.covers","count":0,"size":"0 B","from":"a","to":"b"}' + ); + }); + + it('FE-ADMIN-STOR-PANEL-016: an uncounted category gets the line without count and size', () => { + expect( + migrationPromptLine(t, { category: 'covers', from: 'a', to: 'b', toWire: 'b', objects: null, bytes: null }) + ).toBe('storage.migrate.promptLineUnknown {"category":"storage.category.covers","from":"a","to":"b"}'); + }); +}); diff --git a/client/src/components/Admin/storage/useStoragePanel.ts b/client/src/components/Admin/storage/useStoragePanel.ts new file mode 100644 index 000000000..7f5955e7d --- /dev/null +++ b/client/src/components/Admin/storage/useStoragePanel.ts @@ -0,0 +1,375 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { + StorageAdminState, + StorageBackend, + StorageCategory, + StorageConfig, + StorageConfigPut, + StorageMigrationStatus, +} from '@trek/shared'; + +import { useTranslation } from '../../../i18n'; +import { formatBytes } from '../../../utils/formatBytes'; +import { useToast } from '../../shared/Toast'; +import { + adoptedMirrorFor, + computeMigrationCandidates, + effectiveCategoryMap, + foldBackends, + removeBackend, + removeBackendAndMirrors, + renameBackendRefs, + replicaCandidates, + replicaOfPrimaries, + setMirrorTargets, + settingsDocumentOf, + stripCategories, + upsertBackend, + usageByBackend, + type FoldedBackendRow, + type MigrationCandidate, +} from './storageModel'; +import { useStorageAdmin } from './useStorageAdmin'; + +/** Display-name mapper for joined category lists: the raw id renders only in the badge. */ +export const categoryNames = (t: (key: string) => string, ids: readonly string[]): string => + ids.map((id) => t(`storage.category.${id}`)).join(', '); + +/** + * One line of the migrate prompt for a category whose backend changes. A category the + * usage scan has not counted yet (`objects` null) gets the line without count and size. + */ +export const migrationPromptLine = ( + t: (key: string, params?: Record) => string, + candidate: MigrationCandidate +): string => { + const category = t(`storage.category.${candidate.category}`); + if (candidate.objects === null) { + return t('storage.migrate.promptLineUnknown', { category, from: candidate.from, to: candidate.to }); + } + return t('storage.migrate.promptLine', { + category, + count: candidate.objects, + size: formatBytes(candidate.bytes ?? 0), + from: candidate.from, + to: candidate.to, + }); +}; + +/** The backend the editor is open on, and what its Mirror-targets composer offers. */ +interface StorageEditing { + initial: StorageBackend | null; + originalName: string | null; + mirror: { candidates: string[]; initialTargets: string[] }; +} + +interface UseStoragePanelOptions { + /** + * What a failed migration start does to the rest of the queue. When set, the + * remaining candidates are dropped, named in a second toast, and the state is + * refreshed; otherwise only the error is toasted and the rest stay queued. + */ + dropQueueOnFailure?: boolean; +} + +/** + * The storage admin panel behind both shells (the desktop sections and the phone cards + * render their own markup over it): the backend editor, the removal confirm, the + * replica sync prompt, the save with its migrate prompt, and the queue that starts the + * accepted category migrations one at a time. Everything that needs the loaded state + * and draft sits under `loaded`, which is null until both exist. + */ +export function useStoragePanel({ dropQueueOnFailure = false }: UseStoragePanelOptions = {}) { + const { t } = useTranslation(); + const toast = useToast(); + const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict')); + const [editing, setEditing] = useState(null); + const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null); + const [syncPrompt, setSyncPrompt] = useState(null); + // The candidates the save found, as they were when the prompt opened. The phone + // lists and moves exactly these; the desktop only reads whether the prompt is + // open and recomputes the candidates on every render and again at confirm time. + const [migratePrompt, setMigratePrompt] = useState(null); + const [migrationQueue, setMigrationQueue] = useState([]); + // Set by `save` right before it calls admin.save(): the pre-save mirror + // target count per row name. Consumed (and cleared) by the effect below the + // first time `admin.state` changes afterward, never touched otherwise, so + // unrelated state changes (the backfill poll included) are no-ops here. + const pendingPromptCheck = useRef | null>(null); + // Synchronous single-flight lock for the queue effect below: `admin.state` + // only reflects a just-started migration once startMigration's awaited + // refreshState() resolves, so `setMigrationQueue(rest)` re-firing the + // effect synchronously (still against the pre-POST `admin.state`) would + // otherwise dequeue and POST the next candidate before the server has + // confirmed the first. A ref (not state) so the guard is visible on that + // very next synchronous re-render, not just after a state-driven one. + const migrationStartInFlight = useRef(false); + + useEffect(() => { + if (!pendingPromptCheck.current || !admin.state) return; + const before = pendingPromptCheck.current; + pendingPromptCheck.current = null; + const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state)); + const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0)); + if (grown) setSyncPrompt(grown.name); + }, [admin.state]); + + // Queued category migrations run strictly sequentially: once a slot opens + // (no migration currently running), dequeue the next candidate and start + // it. Depends on admin.state so a poll landing a terminal status re-fires + // this without any user interaction. The in-flight lock closes the race + // where setMigrationQueue(rest) re-fires this effect synchronously while + // admin.state still shows the pre-POST world; without it, the next + // candidate could dequeue and POST before the first is server-confirmed. + // Also waits out a running BACKFILL: the server's one-storage-job-at-a-time + // rule spans backfills and migrations alike, so starting while one runs + // would 409, and the queued candidate would be lost (no retry). + // + // With dropQueueOnFailure, a failed startMigration POST does not strand + // `rest`: it was already dequeued into local state before the POST, so on + // failure nothing would change `migrationQueue` or `admin.state` again and + // the remaining candidates would sit invisibly, never migrated. The whole + // remaining queue is then explicitly cleared (never retried: the operator + // re-triggers via Save), named in a toast, and admin.state is refreshed so + // the panel reflects whatever the failed attempt's category ended up as + // server-side. + useEffect(() => { + if (migrationQueue.length === 0 || !admin.state) return; + if (migrationStartInFlight.current) return; + if (admin.storageBusy()) return; + const [next, ...rest] = migrationQueue; + migrationStartInFlight.current = true; + setMigrationQueue(rest); + void admin.startMigration(next!.category, next!.toWire).then((error) => { + migrationStartInFlight.current = false; + if (error) { + toast.error(error); + if (!dropQueueOnFailure) return; + if (rest.length > 0) { + toast.error( + t('storage.migrate.queueDropped', { + categories: categoryNames( + t, + rest.map((c) => c.category) + ), + }) + ); + setMigrationQueue([]); + } + void admin.refreshState().catch(() => {}); + } + }); + }, [migrationQueue, admin.state]); + + const closeMigratePrompt = () => setMigratePrompt(null); + + const handleCancelMigration = async (m: StorageMigrationStatus) => { + const error = await admin.cancelMigration(m.category); + if (error) toast.error(error); + }; + + const handleRefreshStats = async () => { + const error = await admin.refreshStats(); + if (error) toast.error(error); + }; + + const handleStartBackfill = async (row: FoldedBackendRow) => { + if (!row.mirrorName) return; + setSyncPrompt((prev) => (prev === row.name ? null : prev)); + const error = await admin.startBackfill(row.mirrorName); + if (error) toast.error(error); + }; + + const handleCancelBackfill = async (row: FoldedBackendRow) => { + if (!row.mirrorName) return; + const error = await admin.cancelBackfill(row.mirrorName); + if (error) toast.error(error); + }; + + const loadedPanel = (state: StorageAdminState, draft: StorageConfigPut) => { + // Pure/cheap: recomputed every render so the desktop migrate-prompt dialog + // never shows a stale candidate set while it's open. moveAndSave recomputes + // independently at confirm time rather than reading this render-scoped + // value, since the click handler and the render that produced it are not + // guaranteed to be the same one. + const migrationCandidates = computeMigrationCandidates(draft, state); + + // 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); + const usageSums = usageByBackend(state, draft); + + const startAdd = () => + setEditing({ + initial: null, + originalName: null, + mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] }, + }); + + 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: replicaCandidates(rows, row.name, row.mirrorTargets), + initialTargets: row.mirrorTargets, + }, + }); + }; + + const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => { + const renamedFrom = editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null; + // Widened to StorageConfig: these edit helpers are version-blind (they + // return plain StorageConfig), and admin.setDraft re-attaches the + // draft's own `version` regardless of what shape it's handed. + let next: StorageConfig | null = 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, 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: categoryNames(t, assigned) }) : '', + usedAsReplicaBy.length > 0 + ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) + : '', + ] + .filter(Boolean) + .join(' '); + }; + + /** The removal confirm's yes: drops the backend (and a primary's mirrors) from the draft. */ + const confirmRemoval = () => { + if (confirmRemove) { + admin.setDraft( + confirmRemove.degenerate + ? removeBackend(draft, confirmRemove.name) + : removeBackendAndMirrors(state, draft, confirmRemove.name) + ); + } + setConfirmRemove(null); + }; + + 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 (stateEntry.source === 'default' && target === stateEntry.backend) { + delete categories[category]; // back to the default, so no longer settings-owned + } else { + categories[category] = target; + } + admin.setDraft({ ...draft, categories }); + }; + + // Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the + // in-progress `draft`) mirror target counts; the effect above compares + // them against the fresh post-save state to detect newly-added + // replicas. Folding `draft` here would already include the unsaved + // edit and mask the very growth this is meant to detect. + const snapshotMirrorTargets = (): Map => { + const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state)); + return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length])); + }; + + const doPlainSave = async () => { + pendingPromptCheck.current = snapshotMirrorTargets(); + if (await admin.save()) toast.success(t('storage.saved')); + else pendingPromptCheck.current = null; + }; + + // Without an argument it recomputes the candidate set at confirm time and + // never trusts whatever was true when the dialog opened: the operator can + // edit categories (or a background poll can land a fresher usage scan) + // while the dialog is on screen. The set it uses drives both + // stripCategories (what gets reverted from the PUT body) and the queue + // (what gets POSTed), so the two can never diverge. + const moveAndSave = async (candidates = computeMigrationCandidates(draft, state)) => { + pendingPromptCheck.current = snapshotMirrorTargets(); + const ok = await admin.save( + stripCategories( + draft, + state, + candidates.map((c) => c.category) + ) + ); + if (ok) { + setMigrationQueue(candidates); + toast.success(t('storage.saved')); + } else { + pendingPromptCheck.current = null; + } + setMigratePrompt(null); + }; + + const routeOnlySave = async () => { + setMigratePrompt(null); + await doPlainSave(); + }; + + const save = async () => { + const candidates = computeMigrationCandidates(draft, state); + if (candidates.length > 0) { + setMigratePrompt(candidates); + return; + } + await doPlainSave(); + }; + + return { + state, + draft, + migrationCandidates, + backendNames, + rows, + degenerate, + effective, + usageSums, + startAdd, + startEdit, + commitBackend, + removeMessage, + confirmRemoval, + setCategory, + save, + moveAndSave, + routeOnlySave, + }; + }; + + const loaded = admin.state && admin.draft ? loadedPanel(admin.state, admin.draft) : null; + + return { + admin, + editing, + setEditing, + confirmRemove, + setConfirmRemove, + syncPrompt, + setSyncPrompt, + migratePrompt, + closeMigratePrompt, + migrationQueue, + handleCancelMigration, + handleRefreshStats, + handleStartBackfill, + handleCancelBackfill, + loaded, + }; +} diff --git a/client/src/mobile/screens/admin/MAdminStoragePanel.tsx b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx index 9d82dfa59..d8d500161 100644 --- a/client/src/mobile/screens/admin/MAdminStoragePanel.tsx +++ b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx @@ -1,62 +1,24 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useState } from 'react' import { ChevronDown } from 'lucide-react' import { - STORAGE_BACKEND_TYPES, STORAGE_BACKEND_TYPE_IDS, STORAGE_CATEGORIES, type StorageBackend, - type StorageBackendFieldDef, type StorageBackendTypeId, type StorageCategory, - type StorageConfig, - type StorageMigrationStatus, } from '@trek/shared' import { useTranslation } from '../../../i18n' -import { useToast } from '../../../components/shared/Toast' import { formatBytes } from '../../../utils/formatBytes' import { relativeTime } from '../../../utils/relativeTime' -import { - CACHE_CATEGORIES, - adoptedMirrorFor, - computeMigrationCandidates, - effectiveCategoryMap, - foldBackends, - primaryNameOf, - removeBackend, - removeBackendAndMirrors, - renameBackendRefs, - replicaCandidates, - replicaOfPrimaries, - settingsDocumentOf, - setMirrorTargets, - stripCategories, - upsertBackend, - usageByBackend, - type FoldedBackendRow, - type MigrationCandidate, -} from '../../../components/Admin/storage/storageModel' -import { useStorageAdmin } from '../../../components/Admin/storage/useStorageAdmin' +import { CACHE_CATEGORIES, primaryNameOf } from '../../../components/Admin/storage/storageModel' +import { useBackendForm } from '../../../components/Admin/storage/useBackendForm' +import { categoryNames, migrationPromptLine, useStoragePanel } from '../../../components/Admin/storage/useStoragePanel' import MToggle from '../../components/MToggle' import MSetPickerSheet from '../settings/MSetPickerSheet' import MConfirmSheet from '../settings/MConfirmSheet' import { MSetSelectRow } from '../settings/MSettingsUi' import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi' -type FieldValues = Record - -function valuesOf(backend: StorageBackend | null): FieldValues { - if (!backend) return {} - const values: FieldValues = {} - for (const [key, value] of Object.entries(backend.options)) { - values[key] = Array.isArray(value) ? value : String(value) - } - return values -} - -/** Display-name mapper for joined category lists — the raw id renders only in the badge. */ -const categoryNames = (t: (key: string) => string, ids: readonly string[]): string => - ids.map((id) => t(`storage.category.${id}`)).join(', ') - /** Same behavior contract as the desktop BackendForm, rendered on the M* primitives. */ function MBackendForm({ initial, @@ -72,38 +34,13 @@ function MBackendForm({ onCancel: () => void }): React.ReactElement { const { t } = useTranslation() - const [type, setType] = useState(initial?.type ?? 'local') - const [name, setName] = useState(initial?.name ?? '') - const [values, setValues] = useState(() => valuesOf(initial)) - const [targets, setTargets] = useState(mirror.initialTargets) + const { + type, changeType, name, setName, values, setValue, toggleRef, targets, toggleTarget, fields, refOptions, + duplicate, canApply, buildBackend, visibleCandidates, + } = useBackendForm({ initial, backendNames, mirror }) const [picker, setPicker] = useState(null) - const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[] - const refOptions = backendNames.filter((candidate) => candidate !== name.trim()) - const setValue = (key: string, value: string | string[]) => setValues((prev) => ({ ...prev, [key]: value })) - - const filled = (field: StorageBackendFieldDef): boolean => { - const value = values[field.key] - if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0 - return typeof value === 'string' && value.trim() !== '' - } - const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim()) - const canApply = name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f)) - - const apply = () => { - const options: Record = {} - for (const field of fields) { - const value = values[field.key] - if (field.kind === 'backend-ref-list') { - options[field.key] = Array.isArray(value) ? value : [] - continue - } - const text = typeof value === 'string' ? value : '' - if (text === '' && !field.required) continue - options[field.key] = field.kind === 'number' ? Number(text) : text - } - onCommit({ name: name.trim(), type, options } as StorageBackend, targets) - } + const apply = () => onCommit(buildBackend(), targets) return ( @@ -132,10 +69,7 @@ function MBackendForm({ label: t(`storage.type.${id}`), }))} value={type} - onSelect={(next) => { - setType(next as StorageBackendTypeId) - setValues({}) - }} + onSelect={(next) => changeType(next as StorageBackendTypeId)} /> )} @@ -173,12 +107,7 @@ function MBackendForm({ - setValue( - field.key, - checked ? [...selected, candidate] : selected.filter((existing) => existing !== candidate), - ) - } + onChange={(checked) => toggleRef(field.key, candidate, checked)} />
))} @@ -207,17 +136,13 @@ function MBackendForm({
- {mirror.candidates - .filter((candidate) => candidate !== name.trim()) - .map((candidate) => ( + {visibleCandidates.map((candidate) => (
{candidate} - setTargets(checked ? [...targets, candidate] : targets.filter((existing) => existing !== candidate)) - } + onChange={(checked) => toggleTarget(candidate, checked)} />
))} @@ -243,63 +168,11 @@ function MBackendForm({ export default function MAdminStoragePanel(): React.ReactElement { const { t, locale } = useTranslation() - const toast = useToast() - const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict')) - const [editing, setEditing] = useState<{ - initial: StorageBackend | null - originalName: string | null - mirror: { candidates: string[]; initialTargets: string[] } - } | null>(null) - const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null) + const { + admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt, + handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded, + } = useStoragePanel() const [categoryPicker, setCategoryPicker] = useState(null) - const [syncPrompt, setSyncPrompt] = useState(null) - const [migratePrompt, setMigratePrompt] = useState(null) - const [migrationQueue, setMigrationQueue] = useState([]) - // Set by `save` right before it calls admin.save(): the pre-save mirror - // target count per row name. Consumed (and cleared) by the effect below the - // first time `admin.state` changes afterward — never touched otherwise, so - // unrelated state changes (the backfill poll included) are no-ops here. - const pendingPromptCheck = useRef | null>(null) - // Synchronous single-flight lock for the queue effect below: `admin.state` - // only reflects a just-started migration once startMigration's awaited - // refreshState() resolves, so `setMigrationQueue(rest)` re-firing the - // effect synchronously (still against the pre-POST `admin.state`) would - // otherwise dequeue and POST the next candidate before the server has - // confirmed the first — a ref (not state) so the guard is visible on that - // very next synchronous re-render, not just after a state-driven one. - const migrationStartInFlight = useRef(false) - - useEffect(() => { - if (!pendingPromptCheck.current || !admin.state) return - const before = pendingPromptCheck.current - pendingPromptCheck.current = null - const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state)) - const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0)) - if (grown) setSyncPrompt(grown.name) - }, [admin.state]) - - // Queued category migrations run strictly sequentially: once a slot opens - // (no migration currently running), dequeue the next candidate and start - // it. Depends on admin.state so a poll landing a terminal status re-fires - // this without any user interaction. The in-flight lock closes the race - // where setMigrationQueue(rest) re-fires this effect synchronously while - // admin.state still shows the pre-POST world — without it, the next - // candidate could dequeue and POST before the first is server-confirmed. - // Also waits out a running BACKFILL — the server's one-storage-job-at-a-time - // rule spans backfills and migrations alike, so starting while one runs - // would 409, and the queued candidate would be lost (no retry). - useEffect(() => { - if (migrationQueue.length === 0 || !admin.state) return - if (migrationStartInFlight.current) return - if (admin.storageBusy()) return - const [next, ...rest] = migrationQueue - migrationStartInFlight.current = true - setMigrationQueue(rest) - void admin.startMigration(next!.category, next!.toWire).then((error) => { - migrationStartInFlight.current = false - if (error) toast.error(error) - }) - }, [migrationQueue, admin.state]) if (admin.loading) { return ( @@ -308,136 +181,17 @@ export default function MAdminStoragePanel(): React.ReactElement { ) } - if (!admin.state || !admin.draft) { + if (!loaded) { return (

{admin.loadError || t('common.error')}

) } - const { state, draft } = admin - - 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) - const usageSums = usageByBackend(state, draft) - - const startEdit = (row: FoldedBackendRow) => { - setEditing({ - initial: row.backend, - originalName: row.name, - mirror: { - candidates: replicaCandidates(rows, row.name, row.mirrorTargets), - initialTargets: row.mirrorTargets, - }, - }) - } - - const commitBackend = (backend: StorageBackend, mirrorTargets: string[]) => { - const renamedFrom = editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null - // Widened to StorageConfig: these edit helpers are version-blind (they - // return plain StorageConfig), and admin.setDraft re-attaches the - // draft's own `version` regardless of what shape it's handed. - let next: StorageConfig | null = draft - if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name) - next = upsertBackend(next, backend) - next = setMirrorTargets(state, next, backend.name, mirrorTargets) - admin.setDraft(next) - setEditing(null) - } - - 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: categoryNames(t, assigned) }) : '', - usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '', - ] - .filter(Boolean) - .join(' ') - } - - const setCategory = (category: StorageCategory, primaryName: string) => { - 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 (stateEntry.source === 'default' && target === stateEntry.backend) { - delete categories[category] - } else { - categories[category] = target - } - admin.setDraft({ ...draft, categories }) - } - - // Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the - // in-progress `draft`) mirror target counts; the effect above compares - // them against the fresh post-save state to detect newly-added - // replicas. Folding `draft` here would already include the unsaved - // edit and mask the very growth this is meant to detect. - const snapshotMirrorTargets = (): Map => { - const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state)) - return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length])) - } - - const doPlainSave = async () => { - pendingPromptCheck.current = snapshotMirrorTargets() - if (await admin.save()) toast.success(t('storage.saved')) - else pendingPromptCheck.current = null - } - - const moveAndSave = async (candidates: MigrationCandidate[]) => { - pendingPromptCheck.current = snapshotMirrorTargets() - const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category))) - if (ok) { - setMigrationQueue(candidates) - toast.success(t('storage.saved')) - } else { - pendingPromptCheck.current = null - } - setMigratePrompt(null) - } - - const routeOnlySave = async () => { - setMigratePrompt(null) - await doPlainSave() - } - - const save = async () => { - const candidates = computeMigrationCandidates(draft, state) - if (candidates.length > 0) { - setMigratePrompt(candidates) - return - } - await doPlainSave() - } - - const handleCancelMigration = async (m: StorageMigrationStatus) => { - const error = await admin.cancelMigration(m.category) - if (error) toast.error(error) - } - - const handleRefreshStats = async () => { - const error = await admin.refreshStats() - if (error) toast.error(error) - } - - const handleStartBackfill = async (row: FoldedBackendRow) => { - if (!row.mirrorName) return - setSyncPrompt((prev) => (prev === row.name ? null : prev)) - const error = await admin.startBackfill(row.mirrorName) - if (error) toast.error(error) - } - - const handleCancelBackfill = async (row: FoldedBackendRow) => { - if (!row.mirrorName) return - const error = await admin.cancelBackfill(row.mirrorName) - if (error) toast.error(error) - } + const { + state, draft, backendNames, rows, degenerate, effective, usageSums, startAdd, startEdit, commitBackend, + removeMessage, confirmRemoval, setCategory, save, moveAndSave, routeOnlySave, + } = loaded return (
@@ -723,13 +477,7 @@ export default function MAdminStoragePanel(): React.ReactElement { {editing === null && (
- setEditing({ - initial: null, - originalName: null, - mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] }, - }) - } + onClick={startAdd} > {t('storage.backends.add')} @@ -826,19 +574,7 @@ export default function MAdminStoragePanel(): React.ReactElement {

{t('storage.migrate.promptTitle')}

{migratePrompt.map((c) => (

- {c.objects === null - ? t('storage.migrate.promptLineUnknown', { - category: t(`storage.category.${c.category}`), - from: c.from, - to: c.to, - }) - : t('storage.migrate.promptLine', { - category: t(`storage.category.${c.category}`), - count: c.objects, - size: formatBytes(c.bytes ?? 0), - from: c.from, - to: c.to, - })} + {migrationPromptLine(t, c)}

))}
@@ -873,16 +609,7 @@ export default function MAdminStoragePanel(): React.ReactElement { confirmLabel={t('storage.actions.remove')} cancelLabel={t('storage.form.cancel')} danger - onConfirm={() => { - if (confirmRemove) { - admin.setDraft( - confirmRemove.degenerate - ? removeBackend(draft, confirmRemove.name) - : removeBackendAndMirrors(state, draft, confirmRemove.name), - ) - } - setConfirmRemove(null) - }} + onConfirm={confirmRemoval} />
)