e66987f092
Extracts gallery.js's hand-rolled inflightId pattern into a new useInflightToken composable; adopts in every store that previously had no guard against late-response overwrites or wrong-image URL interpolation. Two operator-impacting bugs the audit (workflow wf_bbe3fdb1-e62) flagged: - modal.removeTag rolled back the chip rail unconditionally even when only the secondary dismiss POST had failed — UI lied until refresh. And all tag-mutation URLs interpolated currentImageId AFTER an await, so a fast prev/next could route DELETE/POST to the wrong image. Both fixed: split try/catch (dismiss failure surfaces a warning, doesn't roll back the delete); imageId captured at call-time and used in URLs throughout. - suggestions.accept dereferenced currentImageId after the awaited POST /api/tags, so the subsequent /suggestions/accept could apply A's chosen tag to image B AND push it to B's allowlist. Fixed by capturing imageId at click-time + inflight guard on load(). Same shape across artist / downloads / artistDirectory / tagDirectory / posts stores: rapid filter/nav changes used to interleave responses (last-writer-wins). Now the late response is discarded and the most-recent request wins. Filter-change-during- search no longer drops the second fetch because the loading flag was still true from the first. gallery.js's inflightId removed in favor of the shared composable so the pattern stays consistent.
133 lines
4.6 KiB
JavaScript
133 lines
4.6 KiB
JavaScript
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
import { useApi } from '../composables/useApi.js'
|
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
|
|
|
export const useDownloadsStore = defineStore('downloads', () => {
|
|
const api = useApi()
|
|
|
|
const events = ref([])
|
|
const cursor = ref(null)
|
|
const hasMore = ref(true)
|
|
const filter = ref({
|
|
status: null, source_id: null, artist_id: null,
|
|
from_date: null, to_date: null,
|
|
})
|
|
const selected = ref(null)
|
|
const { loading, error, run } = useAsyncAction()
|
|
const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 })
|
|
const activity = ref({ hours: 24, buckets: [] })
|
|
const failing = ref([])
|
|
// Running + queued events, fetched independent of the feed's filter so
|
|
// the "active now" panel always reflects what's happening regardless of
|
|
// how the operator has filtered the historical list below.
|
|
const activeEvents = ref([])
|
|
// Filter changes (applyFilter) and rapid pagination can interleave
|
|
// responses; without an inflight guard the late response from a
|
|
// prior filter overwrites the current view. Audit 2026-06-02.
|
|
const inflight = useInflightToken()
|
|
|
|
function _params(extra = {}) {
|
|
const out = { limit: 50, ...extra }
|
|
if (filter.value.status) out.status = filter.value.status
|
|
if (filter.value.source_id != null) out.source_id = filter.value.source_id
|
|
if (filter.value.artist_id != null) out.artist_id = filter.value.artist_id
|
|
return out
|
|
}
|
|
|
|
async function loadFirst() {
|
|
const t = inflight.claim()
|
|
await run(async () => {
|
|
const body = await api.get('/api/downloads', { params: _params() })
|
|
if (!t.isCurrent()) return
|
|
events.value = body
|
|
cursor.value = body.length ? body[body.length - 1].id : null
|
|
hasMore.value = body.length === 50
|
|
})
|
|
}
|
|
|
|
async function loadMore() {
|
|
if (!hasMore.value || cursor.value == null) return
|
|
const t = inflight.claim()
|
|
await run(async () => {
|
|
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
|
|
if (!t.isCurrent()) return
|
|
events.value.push(...body)
|
|
cursor.value = body.length ? body[body.length - 1].id : cursor.value
|
|
hasMore.value = body.length === 50
|
|
})
|
|
}
|
|
|
|
async function loadOne(id) {
|
|
selected.value = await api.get(`/api/downloads/${id}`)
|
|
return selected.value
|
|
}
|
|
|
|
// Open the detail modal for the most recent DownloadEvent of a given
|
|
// source. Used by the failing-sources rollup's "Logs" button so the
|
|
// operator can troubleshoot without leaving the Downloads tab to find
|
|
// the row (operator-flagged 2026-06-01).
|
|
async function loadLastForSource(sourceId) {
|
|
const events = await api.get('/api/downloads', {
|
|
params: { source_id: sourceId, limit: 1 },
|
|
})
|
|
if (!events.length) {
|
|
selected.value = null
|
|
return null
|
|
}
|
|
return await loadOne(events[0].id)
|
|
}
|
|
|
|
async function applyFilter(patch) {
|
|
// Drop any in-flight loadFirst/loadMore from the previous filter
|
|
// so its late response doesn't overwrite this filter's results.
|
|
inflight.cancel()
|
|
filter.value = { ...filter.value, ...patch }
|
|
await loadFirst()
|
|
}
|
|
|
|
function closeDetail() {
|
|
selected.value = null
|
|
}
|
|
|
|
async function loadStats(windowHours = 24) {
|
|
stats.value = await api.get('/api/downloads/stats', { params: { window_hours: windowHours } })
|
|
return stats.value
|
|
}
|
|
|
|
async function loadActivity(hours = 24) {
|
|
activity.value = await api.get('/api/downloads/activity', { params: { hours } })
|
|
return activity.value
|
|
}
|
|
|
|
async function loadFailing() {
|
|
failing.value = await api.get('/api/sources', { params: { failing: true } })
|
|
return failing.value
|
|
}
|
|
|
|
async function loadActive() {
|
|
const [running, pending] = await Promise.all([
|
|
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
|
|
api.get('/api/downloads', { params: { status: 'pending', limit: 50 } }),
|
|
])
|
|
activeEvents.value = [...running, ...pending]
|
|
return activeEvents.value
|
|
}
|
|
|
|
// POSTs to the download-recovery sweep endpoint (fire-and-forget — the
|
|
// Beat schedule also runs it every 5 min). The caller should refresh
|
|
// failing/stats a few seconds after this resolves to see swept rows.
|
|
async function recoverStalled() {
|
|
return api.post('/api/downloads/recover-stalled')
|
|
}
|
|
|
|
return {
|
|
events, cursor, hasMore, filter, selected, loading, error, stats,
|
|
activity, failing, activeEvents,
|
|
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
|
closeDetail, loadStats,
|
|
loadActivity, loadFailing, loadActive, recoverStalled,
|
|
}
|
|
})
|