feat(ia): wave 1 — Import tab dissolves, Maintenance regroups by system, one extension home
CI / frontend-build (push) Successful in 19s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m32s

Settings IA per the approved A3 design (the old layout was the two-app merge
fossilized):
- Import tab retired: ImportTriggerPanel + ImportTaskList deleted (manual
  /import scans stay API-level; imports arrive via downloads/extension, heal
  via the Layer-2 auto-refetch sweep, and show in Activity). ImportFiltersForm
  moves to Maintenance → 'Ingestion & filters' and loads its own settings; the
  import store shrinks to settings-only (no remaining consumers of the
  scan/task-list machinery). Overview's pending banner now points at Activity.
- Maintenance regrouped: Ingestion & filters / GPU agent & embeddings
  (GpuAgent, Failed processing, CPU embedding backfill) / Tagging (sliders,
  Heads, Aliases) / Library health (MissingFiles, Thumbnails, DB, Archive
  re-extract demoted last) / Storage.
- One extension home: BrowserExtensionCard moves from Settings → Overview to
  Subscriptions → Settings, above the API key bar it authenticates.
- Single-color import filter WIRED: skip_single_color/threshold existed since
  FC-2 but nothing read them (the audit module's docstring said as much) —
  now enforced on both import paths via the audit's canonical predicate
  (tolerance 30, matching the Cleanup card default; animated images exempt
  like the transparency check). Default stays off; test added.
- Dead weight: PlaceholderView (zero refs) and the permanently-disabled
  'Export failed logs (CSV — v2)' menu stub deleted; stale docs fixed
  (celery queue docstring, threshold comment citing retired tasks, ml
  package docstring, HeadsCard 'replaces Camie' blurb).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-02 17:37:21 -04:00
parent 19b962f1a7
commit 5b34c9221c
16 changed files with 143 additions and 598 deletions
@@ -2,7 +2,7 @@
<MaintenanceTile
icon="mdi-brain"
title="Concept heads (the learning suggester)"
blurb="Train the per-concept heads that turn your tags into suggestions — they replace Camie and sharpen every time you accept or reject."
blurb="Train the per-concept heads that turn your tags into suggestions — they learn from your library and sharpen every time you accept or reject."
:open="running"
>
<p class="fc-muted text-body-2 mb-3">
@@ -90,10 +90,14 @@
</template>
<script setup>
import { reactive, watch } from 'vue'
import { onMounted, reactive, watch } from 'vue'
import { useImportStore } from '../../stores/import.js'
const store = useImportStore()
// Self-sufficient since the Import tab dissolved (2026-07-02): this form now
// lives in Maintenance → Ingestion & filters and loads its own settings
// instead of relying on the old tab's mount hook.
onMounted(() => { if (!store.settings) store.loadSettings() })
// Labelled stops so the less-initiated get the gist without knowing what a
// Hamming distance is. 0 = byte-for-byte only; 10 = the shipped default.
const PHASH_TICKS = { 0: 'Exact', 4: 'Strict', 10: 'Default', 16: 'Loose' }
@@ -1,254 +0,0 @@
<template>
<v-card>
<CardHeading title="Recent import tasks">
<v-spacer />
<v-select
v-model="statusFilter" :items="statusOptions" density="compact"
hide-details style="max-width: 180px;" @update:model-value="onFilterChange"
/>
<v-btn variant="text" rounded="pill" size="small" @click="onRefresh">
<v-icon start>mdi-refresh</v-icon>
Refresh
</v-btn>
<v-btn
variant="text" rounded="pill" size="small" color="warning"
:disabled="!hasFailed" @click="onRetryFailed"
>
Retry failed
</v-btn>
<v-btn
variant="text" rounded="pill" size="small" color="warning"
:disabled="!hasStuck" @click="onClearStuckOpen"
>
Clear stuck
</v-btn>
<v-btn
variant="text" rounded="pill" size="small" color="error"
@click="onClearOpen"
>
Clear completed
</v-btn>
</CardHeading>
<v-data-table-virtual
:headers="headers" :items="store.tasks" :loading="store.tasksLoading"
height="480" density="compact" fixed-header no-data-text="No tasks yet trigger a scan above."
>
<template #item.status="{ item }">
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
{{ item.status }}
</v-chip>
</template>
<template #item.source_path="{ item }">
<span :title="item.source_path">{{ shorten(item.source_path) }}</span>
</template>
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
<template #item.error="{ item }">
<button
v-if="item.error" type="button" class="fc-err-link text-caption"
@click="openError(`Task ${item.id} failed`, item.error)"
title="Click for full error"
>{{ shorten(item.error, 60) }}</button>
</template>
<template #item.actions="{ item }">
<v-btn
v-if="item.status === 'failed'"
icon size="x-small" variant="text"
:loading="refetching === item.id"
@click="onRefetch(item)"
>
<v-icon size="small">mdi-cloud-refresh</v-icon>
<v-tooltip activator="parent" location="top">
Re-fetch original (re-download from source)
</v-tooltip>
</v-btn>
</template>
</v-data-table-virtual>
<div v-if="store.hasMore" class="d-flex justify-center py-3">
<v-btn variant="text" size="small" @click="onLoadMore">Load more</v-btn>
</div>
<v-dialog v-model="clearDialog" max-width="400">
<v-card>
<v-card-title>Clear completed tasks</v-card-title>
<v-card-text>
<v-select
v-model="clearAgeDays" label="Older than"
:items="[
{ title: 'All finished', value: 0 },
{ title: '1 day', value: 1 },
{ title: '7 days', value: 7 },
{ title: '30 days', value: 30 }
]"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="clearDialog = false">Cancel</v-btn>
<v-btn color="error" rounded="pill" @click="onClearConfirm">Clear</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="clearStuckDialog" max-width="480">
<v-card>
<v-card-title>Clear stuck tasks</v-card-title>
<v-card-text>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Force every <strong>pending / queued / processing</strong> task to
<strong>failed</strong> and finalize any active batch that
has no remaining work. Use this when the automatic recovery
sweep keeps re-queueing the same row (e.g., corrupt file in
an autoretry loop, or worker model missing).
</v-alert>
<p class="text-body-2">
Tasks remain in the database with status=<code>failed</code>;
click <em>Retry failed</em> once the underlying cause is
resolved to re-queue them.
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="clearStuckDialog = false">Cancel</v-btn>
<v-btn color="warning" rounded="pill" @click="onClearStuckConfirm">Clear stuck</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<ErrorDetailModal
v-model="showErrorModal"
:title="errorModalTitle"
:message="errorModalMessage"
/>
</v-card>
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue'
import { useImportStore } from '../../stores/import.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import CardHeading from '../common/CardHeading.vue'
const store = useImportStore()
const statusFilter = ref(null)
const clearDialog = ref(false)
// Click-to-open modal for full error text (operator-flagged 2026-05-26
// — the prior :title="..." tooltip cramped multi-line SQLAlchemy
// tracebacks into an unusable popup with no copy-paste affordance).
const showErrorModal = ref(false)
const errorModalTitle = ref('')
const errorModalMessage = ref('')
function openError(title, message) {
errorModalTitle.value = title
errorModalMessage.value = message || ''
showErrorModal.value = true
}
const clearAgeDays = ref(7)
const clearStuckDialog = ref(false)
const statusOptions = [
{ title: 'All', value: null },
{ title: 'Pending', value: 'pending' },
{ title: 'Queued', value: 'queued' },
{ title: 'Processing', value: 'processing' },
{ title: 'Complete', value: 'complete' },
{ title: 'Skipped', value: 'skipped' },
{ title: 'Failed', value: 'failed' }
]
const headers = [
{ title: 'Status', key: 'status', sortable: false, width: 120 },
{ title: 'Source', key: 'source_path', sortable: false },
{ title: 'Size', key: 'size_bytes', sortable: false, width: 90 },
{ title: 'Created', key: 'created_at', sortable: false, width: 150 },
{ title: 'Note', key: 'error', sortable: false },
{ title: '', key: 'actions', sortable: false, width: 56 }
]
const refetching = ref(null)
const _REFETCH_MSG = {
refetch_queued: { text: 'Re-fetch queued — re-downloading from source', type: 'success' },
no_source: { text: 'No re-fetchable source (filesystem import — replace the file manually)', type: 'info' },
already_refetched: { text: 'Already re-fetched once', type: 'info' },
}
async function onRefetch(item) {
refetching.value = item.id
try {
const res = await store.refetchTask(item.id)
const msg = _REFETCH_MSG[res.status] || { text: `Re-fetch: ${res.status}`, type: 'info' }
toast(msg)
} catch (e) {
toast({ text: `Re-fetch failed: ${e.message}`, type: 'error' })
} finally {
refetching.value = null
}
}
const hasFailed = computed(() => store.tasks.some(t => t.status === 'failed'))
const hasStuck = computed(() => store.tasks.some(
t => t.status === 'pending' || t.status === 'queued' || t.status === 'processing'
))
function statusColor(s) {
return {
complete: 'success',
skipped: 'warning',
failed: 'error',
processing: 'accent',
queued: 'info',
pending: 'info'
}[s] || 'default'
}
function shorten(s, max = 90) {
if (!s) return ''
if (s.length <= max) return s
const head = Math.floor((max - 3) * 0.6)
const tail = max - 3 - head
return s.slice(0, head) + '...' + s.slice(-tail)
}
function formatBytes(b) {
if (!b) return ''
const units = ['B', 'KiB', 'MiB', 'GiB']
let i = 0; let v = b
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
function formatDate(s) {
try { return new Date(s).toLocaleString() } catch { return s }
}
async function onRefresh() { await store.loadTasks(true) }
function onFilterChange() { store.setStatusFilter(statusFilter.value); store.loadTasks(true) }
async function onLoadMore() { await store.loadTasks(false) }
async function onRetryFailed() { await store.retryFailed() }
function onClearOpen() { clearDialog.value = true }
async function onClearConfirm() {
await store.clearCompleted(clearAgeDays.value)
clearDialog.value = false
}
function onClearStuckOpen() { clearStuckDialog.value = true }
async function onClearStuckConfirm() {
await store.clearStuck()
clearStuckDialog.value = false
}
</script>
<style scoped>
.fc-err-link {
/* Truncated error preview as a clickable button — opens
ErrorDetailModal with the full text. Inherits the row's font
sizing so it doesn't visually drift from the prior tooltip-bearing
span. */
color: rgb(var(--v-theme-error, 220 80 80));
background: transparent;
border: 0;
padding: 0;
font: inherit;
text-align: left;
text-decoration: underline dotted;
cursor: pointer;
}
.fc-err-link:hover { text-decoration: underline; }
</style>
@@ -1,97 +0,0 @@
<template>
<v-card>
<v-card-title>Trigger scan</v-card-title>
<v-card-text>
<div v-if="store.activeBatch" class="d-flex align-center mb-3" style="gap: 12px;">
<v-progress-circular
indeterminate color="accent" size="20"
/>
<span>
{{ store.activeBatch.scan_mode === 'deep' ? 'Deep scanning' : 'Scanning' }}
{{ store.activeBatch.source_path || '/import' }}
imported {{ store.activeBatch.imported }},
<template v-if="store.activeBatch.scan_mode === 'deep'">
refreshed {{ store.activeBatch.refreshed || 0 }},
</template>
skipped {{ store.activeBatch.skipped }},
failed {{ store.activeBatch.failed }} /
{{ store.activeBatch.total_files }} files
</span>
<v-spacer />
<v-btn
variant="text" rounded="pill" size="small" color="warning"
:loading="clearing" @click="onClearStuck"
>
Clear stuck
</v-btn>
</div>
<p class="text-body-2 mb-3">
<span v-if="!store.activeBatch">
<strong>Quick scan</strong> walks <code>/import</code> and enqueues
new files only.
<strong>Deep scan</strong> additionally re-walks already-imported
files so updated sidecar metadata (post title/date/attribution) and
previously-NULL phashes / artist links get refreshed. Use after
bulk-downloading fresh sidecars for existing content. Both modes
route non-media + sidecar pairs through PostAttachment capture.
</span>
<span v-else>
An active batch is in progress. Wait for it to finish, or click
<em>Clear stuck</em> above if it has been wedged with no
measurable progress.
</span>
</p>
<div class="d-flex flex-wrap" style="gap: 12px;">
<v-btn
color="primary" rounded="pill"
:disabled="!!store.activeBatch"
:loading="busy === 'quick'"
@click="trigger('quick')"
>
<v-icon start>mdi-magnify-scan</v-icon>
Quick scan
</v-btn>
<v-btn
color="secondary" rounded="pill" variant="tonal"
:disabled="!!store.activeBatch"
:loading="busy === 'deep'"
@click="trigger('deep')"
>
<v-icon start>mdi-magnify-plus-outline</v-icon>
Deep scan
</v-btn>
</div>
<v-alert v-if="store.triggerError" type="error" variant="tonal" class="mt-3" closable>
{{ store.triggerError }}
</v-alert>
</v-card-text>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useImportStore } from '../../stores/import.js'
const store = useImportStore()
const busy = ref(null)
const clearing = ref(false)
async function trigger(mode) {
busy.value = mode
try { await store.triggerScan(mode) } catch {} finally { busy.value = null }
}
async function onClearStuck() {
clearing.value = true
try {
await store.clearStuck()
} catch {
// store surfaces error via triggerError if needed
} finally {
clearing.value = false
}
}
</script>
@@ -1,36 +1,59 @@
<template>
<div class="fc-maint">
<p class="fc-muted text-body-2 mb-5">
One-off backfills, tagging config and storage tools. Heads train nightly
and auto-apply earned tags. Click a tile to open it.
Processing, tagging and storage tools, grouped by system. Heads train
nightly and auto-apply earned tags. Click a tile to open it.
</p>
<section class="fc-section">
<h3 class="fc-section__title">Backfills &amp; reprocessing</h3>
<p class="fc-section__hint">Re-run tagging, thumbnails, extraction and DB upkeep.</p>
<div class="fc-tile-grid">
<MLBackfillCard />
<ThumbnailBackfillCard />
<ArchiveReextractCard />
<MissingFileRepairCard />
<h3 class="fc-section__title">Ingestion &amp; filters</h3>
<p class="fc-section__hint">
What gets imported dedup sensitivity, size/transparency/solid-color
filters. Applies to downloads and folder imports alike.
</p>
<div class="fc-tile-stack">
<ImportFiltersForm />
</div>
</section>
<section class="fc-section">
<h3 class="fc-section__title">GPU agent &amp; embeddings</h3>
<p class="fc-section__hint">
The desktop agent that does the heavy lifting, its failure triage, and
the CPU fallback.
</p>
<div class="fc-tile-stack">
<GpuAgentCard />
<GpuTriageCard />
<DbMaintenanceCard />
<MLBackfillCard />
</div>
</section>
<section class="fc-section">
<h3 class="fc-section__title">Tagging</h3>
<p class="fc-section__hint">
Suggestion thresholds, the auto-apply allowlist and tag aliases.
Suggestion thresholds, trained heads and tag aliases.
</p>
<div class="fc-tile-stack">
<MLThresholdSliders />
<HeadsCard />
<GpuAgentCard />
<AliasTable />
</div>
</section>
<section class="fc-section">
<h3 class="fc-section__title">Library health</h3>
<p class="fc-section__hint">
Self-healing and repair: missing files, thumbnails, database upkeep.
</p>
<div class="fc-tile-grid">
<MissingFileRepairCard />
<ThumbnailBackfillCard />
<DbMaintenanceCard />
<ArchiveReextractCard />
</div>
</section>
<section class="fc-section">
<h3 class="fc-section__title">Storage</h3>
<p class="fc-section__hint">Database + image backups and restore.</p>
@@ -44,6 +67,7 @@
<script setup>
import { onMounted, onUnmounted } from 'vue'
import ImportFiltersForm from './ImportFiltersForm.vue'
import MLBackfillCard from './MLBackfillCard.vue'
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue'
@@ -18,14 +18,6 @@
subtitle="Mark stranded pending/running events as error (also runs every 5 min)"
@click="emit('recover-stalled')"
/>
<v-list-item
:disabled="true"
prepend-icon="mdi-download-box"
title="Export failed logs"
subtitle="CSV dump — v2"
>
<v-tooltip activator="parent" location="start">Deferred to a future release</v-tooltip>
</v-list-item>
</v-list>
</v-menu>
</template>
@@ -33,7 +25,8 @@
<script setup>
// The Downloads tab parent (DownloadsTab.vue) owns the actual retry/sweep
// handlers — same toast + refresh logic already used by the failing-sources
// RETRY ALL button. We just emit. Import-pipeline maintenance lives in
// Settings → Imports (ImportTaskList.vue), not here.
// RETRY ALL button. We just emit. (A permanently-disabled "Export failed
// logs" stub sat here until 2026-07-02 — retired; the event list + detail
// modal cover forensics.)
const emit = defineEmits(['retry-failed', 'recover-stalled'])
</script>
@@ -1,12 +1,18 @@
<template>
<div>
<!-- One extension home (2026-07-02): install/manifest card (moved here
from Settings Overview) + the API key bar it authenticates with.
The extension feeds THIS view cookies + one-click sources so its
setup lives with the rest of the ingestion config. -->
<h3 class="text-h6 mb-3">Browser extension</h3>
<BrowserExtensionCard class="mb-3" />
<ExtensionKeyBar class="mb-4" />
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
{{ String(credentialsStore.error) }}
</v-alert>
<h3 class="text-h6 mb-3">Platform credentials</h3>
<h3 class="text-h6 mb-3 mt-6">Platform credentials</h3>
<v-row>
<v-col
v-for="p in platformsStore.list"
@@ -179,6 +185,7 @@ import { onMounted, reactive, ref, watch } from 'vue'
import { usePlatformsStore } from '../../stores/platforms.js'
import { useCredentialsStore } from '../../stores/credentials.js'
import { useImportStore } from '../../stores/import.js'
import BrowserExtensionCard from '../settings/BrowserExtensionCard.vue'
import ExtensionKeyBar from '../credentials/ExtensionKeyBar.vue'
import CredentialUploadDialog from '../credentials/CredentialUploadDialog.vue'
import CredentialCard from './CredentialCard.vue'