Files
FabledCurator/frontend/src/stores/admin.js
T
bvandeusen 7c19ad91ed
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m28s
feat: cap-aware autoscaler + token-gated whole-instance tag reset (operator feedback)
Autoscaler (agent 2026-07-02.5): the buffer-occupancy signal alone would peg
downloaders at DL_MAX while the bandwidth CAP — not concurrency — is the real
constraint (8 streams sharing 8 MB/s move no more data than 4). Growth is now
gated on the pipe having headroom (net < 85% of cap) and a pipe pinned at the
cap (>= 95%) sheds streams down to 3; dead band prevents flapping. The UI hint
says 'holding at the bandwidth cap' and /status reports bw_capped, so the
behavior is legible without tests that need the ML stack.

Reset content tagging: stays a FULL-instance reset (operator's call), but now
lives in a fenced 'Danger zone' section on Cleanup and the apply is gated by a
preview-derived confirm token (mirrors the Tier-C bulk-delete pattern — stale
counts are rejected server-side). Copy no longer claims suggestions repopulate:
it says plainly the heads' training examples are deleted and re-tagging starts
fresh. Moved out of TagMaintenanceCard into DangerZoneCard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-02 16:14:48 -04:00

160 lines
5.3 KiB
JavaScript

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
export const useAdminStore = defineStore('admin', () => {
const api = useApi()
const lastError = ref(null)
// Every admin action runs through _guard: reset lastError, run the call,
// surface its message to lastError on failure, and re-throw so callers still
// see the rejection. One copy of the capture/rethrow instead of 13.
async function _guard(fn) {
lastError.value = null
try {
return await fn()
} catch (e) {
lastError.value = e.message
throw e
}
}
// The Tier-A maintenance endpoints share one shape: POST a dry_run flag
// (default the SAFE preview), optionally with extra body fields. The backend's
// same predicate drives preview + apply, so the UI just toggles dryRun.
function _dryRunPost(url, { dryRun = true, ...extra } = {}) {
return _guard(() => api.post(url, { body: { dry_run: dryRun, ...extra } }))
}
// --- Tier-C: artist cascade ---------------------------------------
function projectArtistCascade(slug) {
return _guard(() => api.post(
`/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`,
{ body: { dry_run: true } },
))
}
function dispatchArtistCascade(slug, confirm) {
return _guard(() => api.post(
`/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`,
{ body: { dry_run: false, confirm } },
))
}
// --- Tier-C: bulk image delete ------------------------------------
function projectBulkImageDelete(imageIds) {
return _guard(() => api.post(
'/api/admin/images/bulk-delete',
{ body: { image_ids: imageIds, dry_run: true } },
))
}
function dispatchBulkImageDelete(imageIds, confirm) {
return _guard(() => api.post(
'/api/admin/images/bulk-delete',
{ body: { image_ids: imageIds, dry_run: false, confirm } },
))
}
// --- Tier-B: tag delete + merge -----------------------------------
function deleteTag(tagId) {
return _guard(() => api.delete(`/api/admin/tags/${tagId}`))
}
// dryRun → {preview: {...}} (non-mutating counts + sample); else {result}.
function mergeTags(destId, sourceId, { dryRun = false } = {}) {
return _guard(() => api.post(
`/api/admin/tags/${destId}/merge`,
{ body: { source_id: sourceId, dry_run: dryRun } },
))
}
async function tagUsageCount(tagId) {
return _guard(async () => {
const body = await api.get(`/api/admin/tags/${tagId}/usage-count`)
return body.count
})
}
// --- Tier-A: dry-run/apply maintenance ----------------------------
function pruneUnusedTags(opts = {}) {
return _dryRunPost('/api/admin/tags/prune-unused', opts)
}
// Delete bare posts (no images + no attachments) — the empty-post flood
// shells. Preview/apply parity on the backend; UI previews then confirms.
function pruneBarePosts(opts = {}) {
return _dryRunPost('/api/admin/posts/prune-bare', opts)
}
// Unify duplicate post rows (gallery-dl attachment-id + native post-id for the
// same real post) onto one post-id-keyed keeper. Images untouched. sourceId
// (optional) scopes to one source; sent as source_id in the body.
function reconcileDuplicatePosts({ sourceId = null, ...opts } = {}) {
return _dryRunPost('/api/admin/posts/reconcile-duplicates', {
...opts, source_id: sourceId,
})
}
// Destructive whole-instance reset: deletes ALL general + character tags AND
// their applications (the heads' training data included) — fandom + series
// preserved. dry-run returns a `confirm` token; the apply must pass it back
// ({ dryRun: false, confirm }) or the server rejects it.
function resetContentTagging(opts = {}) {
return _dryRunPost('/api/admin/tags/reset-content', opts)
}
// #714: Title-Case the back-catalog + merge case/whitespace-variant tags.
// dry-run returns a projection inline; live returns {task_id} (long op —
// FK repoints) the caller polls via pollTaskUntilDone.
function normalizeTags(opts = {}) {
return _dryRunPost('/api/admin/tags/normalize', opts)
}
// --- Task progress polling (taps FC-3i activity dashboard) --------
/**
* Polls /api/system/activity/runs?queue=maintenance every 3s,
* resolves when a task_run row with the given celery task_id
* reaches a terminal status (ok / error / timeout). Returns the
* row. Times out after 30 min by default.
*/
async function pollTaskUntilDone(taskId, { timeoutMs = 1_800_000 } = {}) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const body = await api.get(
'/api/system/activity/runs',
{ params: { queue: 'maintenance', limit: 20 } },
)
const row = (body.runs || []).find(r => r.celery_task_id === taskId)
if (row && ['ok', 'error', 'timeout'].includes(row.status)) {
return row
}
await new Promise((r) => setTimeout(r, 3000))
}
throw new Error(`task ${taskId} did not finish within ${timeoutMs}ms`)
}
return {
lastError,
projectArtistCascade,
dispatchArtistCascade,
projectBulkImageDelete,
dispatchBulkImageDelete,
deleteTag,
mergeTags,
tagUsageCount,
pruneUnusedTags,
pruneBarePosts,
reconcileDuplicatePosts,
resetContentTagging,
normalizeTags,
pollTaskUntilDone,
}
})