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 @@
+
+
+
+ The library keeps one folder per artist, named after them. Files written
+ under older rules can sit in another artist's folder — this moves them
+ home, updating the record and the file together. Every run can be
+ reverted, so the safe way to use it is one artist at a time: run it,
+ look at the gallery, then continue or put it back.
+
+
+ {{ error }}
+
+
+
+ Check placement
+
+ {{ layout.misplaced_rows.toLocaleString() }} of
+ {{ layout.total_rows.toLocaleString() }} images are in the wrong folder
+
+ — across {{ layout.artists.length }} artists
+
+
+
+
+
+
+
+ | Artist |
+ To move |
+ Currently in |
+ Plan |
+
+
+
+
+ | {{ a.name }} |
+ {{ a.misplaced_rows.toLocaleString() }} |
+ {{ a.stray_dirs.join(', ') }} |
+
+ Plan
+ |
+
+
+
+
+
+
+
+ Runs
+
+
+
+ | When |
+ Scope |
+ Status |
+ Planned |
+ Moved |
+ Refused |
+ Actions |
+
+
+
+
+ |
+ {{ formatRelative(r.started_at) }}
+ |
+ {{ artistName(r.artist_id) }} |
+
+
+ {{ statusIcon(r.status) }}
+
+ {{ r.status }}
+ |
+ {{ r.planned_count.toLocaleString() }} |
+ {{ r.moved_count.toLocaleString() }} |
+
+
+ {{ r.refused_count.toLocaleString() }}
+
+ |
+
+
+
+
+
+
+ |
+
+
+ |
+ No runs yet. Check placement above, then plan one artist.
+ |
+
+
+
+
+
+
+
+
+ Run {{ reviewRun?.id }} — {{ reviewRun?.planned_count?.toLocaleString() }} moves
+
+
+
+ Showing the first {{ REVIEW_LIMIT }}. Each row moves the file and
+ its record together; nothing is overwritten.
+
+
+
+
+ | {{ m.from }} |
+ → {{ m.to }} |
+
+
+
+
+
Refused ({{ reviewRun.refusals.length }})
+
+ Rows the run declined to touch — the source moved, the
+ destination was taken, or the record changed since planning.
+
+
#{{ f.image_id }} — {{ f.reason }}
+
+
+
+
+ Close
+
+
+
+
+
+
+ {{ confirmTitle }}
+ {{ confirmMessage }}
+
+
+ Cancel
+ Go ahead
+
+
+
+
+
+
+
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
+}