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} />
)