feat(settings): tidy Maintenance tab into compact tiles + center the views (pass 2)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 42s
CI / integration (push) Successful in 3m27s

Goal (operator 2026-06-18): the overview of a Settings tab fits one unscrolled
viewport; expanding a tile to read into it is the only reason to scroll.

- Every Maintenance card converted to the collapsible MaintenanceTile (collapsed
  by default = icon + short title + one-line blurb). Task cards (ML backfill,
  centroids, thumbnails, archive re-extract, missing-file repair, DB maintenance)
  sit in a responsive grid; running tasks auto-expand. Tagging config (suggestion
  thresholds, allowlist, aliases) grouped in one Tagging section as collapsible
  tiles; Backup is its own collapsible tile.
- Three labeled sections mirror the Cleanup tab: Backfills and reprocessing /
  Tagging / Storage.
- Center the whole Settings surface: SettingsView is now a centered, width-capped
  (1140px) column so the tab strip and every panel sit in a tidy centered measure
  (was full-width). CleanupView drops its own left-aligned max-width to fill it.

All card logic unchanged - only the chrome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 00:10:58 -04:00
parent 3b435dc0ba
commit 311fe0ee9c
13 changed files with 158 additions and 84 deletions
@@ -1,6 +1,9 @@
<template> <template>
<v-card> <MaintenanceTile
<v-card-title>Tag aliases ({{ store.rows.length }})</v-card-title> icon="mdi-tag-arrow-right-outline"
:title="`Tag aliases (${store.rows.length})`"
blurb="Alternate tag spellings that map to a canonical tag."
>
<v-data-table-virtual <v-data-table-virtual
:headers="headers" :items="store.rows" :loading="store.loading" :headers="headers" :items="store.rows" :loading="store.loading"
height="360" density="compact" height="360" density="compact"
@@ -18,12 +21,13 @@
/> />
</template> </template>
</v-data-table-virtual> </v-data-table-virtual>
</v-card> </MaintenanceTile>
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useAliasesStore } from '../../stores/aliases.js' import { useAliasesStore } from '../../stores/aliases.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useAliasesStore() const store = useAliasesStore()
const headers = [ const headers = [
@@ -1,6 +1,9 @@
<template> <template>
<v-card> <MaintenanceTile
<v-card-title>Allowlisted tags ({{ store.rows.length }})</v-card-title> icon="mdi-playlist-check"
:title="`Allowlisted tags (${store.rows.length})`"
blurb="Tags auto-applied to images that score above their threshold."
>
<v-data-table-virtual <v-data-table-virtual
:headers="headers" :items="store.rows" :loading="store.loading" :headers="headers" :items="store.rows" :loading="store.loading"
height="360" density="compact" height="360" density="compact"
@@ -21,13 +24,14 @@
/> />
</template> </template>
</v-data-table-virtual> </v-data-table-virtual>
</v-card> </MaintenanceTile>
</template> </template>
<script setup> <script setup>
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useAllowlistStore } from '../../stores/allowlist.js' import { useAllowlistStore } from '../../stores/allowlist.js'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useAllowlistStore() const store = useAllowlistStore()
const ml = useMLStore() const ml = useMLStore()
@@ -1,9 +1,12 @@
<template> <template>
<!-- #713: re-extract PostAttachments that are really archives but were filed <!-- #713: re-extract PostAttachments that are really archives but were filed
opaquely before the magic-byte gate, and attach their images to the post. --> opaquely before the magic-byte gate, and attach their images to the post. -->
<v-card> <MaintenanceTile
<v-card-title>Re-extract archive attachments</v-card-title> icon="mdi-folder-zip-outline"
<v-card-text> title="Re-extract archives"
blurb="Extract images from zip attachments filed opaquely."
:open="busy"
>
<p class="text-body-2 mb-3"> <p class="text-body-2 mb-3">
Some posts attached an archive (zip) whose images weren't extracted Some posts attached an archive (zip) whose images weren't extracted
because the downloaded file had no usable extension. This scans existing because the downloaded file had no usable extension. This scans existing
@@ -15,8 +18,7 @@
</v-btn> </v-btn>
<span v-if="queued" class="ml-3 text-caption text-success">Queued ✓</span> <span v-if="queued" class="ml-3 text-caption text-success">Queued ✓</span>
<QueueStatusBar queue="maintenance" queue-label="Maintenance" /> <QueueStatusBar queue="maintenance" queue-label="Maintenance" />
</v-card-text> </MaintenanceTile>
</v-card>
</template> </template>
<script setup> <script setup>
@@ -24,6 +26,7 @@ import { ref } from 'vue'
import { useApi } from '../../composables/useApi.js' import { useApi } from '../../composables/useApi.js'
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const api = useApi() const api = useApi()
@@ -1,7 +1,9 @@
<template> <template>
<v-card class="fc-backup-card"> <MaintenanceTile
<CardHeading icon="mdi-database-export" title="Backups" /> icon="mdi-database-export"
<v-card-text> title="Backups"
blurb="Database + image backups, schedules and restore."
>
<p class="fc-muted text-body-2 mb-4"> <p class="fc-muted text-body-2 mb-4">
pg_dump for the database (fast, nightly-schedulable); pg_dump for the database (fast, nightly-schedulable);
tar+zstd for the images (slow, manual only). Files live in tar+zstd for the images (slow, manual only). Files live in
@@ -80,8 +82,6 @@
@delete="onDelete" @delete="onDelete"
@tag="onTag" @tag="onTag"
/> />
</v-card-text>
<BackupConfirmModal <BackupConfirmModal
v-model="confirmOpen" v-model="confirmOpen"
:action="confirmAction" :action="confirmAction"
@@ -90,7 +90,7 @@
:description="confirmDescription" :description="confirmDescription"
@confirm="onConfirmSubmit" @confirm="onConfirmSubmit"
/> />
</v-card> </MaintenanceTile>
</template> </template>
<script setup> <script setup>
@@ -98,7 +98,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useBackupStore } from '../../stores/backup.js' import { useBackupStore } from '../../stores/backup.js'
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue' import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
import CardHeading from '../common/CardHeading.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import BackupRunsTable from './BackupRunsTable.vue' import BackupRunsTable from './BackupRunsTable.vue'
const store = useBackupStore() const store = useBackupStore()
@@ -1,7 +1,10 @@
<template> <template>
<v-card> <MaintenanceTile
<v-card-title>Tag centroids</v-card-title> icon="mdi-vector-triangle"
<v-card-text> title="Tag centroids"
blurb="Rebuild SigLIP centroids for similarity suggestions."
:open="busy"
>
<p class="text-body-2 mb-3"> <p class="text-body-2 mb-3">
Rebuild the per-tag SigLIP centroids that power similarity-based Rebuild the per-tag SigLIP centroids that power similarity-based
suggestions. Runs nightly automatically; trigger manually after a suggestions. Runs nightly automatically; trigger manually after a
@@ -12,14 +15,14 @@
</v-btn> </v-btn>
<span v-if="done" class="ml-3 text-caption">Enqueued.</span> <span v-if="done" class="ml-3 text-caption">Enqueued.</span>
<QueueStatusBar queue="ml" queue-label="ML" /> <QueueStatusBar queue="ml" queue-label="ML" />
</v-card-text> </MaintenanceTile>
</v-card>
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const store = useMLStore() const store = useMLStore()
const busy = ref(false) const busy = ref(false)
@@ -1,7 +1,10 @@
<template> <template>
<v-card> <MaintenanceTile
<v-card-title>Database maintenance</v-card-title> icon="mdi-database-cog"
<v-card-text> title="Database maintenance"
blurb="VACUUM ANALYZE + per-table bloat stats."
:open="busy"
>
<p class="text-body-2 mb-3"> <p class="text-body-2 mb-3">
VACUUM (ANALYZE) reclaims dead-tuple bloat which slows the random VACUUM (ANALYZE) reclaims dead-tuple bloat which slows the random
showcase, since it samples physical blocks and refreshes the query showcase, since it samples physical blocks and refreshes the query
@@ -42,8 +45,7 @@
<p v-else class="text-caption mt-3" style="opacity: 0.6;"> <p v-else class="text-caption mt-3" style="opacity: 0.6;">
No table statistics yet. No table statistics yet.
</p> </p>
</v-card-text> </MaintenanceTile>
</v-card>
</template> </template>
<script setup> <script setup>
@@ -51,6 +53,7 @@ import { onMounted, ref } from 'vue'
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { useDbMaintenanceStore } from '../../stores/dbMaintenance.js' import { useDbMaintenanceStore } from '../../stores/dbMaintenance.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const store = useDbMaintenanceStore() const store = useDbMaintenanceStore()
@@ -1,7 +1,10 @@
<template> <template>
<v-card> <MaintenanceTile
<v-card-title>ML backfill</v-card-title> icon="mdi-refresh"
<v-card-text> title="ML backfill"
blurb="Re-run tagging + embeddings on images missing them."
:open="busy"
>
<p class="text-body-2 mb-3"> <p class="text-body-2 mb-3">
Re-run Camie + SigLIP on images missing predictions or embeddings Re-run Camie + SigLIP on images missing predictions or embeddings
for the current model versions. Safe to re-run. for the current model versions. Safe to re-run.
@@ -11,14 +14,14 @@
</v-btn> </v-btn>
<span v-if="done" class="ml-3 text-caption">Enqueued.</span> <span v-if="done" class="ml-3 text-caption">Enqueued.</span>
<QueueStatusBar queue="ml" queue-label="ML" /> <QueueStatusBar queue="ml" queue-label="ML" />
</v-card-text> </MaintenanceTile>
</v-card>
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const store = useMLStore() const store = useMLStore()
const busy = ref(false) const busy = ref(false)
@@ -1,7 +1,10 @@
<template> <template>
<v-card> <MaintenanceTile
<v-card-title>Suggestion thresholds</v-card-title> icon="mdi-tune"
<v-card-text v-if="store.settings"> title="Suggestion thresholds"
blurb="Confidence cutoffs that gate auto-suggested tags + video sampling."
>
<div v-if="store.settings">
<v-row v-for="f in fields" :key="f.key"> <v-row v-for="f in fields" :key="f.key">
<v-col cols="12"> <v-col cols="12">
<v-slider <v-slider
@@ -63,15 +66,16 @@
/> />
</v-col> </v-col>
</v-row> </v-row>
</v-card-text> </div>
<v-card-text v-else><v-skeleton-loader type="paragraph" /></v-card-text> <div v-else><v-skeleton-loader type="paragraph" /></div>
</v-card> </MaintenanceTile>
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useMLStore() const store = useMLStore()
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
@@ -1,31 +1,43 @@
<template> <template>
<div class="fc-maint"> <div class="fc-maint">
<p class="text-body-2 mb-4"> <p class="fc-muted text-body-2 mb-5">
Operational backfills and tagging controls. The ML backfill and centroid One-off backfills, tagging config and storage tools. The ML backfill and
recompute run nightly automatically; the allowlist auto-applies accepted centroid recompute also run nightly; the allowlist auto-applies accepted
tags to new and existing images. Use the cards below to trigger a tags. Click a tile to open it.
one-off pass.
</p> </p>
<div class="fc-maint__grid">
<MLBackfillCard /> <section class="fc-section">
<CentroidRecomputeCard /> <h3 class="fc-section__title">Backfills &amp; reprocessing</h3>
<ThumbnailBackfillCard /> <p class="fc-section__hint">Re-run tagging, thumbnails, extraction and DB upkeep.</p>
</div> <div class="fc-tile-grid">
<MLThresholdSliders class="mt-4" /> <MLBackfillCard />
<AllowlistTable class="mt-4" /> <CentroidRecomputeCard />
<AliasTable class="mt-4" /> <ThumbnailBackfillCard />
<DbMaintenanceCard class="mt-6" /> <ArchiveReextractCard />
<ArchiveReextractCard class="mt-6" /> <MissingFileRepairCard />
<MissingFileRepairCard class="mt-6" /> <DbMaintenanceCard />
<BackupCard class="mt-6" /> </div>
<!-- VideoDedupCard + GatedPurgeCard moved to the Cleanup tab (2026-06-18): </section>
both are destructive content cleanup, so "remove unwanted stuff" now
lives in one place. --> <section class="fc-section">
<!-- TODO(2026-06-18): tile-ify this tab's cards into sections too (pass 2). --> <h3 class="fc-section__title">Tagging</h3>
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it <p class="fc-section__hint">
operates on the existing library which fits the Cleanup-tab Suggestion thresholds, the auto-apply allowlist and tag aliases.
theme, and clusters with the other audit cards. LegacyMigrationCard </p>
removed once the one-and-done GS/IR migration cutover completed. --> <div class="fc-tile-stack">
<MLThresholdSliders />
<AllowlistTable />
<AliasTable />
</div>
</section>
<section class="fc-section">
<h3 class="fc-section__title">Storage</h3>
<p class="fc-section__hint">Database + image backups and restore.</p>
<div class="fc-tile-stack">
<BackupCard />
</div>
</section>
</div> </div>
</template> </template>
@@ -35,18 +47,18 @@ import { onMounted, onUnmounted } from 'vue'
import MLBackfillCard from './MLBackfillCard.vue' import MLBackfillCard from './MLBackfillCard.vue'
import CentroidRecomputeCard from './CentroidRecomputeCard.vue' import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue' import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue' import MLThresholdSliders from './MLThresholdSliders.vue'
import AllowlistTable from './AllowlistTable.vue' import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue' import AliasTable from './AliasTable.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue'
import BackupCard from './BackupCard.vue' import BackupCard from './BackupCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js' import { useSystemActivityStore } from '../../stores/systemActivity.js'
// Poll queue depths so each card's QueueStatusBar shows live pending // Poll queue depths so each task tile's QueueStatusBar shows live pending
// counts — the operator can see a backfill is already queued/running // counts — the operator can see a backfill is already queued/running before
// before re-triggering it. // re-triggering it.
const activity = useSystemActivityStore() const activity = useSystemActivityStore()
let qTimer = null let qTimer = null
onMounted(() => { onMounted(() => {
@@ -61,9 +73,29 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.fc-maint__grid { .fc-section { margin-bottom: 24px; }
.fc-section__title {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(var(--v-theme-accent));
margin-bottom: 2px;
}
.fc-section__hint {
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 12px;
}
.fc-tile-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px; gap: 14px;
align-items: start;
}
.fc-tile-stack {
display: flex;
flex-direction: column;
gap: 12px;
} }
</style> </style>
@@ -1,9 +1,12 @@
<template> <template>
<!-- #859: delete ImageRecords whose backing file is gone from disk (orphans <!-- #859: delete ImageRecords whose backing file is gone from disk (orphans
left by the external-attach unlink bug) so they stop 404-ing on playback. --> left by the external-attach unlink bug) so they stop 404-ing on playback. -->
<v-card> <MaintenanceTile
<v-card-title>Repair missing-file records</v-card-title> icon="mdi-file-remove-outline"
<v-card-text> title="Repair missing files"
blurb="Remove records whose file is gone from disk."
:open="busy"
>
<p class="text-body-2 mb-3"> <p class="text-body-2 mb-3">
Scans every image/video record and removes any whose underlying file is Scans every image/video record and removes any whose underlying file is
gone from disk orphaned records that otherwise 404 when you open the gone from disk orphaned records that otherwise 404 when you open the
@@ -16,8 +19,7 @@
</v-btn> </v-btn>
<span v-if="queued" class="ml-3 text-caption text-success">Queued </span> <span v-if="queued" class="ml-3 text-caption text-success">Queued </span>
<QueueStatusBar queue="maintenance" queue-label="Maintenance" /> <QueueStatusBar queue="maintenance" queue-label="Maintenance" />
</v-card-text> </MaintenanceTile>
</v-card>
</template> </template>
<script setup> <script setup>
@@ -25,6 +27,7 @@ import { ref } from 'vue'
import { useApi } from '../../composables/useApi.js' import { useApi } from '../../composables/useApi.js'
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const api = useApi() const api = useApi()
@@ -1,7 +1,10 @@
<template> <template>
<v-card> <MaintenanceTile
<v-card-title>Thumbnail backfill</v-card-title> icon="mdi-image-refresh"
<v-card-text> title="Thumbnail backfill"
blurb="Regenerate missing or broken thumbnails."
:open="busy"
>
<p class="text-body-2 mb-3"> <p class="text-body-2 mb-3">
Scan the library for images with no thumbnail, or whose thumbnail file Scan the library for images with no thumbnail, or whose thumbnail file
is missing or corrupt on disk. Repair candidates are re-enqueued for is missing or corrupt on disk. Repair candidates are re-enqueued for
@@ -19,14 +22,14 @@
· {{ result.ok }} ok · {{ result.ok }} ok
</span> </span>
<QueueStatusBar queue="thumbnail" queue-label="Thumbnail" /> <QueueStatusBar queue="thumbnail" queue-label="Thumbnail" />
</v-card-text> </MaintenanceTile>
</v-card>
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useThumbnailsStore } from '../../stores/thumbnails.js' import { useThumbnailsStore } from '../../stores/thumbnails.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const store = useThumbnailsStore() const store = useThumbnailsStore()
const busy = ref(false) const busy = ref(false)
+2 -1
View File
@@ -53,7 +53,8 @@ import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
</script> </script>
<style scoped> <style scoped>
.fc-cleanup { max-width: 1100px; } /* width is capped + centered by SettingsView's .fc-settings container */
.fc-cleanup { max-width: 100%; }
.fc-section { margin-bottom: 28px; } .fc-section { margin-bottom: 28px; }
.fc-section__title { .fc-section__title {
font-size: 0.78rem; font-size: 0.78rem;
+12 -1
View File
@@ -1,5 +1,9 @@
<template> <template>
<v-container fluid class="pt-2 pb-6"> <!-- Centered, width-capped column so the tab strip + every panel sit in a
tidy centered measure rather than stretching full-width (operator
2026-06-18). v-container is margin-auto by default; the cap keeps long
maintenance/import panels readable. -->
<v-container class="pt-2 pb-6 fc-settings">
<!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance <!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance
panels pushed the tab strip out of the viewport, forcing a scroll- panels pushed the tab strip out of the viewport, forcing a scroll-
to-top just to change tab. AppShell's TopNav is 64px sticky, so the to-top just to change tab. AppShell's TopNav is 64px sticky, so the
@@ -125,3 +129,10 @@ watch(tab, (t) => {
} }
}) })
</script> </script>
<style scoped>
/* Centered, capped measure for the whole Settings surface (tabs + panels). */
.fc-settings {
max-width: 1140px;
}
</style>