feat(maintenance): DB maintenance UI card + fix ruff I001
- Settings → Maintenance gains a "Database maintenance" card: a "Run VACUUM ANALYZE now" button (enqueues the maintenance task) plus a per-table bloat readout (live/dead/dead%/last vacuum) from /api/admin/maintenance/db-stats. - dbMaintenance store (loadStats / runVacuum) + test. - Fix ruff I001: combine the two _sync_engine imports onto one line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,10 +22,7 @@ from ..models import (
|
||||
TaskRun,
|
||||
)
|
||||
from ..utils.phash import compute_phash
|
||||
from ._sync_engine import (
|
||||
get_sync_engine,
|
||||
sync_session_factory as _sync_session_factory,
|
||||
)
|
||||
from ._sync_engine import get_sync_engine, sync_session_factory as _sync_session_factory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Database maintenance</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">
|
||||
VACUUM (ANALYZE) reclaims dead-tuple bloat — which slows the random
|
||||
showcase, since it samples physical blocks — and refreshes the query
|
||||
planner's statistics. Runs automatically each week; trigger a pass
|
||||
here after a large import or cleanup.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||
<v-icon start>mdi-database-cog</v-icon> Run VACUUM ANALYZE now
|
||||
</v-btn>
|
||||
<span v-if="queued" class="ml-3 text-caption text-success">Queued ✓</span>
|
||||
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
|
||||
|
||||
<v-table
|
||||
v-if="store.tables.length" density="compact" class="mt-4 fc-dbm__table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th class="text-right">Live rows</th>
|
||||
<th class="text-right">Dead</th>
|
||||
<th class="text-right">Dead %</th>
|
||||
<th>Last vacuum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in store.tables" :key="t.table">
|
||||
<td>{{ t.table }}</td>
|
||||
<td class="text-right">{{ t.live.toLocaleString() }}</td>
|
||||
<td class="text-right">{{ t.dead.toLocaleString() }}</td>
|
||||
<td
|
||||
class="text-right"
|
||||
:class="{ 'text-warning': t.dead_pct >= 20 }"
|
||||
>{{ t.dead_pct }}%</td>
|
||||
<td class="text-caption">{{ fmt(t.last_vacuum || t.last_autovacuum) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<p v-else class="text-caption mt-3" style="opacity: 0.6;">
|
||||
No table statistics yet.
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { useDbMaintenanceStore } from '../../stores/dbMaintenance.js'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
|
||||
const store = useDbMaintenanceStore()
|
||||
const busy = ref(false)
|
||||
const queued = ref(false)
|
||||
|
||||
onMounted(() => store.loadStats())
|
||||
|
||||
async function run () {
|
||||
busy.value = true
|
||||
queued.value = false
|
||||
try {
|
||||
await store.runVacuum()
|
||||
queued.value = true
|
||||
// pg_stat updates after the vacuum lands — refresh shortly after.
|
||||
setTimeout(() => store.loadStats(), 5000)
|
||||
} catch (e) {
|
||||
toast({ text: e.message, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmt (iso) {
|
||||
return iso ? new Date(iso).toLocaleString() : '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-dbm__table { background: transparent; }
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@
|
||||
<MLThresholdSliders class="mt-4" />
|
||||
<AllowlistTable class="mt-4" />
|
||||
<AliasTable class="mt-4" />
|
||||
<DbMaintenanceCard class="mt-6" />
|
||||
<BackupCard class="mt-6" />
|
||||
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
|
||||
operates on the existing library which fits the Cleanup-tab
|
||||
@@ -31,6 +32,7 @@ import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import AllowlistTable from './AllowlistTable.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
import BackupCard from './BackupCard.vue'
|
||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
|
||||
export const useDbMaintenanceStore = defineStore('dbMaintenance', () => {
|
||||
const api = useApi()
|
||||
const tables = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadStats() {
|
||||
loading.value = true
|
||||
try {
|
||||
const body = await api.get('/api/admin/maintenance/db-stats')
|
||||
tables.value = body.tables || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runVacuum() {
|
||||
return await api.post('/api/admin/maintenance/vacuum')
|
||||
}
|
||||
|
||||
return { tables, loading, loadStats, runVacuum }
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useDbMaintenanceStore } from '../src/stores/dbMaintenance.js'
|
||||
|
||||
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)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('dbMaintenance store', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('loadStats populates the bloat table', async () => {
|
||||
const s = useDbMaintenanceStore()
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: { tables: [{ table: 'image_record', live: 5, dead: 1, dead_pct: 16.7, last_vacuum: null }] },
|
||||
}))
|
||||
await s.loadStats()
|
||||
expect(s.tables).toHaveLength(1)
|
||||
expect(s.tables[0].table).toBe('image_record')
|
||||
})
|
||||
|
||||
it('runVacuum POSTs the trigger endpoint', async () => {
|
||||
const s = useDbMaintenanceStore()
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, init })
|
||||
return { status: 202, body: { status: 'queued' } }
|
||||
})
|
||||
await s.runVacuum()
|
||||
const c = calls.at(-1)
|
||||
expect(c.url).toContain('/api/admin/maintenance/vacuum')
|
||||
expect(c.init.method).toBe('POST')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user