feat: File placement card — survey, plan per artist, apply, put back (4246, slice 3c)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m17s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m17s
The UI half of the reconciler, in Maintenance. Survey shows how many images sit in the wrong artist's folder and which folders they are in; each artist gets its own Plan button; each run can be reviewed, applied, and put back. Deliberately NOT using useMaintenanceTask. That composable stashes a task id in localStorage so a result survives navigate-away, which is the right answer when the only record is a Celery result. Here the runs are database rows — so a reload, another machine, or coming back tomorrow simply shows the same state, because the state IS the row. The card polls the runs endpoint instead. Copy avoids the vocabulary this work has been tripping over: "folder", "put back", "in the wrong folder" rather than artist_id, revert and canonical. The one thing the operator most needs to know — nothing here changes who an image belongs to — is what the blurb says first. Three things I had assumed and checked instead: MaintenanceTile lives in common/ not settings/; there is no generic ConfirmDialog (BackupCard uses a purpose-built modal), so this uses a plain v-dialog; and `loadArtistNames` did not exist — it does now, mapping id to name so a run row reads "Conto" rather than "#47". The name deliberately is not denormalised into the run: it belongs to the artist and would go stale on a rename. Also extracted `stubFetch` to frontend/test/stubFetch.js. The shape ledger flagged it as a byte-identical duplicate across five specs and this would have been the sixth; the others keep their copies until each is next touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -54,6 +54,7 @@
|
||||
Self-healing and repair: missing files, thumbnails, database upkeep.
|
||||
</p>
|
||||
<div class="fc-tile-grid">
|
||||
<PlacementCard />
|
||||
<MissingFileRepairCard />
|
||||
<ThumbnailBackfillCard />
|
||||
<DbMaintenanceCard />
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-folder-move"
|
||||
title="File placement"
|
||||
blurb="Put every image in its own artist's folder, one artist at a time."
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="error" type="warning" variant="tonal" density="compact" class="mb-3"
|
||||
>{{ error }}</v-alert>
|
||||
|
||||
<!-- Survey -->
|
||||
<div class="fc-settings-row mb-3">
|
||||
<v-btn
|
||||
variant="tonal" rounded="pill" prepend-icon="mdi-magnify"
|
||||
:loading="surveying" @click="onSurvey"
|
||||
>Check placement</v-btn>
|
||||
<span v-if="layout" class="fc-muted text-body-2">
|
||||
{{ layout.misplaced_rows.toLocaleString() }} of
|
||||
{{ layout.total_rows.toLocaleString() }} images are in the wrong folder
|
||||
<template v-if="layout.misplaced_rows">
|
||||
— across {{ layout.artists.length }} artists
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<v-table v-if="layout && layout.artists.length" density="compact" class="mb-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artist</th>
|
||||
<th class="text-right">To move</th>
|
||||
<th>Currently in</th>
|
||||
<th class="text-right">Plan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in layout.artists" :key="a.artist_id">
|
||||
<td>{{ a.name }}</td>
|
||||
<td class="text-right fc-tabular">{{ a.misplaced_rows.toLocaleString() }}</td>
|
||||
<td class="fc-muted text-body-2">{{ a.stray_dirs.join(', ') }}</td>
|
||||
<td class="text-right">
|
||||
<v-btn
|
||||
size="small" variant="text" :loading="planningId === a.artist_id"
|
||||
@click="onPlan(a.artist_id)"
|
||||
>Plan</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<!-- Runs -->
|
||||
<h3 class="fc-section-title">Runs</h3>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Scope</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Planned</th>
|
||||
<th class="text-right">Moved</th>
|
||||
<th class="text-right">Refused</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in store.placementRuns" :key="r.id">
|
||||
<td class="fc-tabular" :title="r.started_at">
|
||||
{{ formatRelative(r.started_at) }}
|
||||
</td>
|
||||
<td>{{ artistName(r.artist_id) }}</td>
|
||||
<td>
|
||||
<v-icon size="small" :color="statusColor(r.status)">
|
||||
{{ statusIcon(r.status) }}
|
||||
</v-icon>
|
||||
{{ r.status }}
|
||||
</td>
|
||||
<td class="text-right fc-tabular">{{ r.planned_count.toLocaleString() }}</td>
|
||||
<td class="text-right fc-tabular">{{ r.moved_count.toLocaleString() }}</td>
|
||||
<td class="text-right fc-tabular">
|
||||
<span :class="r.refused_count ? 'text-warning' : 'fc-muted'">
|
||||
{{ r.refused_count.toLocaleString() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<KebabMenu :label="`Actions for placement run ${r.id}`">
|
||||
<v-list-item title="Review…" @click="onReview(r)" />
|
||||
<v-list-item
|
||||
title="Move files" :disabled="r.status !== 'ready' || !r.planned_count"
|
||||
@click="onApply(r)"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Put back…" :disabled="r.status !== 'applied'"
|
||||
@click="onRevert(r)"
|
||||
/>
|
||||
</KebabMenu>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!store.placementRuns.length">
|
||||
<td colspan="7" class="text-center fc-muted py-4">
|
||||
No runs yet. Check placement above, then plan one artist.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<!-- Review: the plan, which is what gets agreed to -->
|
||||
<v-dialog v-model="reviewOpen" max-width="900">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
Run {{ reviewRun?.id }} — {{ reviewRun?.planned_count?.toLocaleString() }} moves
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Showing the first {{ REVIEW_LIMIT }}. Each row moves the file and
|
||||
its record together; nothing is overwritten.
|
||||
</p>
|
||||
<v-table density="compact">
|
||||
<tbody>
|
||||
<tr v-for="m in reviewMoves" :key="m.image_id">
|
||||
<td class="fc-muted text-caption">{{ m.from }}</td>
|
||||
<td class="text-caption">→ {{ m.to }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<div v-if="reviewRun?.refusals?.length" class="mt-4">
|
||||
<h4 class="fc-section-title">Refused ({{ reviewRun.refusals.length }})</h4>
|
||||
<p class="fc-muted text-body-2">
|
||||
Rows the run declined to touch — the source moved, the
|
||||
destination was taken, or the record changed since planning.
|
||||
</p>
|
||||
<div
|
||||
v-for="f in reviewRun.refusals.slice(0, REVIEW_LIMIT)" :key="f.image_id"
|
||||
class="text-caption fc-muted"
|
||||
>#{{ f.image_id }} — {{ f.reason }}</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="reviewOpen = false">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="confirmOpen" max-width="520">
|
||||
<v-card>
|
||||
<v-card-title>{{ confirmTitle }}</v-card-title>
|
||||
<v-card-text class="text-body-2">{{ confirmMessage }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="confirmOpen = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
@click="confirmOpen = false; onConfirmed()"
|
||||
>Go ahead</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import KebabMenu from '../common/KebabMenu.vue'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import { useCleanupStore } from '../../stores/cleanup.js'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
|
||||
// A whole-library plan is tens of thousands of moves. The dialog is for
|
||||
// judging whether the moves look right, which a sample answers — the full
|
||||
// list is in the run row if anyone needs it.
|
||||
const REVIEW_LIMIT = 200
|
||||
|
||||
const store = useCleanupStore()
|
||||
const layout = ref(null)
|
||||
const artists = ref({})
|
||||
const surveying = ref(false)
|
||||
const planningId = ref(null)
|
||||
const error = ref('')
|
||||
|
||||
const reviewOpen = ref(false)
|
||||
const reviewRun = ref(null)
|
||||
const reviewMoves = computed(() => (reviewRun.value?.moves || []).slice(0, REVIEW_LIMIT))
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
const confirmTitle = ref('')
|
||||
const confirmMessage = ref('')
|
||||
let confirmAction = null
|
||||
|
||||
let poll = null
|
||||
|
||||
function artistName(id) {
|
||||
if (id === null || id === undefined) return 'Whole library'
|
||||
return artists.value[id] || `#${id}`
|
||||
}
|
||||
|
||||
function statusIcon(s) {
|
||||
return {
|
||||
ready: 'mdi-clipboard-text-outline', applied: 'mdi-check-circle',
|
||||
reverted: 'mdi-undo-variant', error: 'mdi-close-circle',
|
||||
running: 'mdi-timer-sand', cancelled: 'mdi-cancel',
|
||||
}[s] || 'mdi-help-circle'
|
||||
}
|
||||
function statusColor(s) {
|
||||
return {
|
||||
ready: 'accent', applied: 'success', reverted: 'info',
|
||||
error: 'error', running: 'warning',
|
||||
}[s] || 'grey'
|
||||
}
|
||||
|
||||
async function guard(fn) {
|
||||
error.value = ''
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
error.value = e?.message || String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSurvey() {
|
||||
surveying.value = true
|
||||
try {
|
||||
await guard(async () => { layout.value = await store.loadLayout() })
|
||||
} finally {
|
||||
surveying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onPlan(artistId) {
|
||||
planningId.value = artistId
|
||||
try {
|
||||
await guard(async () => {
|
||||
await store.planPlacement(artistId)
|
||||
toast('Planning — the run appears below when it is ready')
|
||||
await store.loadPlacementRuns()
|
||||
})
|
||||
} finally {
|
||||
planningId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onReview(run) {
|
||||
await guard(async () => {
|
||||
reviewRun.value = await store.getPlacementRun(run.id)
|
||||
reviewOpen.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function ask(title, message, action) {
|
||||
confirmTitle.value = title
|
||||
confirmMessage.value = message
|
||||
confirmAction = action
|
||||
confirmOpen.value = true
|
||||
}
|
||||
|
||||
function onApply(run) {
|
||||
ask(
|
||||
'Move these files?',
|
||||
`${run.planned_count.toLocaleString()} files move into their artist's `
|
||||
+ 'folder. The record and the file move together, and this run can be '
|
||||
+ 'put back afterwards.',
|
||||
async () => {
|
||||
await store.applyPlacement(run.id)
|
||||
toast('Moving files — this runs in the background')
|
||||
await store.loadPlacementRuns()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function onRevert(run) {
|
||||
ask(
|
||||
'Put these files back?',
|
||||
`${run.moved_count.toLocaleString()} files return to where they were `
|
||||
+ 'before this run.',
|
||||
async () => {
|
||||
await store.revertPlacement(run.id)
|
||||
toast('Putting files back — this runs in the background')
|
||||
await store.loadPlacementRuns()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function onConfirmed() {
|
||||
const action = confirmAction
|
||||
confirmAction = null
|
||||
if (action) await guard(action)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await guard(async () => {
|
||||
artists.value = await store.loadArtistNames()
|
||||
await store.loadPlacementRuns()
|
||||
})
|
||||
// Runs change state in a worker, so the table needs refreshing. The rows
|
||||
// are server-side, which is why no localStorage resurfacing is needed here
|
||||
// (useMaintenanceTask's job) — a reload or another machine sees the same
|
||||
// state because the state IS the row.
|
||||
poll = setInterval(() => { store.loadPlacementRuns().catch(() => {}) }, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (poll) clearInterval(poll) })
|
||||
</script>
|
||||
Reference in New Issue
Block a user