Five small G5 findings from the 2026-06-02 audit. Each is local and
follows an established FC pattern.
- download_service: replace hardcoded ('discord','pixiv') tuple with
auth_type_for(platform) == 'token'. A 7th token-platform now picks
up the right credential path without touching this site.
- /api/tags/<source_id>/merge enqueues recompute_centroid.delay after
merge so the target's centroid reflects its new image set
immediately. Daily list_drifted catches it within 24h, but eager
recompute closes the suggestion-quality dip in the meantime.
- backfill_thumbnails added to beat_schedule (daily). The task
docstring claimed periodic Beat but the entry was never registered,
so the library got no self-healing thumbnail repair; only the
manual admin-UI button fired it.
- modal.createAndAdd pushes a kind='fandom' tag into
tagsStore.fandomCache so FandomPicker sees the new fandom on next
open. Was: cache-gated load (length===0) skipped refetch, new
fandom invisible until full page reload.
- cleanup cluster:
- Drop .webp from cleanup_service.unlink — thumbnailer only writes
.jpg/.png; the third tuple member was dead code.
- Drop effective_date from /api/gallery/scroll response — no FE
consumer reads it. Service still computes the attribute for
timeline ordering; this just trims the JSON.
- Rename store.recentMinute → store.recentRuns across the
systemActivity store + three consumers (SystemActivitySummary,
QueuesTable, SystemActivityTab). The data is the last 200 runs
(not actually "last minute"), so the name lied.
NOT in this bundle: the duplicate tag-merge endpoint
(/api/tags vs /api/admin/tags) is harder — has 1 FE caller and 3 tests
on the admin variant; consolidation is its own change.
310 lines
9.5 KiB
Vue
310 lines
9.5 KiB
Vue
<template>
|
||
<div class="fc-activity">
|
||
<!-- Queues + workers pane -->
|
||
<v-card class="mb-4">
|
||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||
<v-icon icon="mdi-pulse" size="small" />
|
||
<span>Queues + workers</span>
|
||
<v-spacer />
|
||
<span class="text-caption fc-muted">
|
||
updated {{ formatRelative(store.queues?.fetched_at) }}
|
||
</span>
|
||
</v-card-title>
|
||
<v-card-text>
|
||
<QueuesTable
|
||
:queues="store.queues"
|
||
:workers="store.workers"
|
||
:recent-runs="store.recentRuns"
|
||
/>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- Recent failures pane -->
|
||
<v-card class="mb-4">
|
||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||
<v-icon icon="mdi-alert-circle-outline" size="small" />
|
||
<span>Recent failures (last 24h)</span>
|
||
<v-spacer />
|
||
<span class="text-caption fc-muted">
|
||
{{ failureCount }} failures
|
||
</span>
|
||
</v-card-title>
|
||
<v-card-text>
|
||
<div v-if="errorTypes.length" class="mb-3 fc-pills">
|
||
<v-chip
|
||
v-for="t in errorTypes" :key="t.name"
|
||
:color="t.name === filterErrorType ? 'accent' : undefined"
|
||
size="small" variant="tonal" closable
|
||
@click="toggleErrorTypeFilter(t.name)"
|
||
@click:close="toggleErrorTypeFilter(t.name)"
|
||
>
|
||
{{ t.name }} × {{ t.count }}
|
||
</v-chip>
|
||
</div>
|
||
<v-table density="compact">
|
||
<thead>
|
||
<tr>
|
||
<th>Time</th>
|
||
<th>Queue</th>
|
||
<th>Task</th>
|
||
<th>Target</th>
|
||
<th>Error</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="r in filteredFailures" :key="r.id">
|
||
<td class="fc-tabular" :title="r.finished_at">
|
||
{{ formatRelative(r.finished_at) }}
|
||
</td>
|
||
<td>{{ r.queue }}</td>
|
||
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
|
||
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
|
||
<td>
|
||
<button
|
||
type="button" class="fc-err-link"
|
||
@click="openError(r)"
|
||
:title="'Click for full error'"
|
||
>{{ r.error_type }}</button>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!filteredFailures.length">
|
||
<td colspan="5" class="text-center fc-muted py-4">
|
||
No failures in the last 24h.
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- All activity pane -->
|
||
<v-card>
|
||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||
<v-icon icon="mdi-format-list-bulleted" size="small" />
|
||
<span>All recent activity</span>
|
||
<v-spacer />
|
||
<v-select
|
||
v-model="filterQueue"
|
||
:items="queueOptions" density="compact" hide-details
|
||
style="max-width: 180px;"
|
||
@update:model-value="onFilterChange"
|
||
/>
|
||
<v-select
|
||
v-model="filterStatus"
|
||
:items="statusOptions" density="compact" hide-details
|
||
style="max-width: 180px;"
|
||
@update:model-value="onFilterChange"
|
||
/>
|
||
<v-btn
|
||
variant="text" size="small" rounded="pill"
|
||
@click="onRefresh"
|
||
>
|
||
<v-icon start size="small">mdi-refresh</v-icon>
|
||
Refresh
|
||
</v-btn>
|
||
</v-card-title>
|
||
<v-card-text>
|
||
<v-table density="compact">
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th>Time</th>
|
||
<th>Queue</th>
|
||
<th>Task</th>
|
||
<th>Target</th>
|
||
<th class="text-right">Duration</th>
|
||
<th>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="r in store.runs" :key="r.id">
|
||
<td><v-icon size="small" :color="statusColor(r.status)">{{ statusIcon(r.status) }}</v-icon></td>
|
||
<td class="fc-tabular" :title="r.started_at">{{ formatRelative(r.started_at) }}</td>
|
||
<td>{{ r.queue }}</td>
|
||
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
|
||
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
|
||
<td class="text-right fc-tabular">{{ formatDuration(r.duration_ms) }}</td>
|
||
<td>{{ r.status }}</td>
|
||
</tr>
|
||
<tr v-if="!store.runs.length && !store.loading.runs">
|
||
<td colspan="7" class="text-center fc-muted py-4">
|
||
No activity yet.
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
<div v-if="store.runsHasMore" class="d-flex justify-center py-3">
|
||
<v-btn
|
||
variant="text" size="small"
|
||
:loading="store.loading.runs"
|
||
@click="onLoadMore"
|
||
>Load more</v-btn>
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<ErrorDetailModal
|
||
v-model="showErrorModal"
|
||
:row="errorModalRow"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
|
||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||
import { formatRelative as fmtRelative } from '../../utils/date.js'
|
||
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||
import QueuesTable from './QueuesTable.vue'
|
||
|
||
// Click-to-open modal for full error text. Replaces the unusable
|
||
// :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy
|
||
// rollback + traceback content rendered as a cramped browser tooltip
|
||
// you couldn't copy from or scroll within).
|
||
const showErrorModal = ref(false)
|
||
const errorModalRow = ref(null)
|
||
|
||
function openError(row) {
|
||
errorModalRow.value = row
|
||
showErrorModal.value = true
|
||
}
|
||
|
||
const store = useSystemActivityStore()
|
||
|
||
const filterQueue = ref(null)
|
||
const filterStatus = ref(null)
|
||
const filterErrorType = ref(null)
|
||
|
||
const queueOptions = [
|
||
{ title: 'All queues', value: null },
|
||
{ title: 'import', value: 'import' },
|
||
{ title: 'thumbnail', value: 'thumbnail' },
|
||
{ title: 'ml', value: 'ml' },
|
||
{ title: 'download', value: 'download' },
|
||
{ title: 'scan', value: 'scan' },
|
||
{ title: 'maintenance', value: 'maintenance' },
|
||
{ title: 'default', value: 'default' },
|
||
]
|
||
const statusOptions = [
|
||
{ title: 'All statuses', value: null },
|
||
{ title: 'Running', value: 'running' },
|
||
{ title: 'OK', value: 'ok' },
|
||
{ title: 'Error', value: 'error' },
|
||
{ title: 'Timeout', value: 'timeout' },
|
||
{ title: 'Retry', value: 'retry' },
|
||
]
|
||
|
||
let queuesPollId = null
|
||
let failuresPollId = null
|
||
|
||
function pollQueues() {
|
||
if (document.hidden) return
|
||
store.loadQueues()
|
||
store.loadWorkers()
|
||
store.loadRecentRuns()
|
||
}
|
||
function pollFailures() {
|
||
if (document.hidden) return
|
||
store.loadFailures()
|
||
}
|
||
|
||
onMounted(() => {
|
||
pollQueues()
|
||
pollFailures()
|
||
store.setFilter({ queue: null, status: null })
|
||
store.loadRuns({ reset: true })
|
||
queuesPollId = setInterval(pollQueues, 3000)
|
||
failuresPollId = setInterval(pollFailures, 15000)
|
||
})
|
||
onUnmounted(() => {
|
||
if (queuesPollId) clearInterval(queuesPollId)
|
||
if (failuresPollId) clearInterval(failuresPollId)
|
||
})
|
||
|
||
const errorTypes = computed(() => {
|
||
const counts = store.failures?.count_by_type || {}
|
||
return Object.entries(counts).map(([name, count]) => ({ name, count }))
|
||
})
|
||
|
||
const failureCount = computed(() => (store.failures?.recent || []).length)
|
||
|
||
const filteredFailures = computed(() => {
|
||
const all = store.failures?.recent || []
|
||
if (!filterErrorType.value) return all
|
||
return all.filter(r => r.error_type === filterErrorType.value)
|
||
})
|
||
|
||
function toggleErrorTypeFilter(name) {
|
||
filterErrorType.value = filterErrorType.value === name ? null : name
|
||
if (filterErrorType.value) {
|
||
// Mirror into the All-activity table too.
|
||
filterStatus.value = 'error'
|
||
onFilterChange()
|
||
}
|
||
}
|
||
|
||
function onFilterChange() {
|
||
store.setFilter({ queue: filterQueue.value, status: filterStatus.value })
|
||
store.loadRuns({ reset: true })
|
||
}
|
||
function onLoadMore() { store.loadRuns({ reset: false }) }
|
||
function onRefresh() { store.loadRuns({ reset: true }) }
|
||
|
||
function statusIcon(status) {
|
||
return {
|
||
ok: 'mdi-check-circle',
|
||
error: 'mdi-close-circle',
|
||
timeout: 'mdi-clock-alert',
|
||
retry: 'mdi-refresh',
|
||
running: 'mdi-timer-sand',
|
||
}[status] || 'mdi-help-circle'
|
||
}
|
||
function statusColor(status) {
|
||
return {
|
||
ok: 'success', error: 'error', timeout: 'warning',
|
||
retry: 'info', running: 'accent',
|
||
}[status] || 'on-surface-variant'
|
||
}
|
||
function shortTaskName(name) {
|
||
if (!name) return ''
|
||
const parts = name.split('.')
|
||
return parts.slice(-1)[0]
|
||
}
|
||
function formatDuration(ms) {
|
||
if (ms == null) return '—'
|
||
if (ms < 1000) return `${ms} ms`
|
||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
|
||
return `${(ms / 60_000).toFixed(1)} min`
|
||
}
|
||
function formatRelative(iso) {
|
||
return fmtRelative(iso, { nullText: '—' })
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.fc-activity { padding: 0; }
|
||
.fc-pills {
|
||
display: flex; gap: 6px; flex-wrap: wrap;
|
||
}
|
||
.fc-tabular {
|
||
font-variant-numeric: tabular-nums;
|
||
font-feature-settings: 'tnum';
|
||
}
|
||
.fc-err { color: rgb(var(--v-theme-error, 220 80 80)); }
|
||
.fc-err-link {
|
||
/* Styled as a text-only button so the error_type cell stays
|
||
visually identical to the prior tooltip-bearing row, but is
|
||
now a real clickable target with hover affordance. */
|
||
color: rgb(var(--v-theme-error, 220 80 80));
|
||
background: transparent;
|
||
border: 0;
|
||
padding: 0;
|
||
font: inherit;
|
||
text-decoration: underline dotted;
|
||
cursor: pointer;
|
||
}
|
||
.fc-err-link:hover { text-decoration: underline; }
|
||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||
</style>
|