import React, { useEffect, useRef, 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 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, backendNames, mirror, onCommit, onCancel, }: { initial: StorageBackend | null backendNames: string[] mirror: { candidates: string[]; initialTargets: string[] } onCommit: (backend: StorageBackend, mirrorTargets: string[]) => void 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 [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) } return ( setName(e.target.value)} spellCheck={false} autoComplete="off" /> {duplicate && (

{t('storage.form.duplicateName', { name: name.trim() })}

)} {initial === null && ( } onClick={() => setPicker('type')} /> setPicker(null)} title={t('storage.form.type')} options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({ value: id, label: t(`storage.type.${id}`), }))} value={type} onSelect={(next) => { setType(next as StorageBackendTypeId) setValues({}) }} /> )} {fields.map((field) => { const value = values[field.key] if (field.kind === 'backend-ref') { const current = typeof value === 'string' ? value : '' return ( } onClick={() => setPicker(`field:${field.key}`)} /> setPicker(null)} title={t(field.labelKey)} options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))} value={current} onSelect={(next) => setValue(field.key, next)} /> ) } if (field.kind === 'backend-ref-list') { const selected = Array.isArray(value) ? value : [] return (
{refOptions.map((candidate) => (
{candidate} setValue( field.key, checked ? [...selected, candidate] : selected.filter((existing) => existing !== candidate), ) } />
))}
) } const text = typeof value === 'string' ? value : '' const shared = { value: text, onChange: (e: React.ChangeEvent) => setValue(field.key, e.target.value), placeholder: field.defaultValue !== undefined ? String(field.defaultValue) : '', spellCheck: false, autoComplete: 'off', } return ( {field.kind === 'secret' ? ( ) : ( )} ) })}
{mirror.candidates .filter((candidate) => candidate !== name.trim()) .map((candidate) => (
{candidate} setTargets(checked ? [...targets, candidate] : targets.filter((existing) => existing !== candidate)) } />
))}
{targets.length > 0 && (

{t('storage.mirror.latencyNote')}

)}
{t('storage.form.apply')} {t('storage.form.cancel')}
) } 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 [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 (

{t('storage.loading')}

) } if (!admin.state || !admin.draft) { 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) } return (
{state.health.replicaFailures.length === 0 ? (

{t('storage.health.allClear')}

) : (
{state.health.replicaFailures.map((failure) => (

{t('storage.health.failureLine', { op: failure.op, key: failure.key, backend: failure.backend, error: failure.error, })}{' '} · {relativeTime(failure.at, locale)}

))}
)} {state.seedFilePresent && (

{t('storage.health.seedFile')}

)}

{state.usage ? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) }) : t('storage.usage.never')}

{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
{rows.map((row) => { const resultKey = row.mirrorName ?? row.name const result = admin.testResults[resultKey] // row.mirrorName only exists when foldBackends adopted a draft mirror // for this row, so the draft lookup below cannot miss. const testCandidate = row.mirrorName ? draft.backends.find((b) => b.name === row.mirrorName)! : row.backend const rowUsage = usageSums?.[row.name] const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined return (
{row.name} {t(`storage.type.${row.type}`)} {t(`storage.source.${row.source}`)}

{row.categories.length > 0 ? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) }) : t('storage.backends.unused')}

{rowUsage && (

{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })} {row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0 ? ` (${t('storage.usage.legacyNote')})` : ''}

)} {row.mirrorTargets.length > 0 && (

{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}

)} {row.replicaOf.length > 0 && (

{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}

)} {row.source === 'env' && (

{t('storage.backends.envReadOnly')}

)}
admin.test(testCandidate)}> {t('storage.actions.test')} {row.source !== 'env' && ( startEdit(row)}> {t('storage.actions.edit')} )} {row.source === 'settings' && ( setConfirmRemove({ name: row.name, degenerate: false })}> {t('storage.actions.remove')} )}
{result === 'running' ? (

{t('storage.test.running')}

) : ( result && (

{result.ok ? t('storage.test.ok') : t('storage.test.failed')}

{result.targets.map((target) => (

{target.ok ? '✓' : '✗'} {target.name} {target.error ? ` — ${target.error}` : ''}

))}
) )} {row.mirrorTargets.length > 0 && (
{backfill?.status === 'running' ? ( <>

{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}

{t('storage.sync.counts', { copied: String(backfill.copied), skipped: String(backfill.skipped), failed: String(backfill.failed), })}

handleCancelBackfill(row)}> {t('storage.sync.cancel')}
) : backfill?.status === 'done' ? (

{t('storage.sync.done', { copied: String(backfill.copied), deleted: String(backfill.deleted), failed: String(backfill.failed), })}

) : backfill?.status === 'cancelled' ? (

{t('storage.sync.cancelled')}

) : backfill?.status === 'error' ? (

{t('storage.sync.error', { error: backfill.error ?? '' })}

) : syncPrompt !== row.name ? (
handleStartBackfill(row)}> {t('storage.sync.now')}
) : null}
)} {syncPrompt === row.name && (

{t('storage.sync.prompt')}

handleStartBackfill(row)}> {t('storage.sync.now')} setSyncPrompt(null)}> {t('storage.sync.dismiss')}
)}
) })} {degenerate.map(({ backend, reason }) => { const result = admin.testResults[backend.name] const primary = backend.type === 'mirror' ? backend.options.primary : '' return (
{backend.name} {t('storage.type.mirror')}

{t(`storage.mirror.degenerate.${reason}`, { primary })}

admin.test(backend)}> {t('storage.actions.test')} setConfirmRemove({ name: backend.name, degenerate: true })}> {t('storage.actions.remove')}
{result === 'running' ? (

{t('storage.test.running')}

) : ( result && (

{result.ok ? t('storage.test.ok') : t('storage.test.failed')}

{result.targets.map((target) => (

{target.ok ? '✓' : '✗'} {target.name} {target.error ? ` — ${target.error}` : ''}

))}
) )}
) })} {state.migrations.map((m) => (

{t(`storage.category.${m.category}`)}

{m.status === 'running' ? ( <>

{t('storage.migrate.running', { category: t(`storage.category.${m.category}`), done: String(m.done), total: String(m.total), })}

void handleCancelMigration(m)}> {t('storage.migrate.cancel')}
) : m.status === 'done' ? ( <>

{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}

{m.failed > 0 && (

{t('storage.migrate.doneFailures', { failed: String(m.failed) })}

)} {m.reclaimable && (

{t('storage.migrate.reclaimable', { objects: String(m.reclaimable.objects), size: formatBytes(m.reclaimable.bytes), from: m.from, })}

)} ) : m.status === 'failed' ? (

{t('storage.migrate.failed', { error: m.error ?? '' })}

) : (

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

)}
))}
{editing === null && (
setEditing({ initial: null, originalName: null, mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] }, }) } > {t('storage.backends.add')}
)}
{editing !== null && ( setEditing(null)} /> )}
{STORAGE_CATEGORIES.map((category) => { // The admin state's category record is exhaustive by schema contract. const stateEntry = state.categories[category]! const selectedPrimary = primaryNameOf(state, draft, effective[category]) const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend) const viaMirror = effective[category] !== selectedPrimary return ( {t(`storage.category.${category}`)}{' '} {category} } hint={t(`storage.categoryDesc.${category}`)} > {state.usage?.categories[category] && (

{t('storage.usage.line', { objects: String(state.usage.categories[category]!.objects), size: formatBytes(state.usage.categories[category]!.bytes), })}

)}
} onClick={() => setCategoryPicker(category)} />
{changed && (

{t('storage.categories.reassignWarning')}

)} {viaMirror && CACHE_CATEGORIES.includes(category) && (

{t('storage.mirror.cacheWarning')}

)}
) })}
setCategoryPicker(null)} title={categoryPicker ? t(`storage.category.${categoryPicker}`) : ''} options={rows.map((row) => ({ value: row.name, label: row.name }))} value={categoryPicker ? primaryNameOf(state, draft, effective[categoryPicker]) : ''} onSelect={(name) => { if (categoryPicker) setCategory(categoryPicker, name) }} />
{t('storage.save')} {admin.dirty && {t('storage.unsaved')}}
{migratePrompt && (

{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}`), objects: String(c.objects), size: formatBytes(c.bytes ?? 0), from: c.from, to: c.to, })}

))}
void moveAndSave(migratePrompt)}> {t('storage.migrate.move')} void routeOnlySave()}> {t('storage.migrate.routeOnly')}
)} {admin.saveError && (

{admin.saveError}

{/* A 409 pins the draft to a version the server moved past — saving again can never clear it, so offer the one action that can. */} {admin.saveConflict && ( void admin.discardDraft()}> {t('storage.discardAndReload')} )}
)} setConfirmRemove(null)} title={t('storage.remove.title')} message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''} 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) }} />
) }