feat(fc2b): Settings Maintenance tab

MaintenancePanel hosts: backfill + centroid recompute trigger cards,
the five suggestion-threshold sliders (autosave on slider release),
the allowlist table (inline editable min_confidence, delete), and the
alias table (mapping display, delete). Wired as a third Settings tab,
ML settings loaded lazily when the tab opens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 07:57:42 -04:00
parent 6cf82970a1
commit cd7721fb03
7 changed files with 232 additions and 0 deletions
@@ -0,0 +1,43 @@
<template>
<v-card>
<v-card-title>Tag aliases ({{ store.rows.length }})</v-card-title>
<v-data-table-virtual
:headers="headers" :items="store.rows" :loading="store.loading"
height="360" density="compact"
no-data-text="No aliases yet create one from a suggestion's menu."
>
<template #item.mapping="{ item }">
<code>{{ item.alias_string }}</code>
<span class="mx-2"></span>
<strong>{{ item.canonical_tag_name }}</strong>
</template>
<template #item.actions="{ item }">
<v-btn
icon="mdi-delete" size="x-small" variant="text" color="error"
@click="store.remove(item.alias_string, item.alias_category)"
/>
</template>
</v-data-table-virtual>
</v-card>
</template>
<script setup>
import { onMounted } from 'vue'
import { useAliasesStore } from '../../stores/aliases.js'
const store = useAliasesStore()
const headers = [
{ title: 'Mapping', key: 'mapping', sortable: false },
{ title: 'Category', key: 'alias_category', sortable: true, width: 130 },
{ title: '', key: 'actions', sortable: false, width: 60 }
]
onMounted(() => store.load())
</script>
<style scoped>
code {
background: rgb(var(--v-theme-surface-light));
padding: 1px 6px; border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
}
</style>
@@ -0,0 +1,48 @@
<template>
<v-card>
<v-card-title>Allowlisted tags ({{ store.rows.length }})</v-card-title>
<v-data-table-virtual
:headers="headers" :items="store.rows" :loading="store.loading"
height="360" density="compact"
no-data-text="No tags on the allowlist yet accept a suggestion to add one."
>
<template #item.min_confidence="{ item }">
<v-text-field
:model-value="item.min_confidence" type="number"
density="compact" hide-details style="max-width: 100px;"
min="0.05" max="1" step="0.05"
@update:model-value="(v) => onThreshold(item.tag_id, v)"
/>
</template>
<template #item.actions="{ item }">
<v-btn
icon="mdi-delete" size="x-small" variant="text" color="error"
@click="store.remove(item.tag_id)"
/>
</template>
</v-data-table-virtual>
</v-card>
</template>
<script setup>
import { onMounted } from 'vue'
import { useAllowlistStore } from '../../stores/allowlist.js'
const store = useAllowlistStore()
const headers = [
{ title: 'Tag', key: 'tag_name', sortable: true },
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 110 },
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 140 },
{ title: '', key: 'actions', sortable: false, width: 60 }
]
onMounted(() => store.load())
let debounce = null
function onThreshold(tagId, value) {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
const v = parseFloat(value)
if (v > 0 && v <= 1) store.updateThreshold(tagId, v)
}, 500)
}
</script>
@@ -0,0 +1,30 @@
<template>
<v-card>
<v-card-title>Tag centroids</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">
Rebuild the per-tag SigLIP centroids that power similarity-based
suggestions. Runs nightly automatically; trigger manually after a
large tagging session.
</p>
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
<v-icon start>mdi-vector-triangle</v-icon> Recompute centroids
</v-btn>
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
</v-card-text>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js'
const store = useMLStore()
const busy = ref(false)
const done = ref(false)
async function run() {
busy.value = true
try { await store.triggerRecomputeCentroids(); done.value = true }
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) }
finally { busy.value = false }
}
</script>
@@ -0,0 +1,29 @@
<template>
<v-card>
<v-card-title>ML backfill</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">
Re-run Camie + SigLIP on images missing predictions or embeddings
for the current model versions. Safe to re-run.
</p>
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
<v-icon start>mdi-refresh</v-icon> Run backfill now
</v-btn>
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
</v-card-text>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js'
const store = useMLStore()
const busy = ref(false)
const done = ref(false)
async function run() {
busy.value = true
try { await store.triggerBackfill(); done.value = true }
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) }
finally { busy.value = false }
}
</script>
@@ -0,0 +1,40 @@
<template>
<v-card>
<v-card-title>Suggestion thresholds</v-card-title>
<v-card-text v-if="store.settings">
<v-row v-for="f in fields" :key="f.key">
<v-col cols="12">
<v-slider
v-model="local[f.key]" :label="f.label"
min="0" max="1" step="0.05" thumb-label hide-details
color="accent" @end="save"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-else><v-skeleton-loader type="paragraph" /></v-card-text>
</v-card>
</template>
<script setup>
import { reactive, watch } from 'vue'
import { useMLStore } from '../../stores/ml.js'
const store = useMLStore()
const fields = [
{ key: 'suggestion_threshold_artist', label: 'Artist' },
{ key: 'suggestion_threshold_character', label: 'Character' },
{ key: 'suggestion_threshold_copyright', label: 'Copyright' },
{ key: 'suggestion_threshold_general', label: 'General' },
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
]
const local = reactive({})
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
async function save() {
const patch = {}
for (const f of fields) patch[f.key] = local[f.key]
try { await store.patchSettings(patch) }
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) }
}
</script>
@@ -0,0 +1,32 @@
<template>
<div class="fc-maint">
<p class="text-body-2 mb-4">
Machine-assisted tagging controls. Backfill and centroid recompute run
nightly automatically; the allowlist auto-applies accepted tags to new
and existing images.
</p>
<div class="fc-maint__grid">
<MLBackfillCard />
<CentroidRecomputeCard />
</div>
<MLThresholdSliders class="mt-4" />
<AllowlistTable class="mt-4" />
<AliasTable class="mt-4" />
</div>
</template>
<script setup>
import MLBackfillCard from './MLBackfillCard.vue'
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue'
import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue'
</script>
<style scoped>
.fc-maint__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
</style>
+10
View File
@@ -5,6 +5,7 @@
<v-tabs v-model="tab" color="accent" class="mb-4">
<v-tab value="overview">Overview</v-tab>
<v-tab value="import">Import</v-tab>
<v-tab value="maintenance">Maintenance</v-tab>
</v-tabs>
<v-window v-model="tab">
@@ -26,6 +27,10 @@
<v-divider class="my-6" />
<ImportTaskList />
</v-window-item>
<v-window-item value="maintenance">
<MaintenancePanel />
</v-window-item>
</v-window>
</v-container>
</template>
@@ -38,10 +43,13 @@ import SystemStatsCards from '../components/settings/SystemStatsCards.vue'
import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue'
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
import ImportTaskList from '../components/settings/ImportTaskList.vue'
import MaintenancePanel from '../components/settings/MaintenancePanel.vue'
import { useMLStore } from '../stores/ml.js'
const tab = ref('overview')
const system = useSystemStore()
const importStore = useImportStore()
const mlStore = useMLStore()
let pollId = null
@@ -72,6 +80,8 @@ watch(tab, (t) => {
if (t === 'import') {
importStore.refreshStatus()
importStore.loadTasks(true)
} else if (t === 'maintenance') {
mlStore.loadSettings()
}
})
</script>