diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 3931384..0c44eaa 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -54,6 +54,7 @@ Self-healing and repair: missing files, thumbnails, database upkeep.

+ @@ -80,6 +81,7 @@ import MLBackfillCard from './MLBackfillCard.vue' import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue' import ArchiveReextractCard from './ArchiveReextractCard.vue' import MissingFileRepairCard from './MissingFileRepairCard.vue' +import PlacementCard from './PlacementCard.vue' import GpuTriageCard from './GpuTriageCard.vue' import DbMaintenanceCard from './DbMaintenanceCard.vue' import VideoEmbeddingCard from './VideoEmbeddingCard.vue' diff --git a/frontend/src/components/settings/PlacementCard.vue b/frontend/src/components/settings/PlacementCard.vue new file mode 100644 index 0000000..462c255 --- /dev/null +++ b/frontend/src/components/settings/PlacementCard.vue @@ -0,0 +1,312 @@ + + + diff --git a/frontend/src/stores/cleanup.js b/frontend/src/stores/cleanup.js index 82813a2..73b17d7 100644 --- a/frontend/src/stores/cleanup.js +++ b/frontend/src/stores/cleanup.js @@ -71,10 +71,66 @@ export const useCleanupStore = defineStore('cleanup', () => { return await api.post(`/api/cleanup/audit/${id}/cancel`) } + // --- placement reconciler (milestone #421) -------------------------------- + // + // Runs are server-side rows, so the DATABASE is the durable state here — + // no localStorage resurfacing (useMaintenanceTask) is needed. Reload the + // page, open it on another machine, and the run and its status are simply + // there. That also means a plan survives being walked away from for a day. + + const placementRuns = ref([]) + const layout = ref(null) + + // The survey: which rows sit outside their artist's directory. check_disk + // additionally stats every destination (collisions, missing sources) and + // costs one stat per misplaced row over NFS, so it is opt-in. + async function loadLayout(checkDisk = false) { + layout.value = await api.get('/api/cleanup/layout', { + params: checkDisk ? { check_disk: 1 } : {}, + }) + return layout.value + } + + // id -> name, so a run row can say "Conto" instead of "#47". The runs + // endpoint carries artist_id alone: the name belongs to the artist, and + // denormalising it into every run would go stale the moment one is renamed. + async function loadArtistNames() { + const rows = await api.get('/api/artists/names') + return Object.fromEntries((rows || []).map(a => [a.id, a.name])) + } + + async function loadPlacementRuns(limit = 25) { + const body = await api.get('/api/cleanup/placement/runs', { params: { limit } }) + placementRuns.value = body.runs || [] + return placementRuns.value + } + + // Detail carries `moves` — the plan the operator reads before agreeing. + async function getPlacementRun(id) { + return await api.get(`/api/cleanup/placement/runs/${id}`) + } + + async function planPlacement(artistId = null) { + return await api.post('/api/cleanup/placement/plan', { + body: artistId === null ? {} : { artist_id: artistId }, + }) + } + + async function applyPlacement(id) { + return await api.post(`/api/cleanup/placement/runs/${id}/apply`) + } + + async function revertPlacement(id) { + return await api.post(`/api/cleanup/placement/runs/${id}/revert`) + } + return { defaults, recentRuns, loadDefaults, previewMinDim, deleteMinDim, startAudit, getAudit, loadHistory, latestAuditForRule, applyAudit, cancelAudit, + placementRuns, layout, + loadLayout, loadArtistNames, loadPlacementRuns, getPlacementRun, + planPlacement, applyPlacement, revertPlacement, } }) diff --git a/frontend/test/placement.spec.js b/frontend/test/placement.spec.js new file mode 100644 index 0000000..af5fc1a --- /dev/null +++ b/frontend/test/placement.spec.js @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useCleanupStore } from '../src/stores/cleanup.js' +import { stubFetch } from './stubFetch.js' + + +describe('placement reconciler store (milestone #421)', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.restoreAllMocks()) + + it('loadLayout leaves the disk check off by default', async () => { + const s = useCleanupStore() + let seen = '' + stubFetch((url) => { + seen = url + return { status: 200, body: { total_rows: 10, misplaced_rows: 2, artists: [] } } + }) + await s.loadLayout() + // One stat per misplaced row over NFS is the cost; it must be opt-in. + expect(seen).not.toContain('check_disk') + expect(s.layout.misplaced_rows).toBe(2) + }) + + it('loadLayout asks for the disk check when requested', async () => { + const s = useCleanupStore() + let seen = '' + stubFetch((url) => { + seen = url + return { status: 200, body: { total_rows: 0, misplaced_rows: 0, artists: [] } } + }) + await s.loadLayout(true) + expect(seen).toContain('check_disk=1') + }) + + it('planPlacement scopes to an artist when given one', async () => { + const s = useCleanupStore() + let sent = null + stubFetch((url, init) => { + sent = JSON.parse(init.body) + return { status: 202, body: { status: 'dispatched' } } + }) + await s.planPlacement(47) + expect(sent).toEqual({ artist_id: 47 }) + }) + + it('planPlacement sends no scope for the whole library', async () => { + const s = useCleanupStore() + let sent = null + stubFetch((url, init) => { + sent = JSON.parse(init.body) + return { status: 202, body: { status: 'dispatched' } } + }) + await s.planPlacement() + // Not `{artist_id: null}` — the endpoint rejects a non-integer, and an + // absent key is how "whole library" is spelled. + expect(sent).toEqual({}) + }) + + it('loadPlacementRuns keeps the rows for the table', async () => { + const s = useCleanupStore() + stubFetch(() => ({ + status: 200, + body: { runs: [{ id: 3, status: 'ready', planned_count: 12 }] }, + })) + await s.loadPlacementRuns() + expect(s.placementRuns).toHaveLength(1) + expect(s.placementRuns[0].status).toBe('ready') + }) + + it('getPlacementRun carries the moves — it is the preview', async () => { + const s = useCleanupStore() + stubFetch(() => ({ + status: 200, + body: { + id: 3, status: 'ready', planned_count: 1, + moves: [{ image_id: 9, from: '/images/Conto/x.png', to: '/images/conto/x.png' }], + }, + })) + const run = await s.getPlacementRun(3) + expect(run.moves[0].from).toBe('/images/Conto/x.png') + expect(run.moves[0].to).toBe('/images/conto/x.png') + }) + + it('loadArtistNames maps id to name for the run rows', async () => { + const s = useCleanupStore() + stubFetch(() => ({ + status: 200, + body: [{ id: 47, name: 'Conto', slug: 'conto' }], + })) + expect(await s.loadArtistNames()).toEqual({ 47: 'Conto' }) + }) + + it('loadArtistNames survives an empty roster', async () => { + const s = useCleanupStore() + stubFetch(() => ({ status: 200, body: [] })) + expect(await s.loadArtistNames()).toEqual({}) + }) + + it('applyPlacement and revertPlacement post to their own run', async () => { + const s = useCleanupStore() + const urls = [] + stubFetch((url) => { + urls.push(url) + return { status: 202, body: { status: 'dispatched' } } + }) + await s.applyPlacement(5) + await s.revertPlacement(5) + expect(urls[0]).toContain('/api/cleanup/placement/runs/5/apply') + expect(urls[1]).toContain('/api/cleanup/placement/runs/5/revert') + }) +}) diff --git a/frontend/test/stubFetch.js b/frontend/test/stubFetch.js new file mode 100644 index 0000000..80dc8fd --- /dev/null +++ b/frontend/test/stubFetch.js @@ -0,0 +1,24 @@ +import { vi } from 'vitest' + +// The canonical fetch stub for store specs. +// +// `handler(url, init)` returns `{ status, body }`; body is JSON-encoded, and +// `ok` is derived from the status so a store's error path can be exercised by +// returning 4xx/5xx. Returns the vi.fn so a caller can assert on calls. +// +// Extracted 2026-09-21 from six specs carrying byte-identical copies +// (adminStore, credentials, dbMaintenance, gallery, suggestions, +// galleryRelatedStrip). Those still hold their own; migrate each the next +// time it is touched rather than in one sweep. +export function stubFetch (handler) { + globalThis.fetch = vi.fn(async (url, init) => { + const { status, body } = handler(url, init) + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + text: async () => (body == null ? '' : JSON.stringify(body)), + } + }) + return globalThis.fetch +}