mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
bf8e04741d
Replacing a cover or an avatar and deleting a note file now hand the old bytes to the release queue instead of deleting them in the request; the integration tests check the queue row and that the bytes are still there. Release mocks answer with a count, the reclaim e2e sends its refused bodies one after another, and a stale-count reload that fails is told to the admin instead of swallowed.
476 lines
19 KiB
TypeScript
476 lines
19 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import type {
|
|
StorageAdminState,
|
|
StorageBackend,
|
|
StorageConfig,
|
|
StorageConfigPut,
|
|
StorageReclaimConfirmRequest,
|
|
StorageTestResponse,
|
|
} from '@trek/shared'
|
|
import { adminApi } from '../../../api/client'
|
|
import { storageReclaimApi } from '../../../api/storageReclaim'
|
|
import { getApiErrorMessage, type ApiError } from '../../../types'
|
|
import { settingsDocumentOf } from './storageModel'
|
|
|
|
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
|
|
|
|
/** A refused reclaim call: the server's code when it sent one the panel can name, its message otherwise. */
|
|
export type ReclaimFailure = { code: string } | { message: string }
|
|
|
|
function reclaimFailureOf(err: unknown, fallback: string): ReclaimFailure {
|
|
const code = (err as ApiError | null)?.response?.data as { code?: unknown } | undefined
|
|
return typeof code?.code === 'string' && code.code.startsWith('reclaim_')
|
|
? { code: code.code }
|
|
: { message: getApiErrorMessage(err, fallback) }
|
|
}
|
|
|
|
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
|
|
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
|
|
|
|
/** True for a 409 response — the version this draft was built at is no longer current (audit #7). */
|
|
function isConflictError(err: unknown): boolean {
|
|
return typeof err === 'object' && err !== null && 'response' in err && (err as ApiError).response?.status === 409
|
|
}
|
|
|
|
export interface StorageAdmin {
|
|
state: StorageAdminState | null
|
|
/**
|
|
* Whether the server is already busy with a storage job: a migration or a
|
|
* backfill, since its one-job-at-a-time rule spans both. Answered off the
|
|
* last response rather than off `state`, so it is current the instant that
|
|
* response lands rather than on the commit that renders it.
|
|
*
|
|
* That difference is the whole point. An effect re-run by a DEFERRED commit
|
|
* still closes over the `state` of the render it belongs to, which under load
|
|
* is several responses old by the time it runs, and the queue effect deciding
|
|
* on that closure POSTed a second migration on top of a running one. Anything
|
|
* deciding whether to fire a request asks this; anything rendering reads
|
|
* `state`. Never called during render.
|
|
*/
|
|
storageBusy: () => boolean
|
|
/** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */
|
|
draft: StorageConfigPut | null
|
|
dirty: boolean
|
|
loading: boolean
|
|
loadError: string | null
|
|
/** Server 400s/409s land here VERBATIM (400) or as the distinct conflict copy (409) — long registry messages outlive a toast. */
|
|
saveError: string | null
|
|
/** True while `saveError` is the 409 copy — the panels render the discard-and-reload escape next to it. */
|
|
saveConflict: boolean
|
|
saving: boolean
|
|
testResults: StorageTestResults
|
|
setDraft: (next: StorageConfig) => void
|
|
/** PUTs `overrideDraft ?? draft` — an override lets a caller strip categories from the wire body without staging them (and marking dirty) via setDraft. */
|
|
save: (overrideDraft?: StorageConfig) => Promise<boolean>
|
|
test: (backend: StorageBackend) => Promise<void>
|
|
/**
|
|
* Probe each target individually (client-expanded mirror primary +
|
|
* replicas — see `mirrorProbeTargets`) and merge the results into one
|
|
* `StorageTestResponse` under `key` (the row's mirror wire name). Never
|
|
* sends the mirror stub itself — each call probes a concrete backend.
|
|
*/
|
|
testMirror: (key: string, targets: StorageBackend[]) => Promise<void>
|
|
/** null on success; the verbatim server error otherwise. */
|
|
startBackfill: (mirrorName: string) => Promise<string | null>
|
|
cancelBackfill: (mirrorName: string) => Promise<string | null>
|
|
startMigration: (category: string, to: string) => Promise<string | null>
|
|
cancelMigration: (category: string) => Promise<string | null>
|
|
refreshStats: () => Promise<string | null>
|
|
/** Switches the nightly deletion of unreferenced files on or off; null on success. */
|
|
setReclaimDelete: (enabled: boolean) => Promise<ReclaimFailure | null>
|
|
/** Starts a check that only reports, in the background; the state polls until it is done. Null on success. */
|
|
checkReclaim: () => Promise<ReclaimFailure | null>
|
|
/** Lets the nightly run delete in a category where an unusually large share looks unused. Null on success. */
|
|
confirmReclaim: (request: StorageReclaimConfirmRequest) => Promise<ReclaimFailure | null>
|
|
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
|
|
refreshState: () => Promise<void>
|
|
/**
|
|
* The 409's only exit: throw the local edits away and reload the saved
|
|
* settings (fresh GET → fresh draft → fresh version), clearing dirty and
|
|
* saveError. See the implementation comment for why refreshState can't do
|
|
* this itself.
|
|
*/
|
|
discardDraft: () => Promise<void>
|
|
}
|
|
|
|
/**
|
|
* Shared by the desktop and phone storage panels (DefaultUserSettingsTab
|
|
* model: self-contained, adminApi directly, deliberately no offline core —
|
|
* hoster-level config is online-only).
|
|
*/
|
|
export function useStorageAdmin(genericError: string, conflictError: string): StorageAdmin {
|
|
const [state, setState] = useState<StorageAdminState | null>(null)
|
|
const [draft, setDraftState] = useState<StorageConfigPut | null>(null)
|
|
const [dirty, setDirty] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [loadError, setLoadError] = useState<string | null>(null)
|
|
const [saveError, setSaveError] = useState<string | null>(null)
|
|
const [saveConflict, setSaveConflict] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [testResults, setTestResults] = useState<StorageTestResults>({})
|
|
|
|
// Read imperatively from the poll effect/refreshState so a stale closure
|
|
// never re-derives the draft out from under an in-flight edit.
|
|
const dirtyRef = useRef(dirty)
|
|
useEffect(() => {
|
|
dirtyRef.current = dirty
|
|
}, [dirty])
|
|
|
|
// Read imperatively at refreshState's resolve time so a poll GET that was
|
|
// in flight when a save started never applies after the save's own state.
|
|
const savingRef = useRef(saving)
|
|
useEffect(() => {
|
|
savingRef.current = saving
|
|
}, [saving])
|
|
|
|
// Written by every state application, at response time rather than at commit
|
|
// time, and read back through storageBusy(). See the interface doc.
|
|
const stateRef = useRef<StorageAdminState | null>(null)
|
|
const storageBusy = useCallback((): boolean => {
|
|
const live = stateRef.current
|
|
if (!live) return false
|
|
return live.migrations.some((m) => m.status === 'running') || live.backfills.some((b) => b.status === 'running')
|
|
}, [])
|
|
|
|
// Bumped by every deliberate state application (initial load, save
|
|
// success). A refreshState() call captures the seq before its GET and
|
|
// drops the response if the seq moved on — i.e. a save applied its own
|
|
// fresher state — while that GET was in flight.
|
|
const stateSeq = useRef(0)
|
|
|
|
const applyState = useCallback((next: StorageAdminState) => {
|
|
stateSeq.current += 1
|
|
stateRef.current = next
|
|
setState(next)
|
|
setDraftState(settingsDocumentOf(next))
|
|
setDirty(false)
|
|
setSaveError(null)
|
|
setSaveConflict(false)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
adminApi
|
|
.getStorage()
|
|
.then((data) => {
|
|
if (!cancelled) applyState(data)
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (!cancelled) setLoadError(getApiErrorMessage(err, genericError))
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [applyState, genericError])
|
|
|
|
// Re-attaches the CURRENT draft's version onto whatever shape the caller
|
|
// hands in (the storageModel edit helpers are version-blind — they operate
|
|
// on plain StorageConfig — so this is the one place that keeps `version`
|
|
// pinned to what the operator's form was loaded at, no matter how many
|
|
// local edits run in between).
|
|
const setDraft = useCallback((next: StorageConfig) => {
|
|
setDraftState((prev) => ({ ...next, version: prev?.version ?? 0 }))
|
|
setDirty(true)
|
|
}, [])
|
|
|
|
// Poll-safe refresh: replaces `state` unconditionally, but only re-derives
|
|
// `draft` when the operator has no unsaved edits in flight — never touches
|
|
// `dirty`/`saveError`, so a running poll cannot clobber a dirty draft or
|
|
// mask a pending save error.
|
|
//
|
|
// Race guard: a poll GET issued before a save's PUT resolves can resolve
|
|
// AFTER the save applied its own (fresher) response, overwriting it with
|
|
// the pre-save world. Capture the state-application sequence number before
|
|
// the await and drop the response if it moved on, or if a save is
|
|
// in-flight/just landed (read via a ref so it's current at resolve time).
|
|
//
|
|
// Declared before `save` (which calls it on a 409) — a hook's `const`s
|
|
// must exist before another callback's dependency array references them.
|
|
const refreshState = useCallback(async (): Promise<void> => {
|
|
const seq = stateSeq.current
|
|
const next = await adminApi.getStorage()
|
|
if (stateSeq.current !== seq || savingRef.current) return
|
|
stateRef.current = next
|
|
setState(next)
|
|
if (!dirtyRef.current) setDraftState(settingsDocumentOf(next))
|
|
}, [])
|
|
|
|
const save = useCallback(
|
|
async (overrideDraft?: StorageConfig): Promise<boolean> => {
|
|
if (!draft) return false
|
|
// Always the DRAFT's own version, even for an overrideDraft (a variant
|
|
// of the same draft with categories stripped, per stripCategories) —
|
|
// both share the same version basis: what the operator's form was
|
|
// loaded/refreshed at, never re-read from `state` here (that would
|
|
// defeat the whole check — see setDraft's re-attach comment above).
|
|
const body: StorageConfigPut = { ...(overrideDraft ?? draft), version: draft.version }
|
|
setSaving(true)
|
|
setSaveError(null)
|
|
setSaveConflict(false)
|
|
try {
|
|
// The response is the fresh effective world, never the request echo.
|
|
applyState(await adminApi.updateStorage(body))
|
|
return true
|
|
} catch (err: unknown) {
|
|
if (isConflictError(err)) {
|
|
// Something else (another admin's save, or a category migration's
|
|
// flip) wrote since this draft's version was captured. Refresh
|
|
// `state` so the operator sees the fresh world, but the dirty
|
|
// draft itself is left untouched for review (dirty is never
|
|
// cleared here) — refreshState() already respects dirtyRef.
|
|
setSaveError(conflictError)
|
|
setSaveConflict(true)
|
|
void refreshState().catch(() => {})
|
|
} else {
|
|
setSaveError(getApiErrorMessage(err, genericError))
|
|
}
|
|
return false
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
},
|
|
[draft, applyState, genericError, conflictError, refreshState],
|
|
)
|
|
|
|
/**
|
|
* Conflict recovery (final-review fix): after a 409 the dirty draft keeps
|
|
* the version it was built at. refreshState deliberately leaves a dirty
|
|
* draft's version alone — bumping it there would resurrect audit #7, where
|
|
* the background poll floats a mid-edit draft past a migration flip it
|
|
* never carried — so nothing else can ever move it, and every retry 409s
|
|
* again. The operator's way out is to say so explicitly: discard the local
|
|
* edits, reload the saved settings, and start over from the fresh version.
|
|
* A fresh GET (not the possibly-dropped post-409 refresh) guarantees that
|
|
* version is current, and applyState clears dirty/saveError with it.
|
|
*/
|
|
const discardDraft = useCallback(async (): Promise<void> => {
|
|
try {
|
|
applyState(await adminApi.getStorage())
|
|
} catch (err: unknown) {
|
|
setSaveError(getApiErrorMessage(err, genericError))
|
|
setSaveConflict(false)
|
|
}
|
|
}, [applyState, genericError])
|
|
|
|
const test = useCallback(
|
|
async (backend: StorageBackend): Promise<void> => {
|
|
setTestResults((prev) => ({ ...prev, [backend.name]: 'running' }))
|
|
try {
|
|
const result = await adminApi.testStorageBackend(backend)
|
|
setTestResults((prev) => ({ ...prev, [backend.name]: result }))
|
|
} catch (err: unknown) {
|
|
const error = getApiErrorMessage(err, genericError)
|
|
setTestResults((prev) => ({
|
|
...prev,
|
|
[backend.name]: { ok: false, targets: [{ name: backend.name, ok: false, error }] },
|
|
}))
|
|
}
|
|
},
|
|
[genericError],
|
|
)
|
|
|
|
const testMirror = useCallback(
|
|
async (key: string, targets: StorageBackend[]): Promise<void> => {
|
|
setTestResults((prev) => ({ ...prev, [key]: 'running' }))
|
|
const settled = await Promise.all(
|
|
targets.map(async (target) => {
|
|
try {
|
|
const result = await adminApi.testStorageBackend(target)
|
|
return result.targets[0] ?? { name: target.name, ok: result.ok }
|
|
} catch (err: unknown) {
|
|
return { name: target.name, ok: false, error: getApiErrorMessage(err, genericError) }
|
|
}
|
|
}),
|
|
)
|
|
setTestResults((prev) => ({ ...prev, [key]: { ok: settled.every((r) => r.ok), targets: settled } }))
|
|
},
|
|
[genericError],
|
|
)
|
|
|
|
// While any backfill or category migration is running, poll GET state so
|
|
// progress/counts advance without the operator refreshing the page.
|
|
useEffect(() => {
|
|
if (
|
|
!state?.backfills.some((b) => b.status === 'running') &&
|
|
!state?.migrations.some((m) => m.status === 'running') &&
|
|
!state?.reclaim?.running
|
|
)
|
|
return
|
|
const iv = setInterval(() => {
|
|
// A transient poll failure is retried on the next tick — it must never
|
|
// surface as a toast or interrupt the operator's in-progress edit.
|
|
void refreshState().catch(() => {})
|
|
}, BACKFILL_POLL_MS)
|
|
return () => clearInterval(iv)
|
|
}, [state, refreshState])
|
|
|
|
const startBackfill = useCallback(
|
|
async (mirrorName: string): Promise<string | null> => {
|
|
try {
|
|
await adminApi.startStorageBackfill(mirrorName)
|
|
void refreshState().catch(() => {})
|
|
return null
|
|
} catch (err: unknown) {
|
|
return getApiErrorMessage(err, genericError)
|
|
}
|
|
},
|
|
[refreshState, genericError],
|
|
)
|
|
|
|
const cancelBackfill = useCallback(
|
|
async (mirrorName: string): Promise<string | null> => {
|
|
try {
|
|
await adminApi.cancelStorageBackfill(mirrorName)
|
|
void refreshState().catch(() => {})
|
|
return null
|
|
} catch (err: unknown) {
|
|
return getApiErrorMessage(err, genericError)
|
|
}
|
|
},
|
|
[refreshState, genericError],
|
|
)
|
|
|
|
const startMigration = useCallback(
|
|
async (category: string, to: string): Promise<string | null> => {
|
|
try {
|
|
await adminApi.startStorageMigration(category, to)
|
|
// Awaited (unlike cancelMigration/startBackfill/cancelBackfill): the
|
|
// queue effect's in-flight lock releases as soon as this promise
|
|
// resolves, so admin.state must already reflect the just-started
|
|
// migration by then — otherwise the next queued candidate can
|
|
// dequeue and POST before the server has confirmed this one.
|
|
await refreshState().catch(() => {})
|
|
return null
|
|
} catch (err: unknown) {
|
|
return getApiErrorMessage(err, genericError)
|
|
}
|
|
},
|
|
[refreshState, genericError],
|
|
)
|
|
|
|
const cancelMigration = useCallback(
|
|
async (category: string): Promise<string | null> => {
|
|
try {
|
|
await adminApi.cancelStorageMigration(category)
|
|
void refreshState().catch(() => {})
|
|
return null
|
|
} catch (err: unknown) {
|
|
return getApiErrorMessage(err, genericError)
|
|
}
|
|
},
|
|
[refreshState, genericError],
|
|
)
|
|
|
|
const refreshStats = useCallback(async (): Promise<string | null> => {
|
|
try {
|
|
const usage = await adminApi.refreshStorageStats()
|
|
// Off stateRef rather than a functional updater, so the ref and the
|
|
// rendered state stay one world: every writer here sets both.
|
|
const prev = stateRef.current
|
|
if (prev) {
|
|
const next = { ...prev, usage }
|
|
stateRef.current = next
|
|
setState(next)
|
|
}
|
|
return null
|
|
} catch (err: unknown) {
|
|
return getApiErrorMessage(err, genericError)
|
|
}
|
|
}, [genericError])
|
|
|
|
/**
|
|
* Writes the reclaim part of the state. It counts as a deliberate state
|
|
* application, so a poll GET that was in flight before it is dropped instead
|
|
* of putting the switch back where it was.
|
|
*/
|
|
const setReclaim = useCallback((reclaim: NonNullable<StorageAdminState['reclaim']>) => {
|
|
const prev = stateRef.current
|
|
if (!prev) return
|
|
stateSeq.current += 1
|
|
const next = { ...prev, reclaim }
|
|
stateRef.current = next
|
|
setState(next)
|
|
}, [])
|
|
|
|
const setReclaimDelete = useCallback(
|
|
async (enabled: boolean): Promise<ReclaimFailure | null> => {
|
|
try {
|
|
setReclaim(await storageReclaimApi.setDelete(enabled))
|
|
return null
|
|
} catch (err: unknown) {
|
|
return reclaimFailureOf(err, genericError)
|
|
}
|
|
},
|
|
[genericError, setReclaim],
|
|
)
|
|
|
|
const checkReclaim = useCallback(async (): Promise<ReclaimFailure | null> => {
|
|
try {
|
|
await storageReclaimApi.check()
|
|
// Running from here on: the poll above picks the report up once the pass is done.
|
|
const current = stateRef.current?.reclaim
|
|
setReclaim({
|
|
deleteEnabled: current?.deleteEnabled ?? false,
|
|
running: true,
|
|
lastRun: current?.lastRun ?? null,
|
|
lastDeleteRun: current?.lastDeleteRun ?? null,
|
|
})
|
|
return null
|
|
} catch (err: unknown) {
|
|
return reclaimFailureOf(err, genericError)
|
|
}
|
|
}, [genericError, setReclaim])
|
|
|
|
const confirmReclaim = useCallback(
|
|
async (request: StorageReclaimConfirmRequest): Promise<ReclaimFailure | null> => {
|
|
try {
|
|
setReclaim(await storageReclaimApi.confirm(request))
|
|
return null
|
|
} catch (err: unknown) {
|
|
const failure = reclaimFailureOf(err, genericError)
|
|
// The count moved on since it was shown: show the current one before the admin confirms again.
|
|
if ('code' in failure && failure.code === 'reclaim_confirm_stale') {
|
|
try {
|
|
await refreshState()
|
|
} catch (reloadErr: unknown) {
|
|
// Without the new figure there is nothing to confirm against: say why instead.
|
|
return reclaimFailureOf(reloadErr, genericError)
|
|
}
|
|
}
|
|
return failure
|
|
}
|
|
},
|
|
[genericError, setReclaim, refreshState],
|
|
)
|
|
|
|
return {
|
|
state,
|
|
storageBusy,
|
|
draft,
|
|
dirty,
|
|
loading,
|
|
loadError,
|
|
saveError,
|
|
saveConflict,
|
|
saving,
|
|
testResults,
|
|
setDraft,
|
|
save,
|
|
test,
|
|
testMirror,
|
|
startBackfill,
|
|
cancelBackfill,
|
|
startMigration,
|
|
cancelMigration,
|
|
refreshStats,
|
|
setReclaimDelete,
|
|
checkReclaim,
|
|
confirmReclaim,
|
|
refreshState,
|
|
discardDraft,
|
|
}
|
|
}
|