Files
FabledCurator/frontend/src/components/settings/GpuTriageCard.vue
T
bvandeusen a2d1ed935d
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m57s
refactor(ui): DRY the settings-card CSS tokens + fix unstyled headers (#161)
- Promote .fc-section-h to a global token (app.css). It was copied identically
  into 4 cards, and TranslationCard used the class with NO local def — so its
  section headers rendered unstyled. Now fixed everywhere.
- Promote .fc-good / .fc-weak status colours to globals; delete the local copies
  in the GPU/heads cards. (.fc-ok stays local — divergent: on-surface in
  HeadsCard vs success in QueuesTable. .fc-bad stays — different name.)
- Delete 10 identical local .fc-muted redefinitions that crept back after the
  2026-06-09 sweep; the global utility already covers them.
- DbMaintenanceCard: opacity:0.6 muted text → the .fc-muted token (the exact
  anti-pattern that token's comment forbids).
- HeadsCard: collapse byte-identical ratePct() into pct().

CSS-only + one template class swap; no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
2026-07-13 21:51:23 -04:00

183 lines
6.9 KiB
Vue

<template>
<MaintenanceTile
icon="mdi-file-alert"
title="Failed processing"
blurb="Triage originals that failed GPU processing — probe the files, flag defects, recover them."
>
<p class="fc-muted text-body-2 mb-3">
A job that keeps failing parks as an error with its reason. A background
probe then checks the FILE itself (checksum + decode) and splits the
errors: <b>defective files</b> (truncated/corrupt originals listed below
for recovery) vs <b>file&nbsp;OK</b> (the failure was operational; requeue
those with <i>Retry errored jobs</i> on the GPU agent card).
</p>
<div v-if="loading" class="fc-muted text-body-2">Loading</div>
<template v-else-if="overview">
<div class="fc-queue mb-3">
<div class="fc-q"><div class="fc-q__n">{{ overview.total }}</div><div class="fc-q__l">errored</div></div>
<div class="fc-q"><div class="fc-q__n" :class="overview.triage.defect ? 'fc-weak' : ''">{{ overview.triage.defect }}</div><div class="fc-q__l">defective</div></div>
<div class="fc-q"><div class="fc-q__n fc-good">{{ overview.triage.file_ok }}</div><div class="fc-q__l">file ok</div></div>
<div class="fc-q"><div class="fc-q__n">{{ overview.triage.unclassified }}</div><div class="fc-q__l">unprobed</div></div>
</div>
<p v-if="classSummary" class="fc-muted text-caption mb-3">
Reasons: {{ classSummary }}
</p>
<div class="d-flex mb-4" style="gap:8px">
<v-btn
size="small" color="accent" variant="tonal" rounded="pill"
prepend-icon="mdi-magnify-scan" :loading="probing"
:disabled="!overview.triage.unclassified" @click="onProbe"
>Probe unclassified now</v-btn>
<v-btn
size="small" variant="text" rounded="pill"
prepend-icon="mdi-refresh" @click="refresh"
>Refresh</v-btn>
</div>
<template v-if="defects.length">
<div class="fc-section-h mb-2">Defective originals</div>
<div v-for="it in defects" :key="it.job_id" class="fc-defect mb-2">
<a :href="it.image_url" target="_blank" rel="noopener" class="fc-defect__thumb">
<img v-if="it.thumbnail_url" :src="it.thumbnail_url" alt="">
<v-icon v-else icon="mdi-file-question" size="28" />
</a>
<div class="fc-defect__meta">
<div class="text-body-2">
image <b>{{ it.image_id }}</b> · {{ it.task }} ·
<span class="fc-weak">{{ it.integrity_status }}</span>
</div>
<div class="fc-muted text-caption fc-defect__err" :title="it.error || ''">
{{ it.error || 'no stored reason' }}
</div>
</div>
<v-btn
v-if="recovered[it.image_id] !== 'no_source'"
size="small" color="accent" variant="tonal" rounded="pill"
prepend-icon="mdi-cloud-download" :loading="recovering === it.image_id"
@click="onRecover(it)"
>Recover</v-btn>
<span v-else class="fc-muted text-caption">
no pollable source replace the file manually
</span>
</div>
<p class="fc-muted text-caption mt-2 mb-0">
Recover deletes the bad copy (and its record) and re-checks its
subscription source, so a fresh download re-imports it and re-enters
processing. Files without a pollable source need manual replacement.
</p>
</template>
<p v-else-if="!overview.total" class="fc-muted text-body-2 mb-0">
No failed jobs the pipeline is clean.
</p>
<p v-else-if="!overview.triage.unclassified" class="fc-muted text-body-2 mb-0">
No defective files every probed failure was operational
(file&nbsp;OK). Requeue them from the GPU agent card.
</p>
</template>
</MaintenanceTile>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { toast } from '../../utils/toast.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import { useGpuStore } from '../../stores/gpu.js'
const store = useGpuStore()
const loading = ref(true)
const overview = ref(null)
const probing = ref(false)
const recovering = ref(null)
// image_id -> 'no_source' for rows recovery already declined; keeps the
// verdict visible instead of a button that fails the same way again.
const recovered = ref({})
const defects = computed(() =>
(overview.value?.items || []).filter((i) => i.triage_status === 'defect'))
const classSummary = computed(() => {
const bc = overview.value?.by_class || {}
return Object.entries(bc)
.sort((a, b) => b[1] - a[1])
.map(([k, n]) => `${k.replaceAll('_', ' ')} ${n}`)
.join(' · ')
})
onMounted(refresh)
async function refresh() {
loading.value = true
try {
overview.value = await store.errors()
} catch (e) {
toast({ text: `Could not load failed jobs: ${e.message}`, type: 'error' })
} finally {
loading.value = false
}
}
async function onProbe() {
probing.value = true
try {
await store.triageErrors()
toast({ text: 'Probe queued — verdicts appear here as files are checked (large videos take a while)', type: 'success' })
} catch (e) {
toast({ text: `Could not start the probe: ${e.message}`, type: 'error' })
} finally {
probing.value = false
}
}
async function onRecover(it) {
recovering.value = it.image_id
try {
const res = await store.recoverImage(it.image_id)
if (res.status === 'refetch_queued') {
toast({ text: `Deleted the bad copy and queued a re-check of source #${res.source_id} — it re-imports on the next fetch`, type: 'success' })
await refresh()
} else if (res.status === 'no_source') {
recovered.value = { ...recovered.value, [it.image_id]: 'no_source' }
toast({ text: 'No enabled subscription source covers this file — replace it manually', type: 'warning' })
} else {
toast({ text: 'Image record no longer exists — refreshing', type: 'warning' })
await refresh()
}
} catch (e) {
toast({ text: `Recovery failed: ${e.message}`, type: 'error' })
} finally {
recovering.value = null
}
}
</script>
<style scoped>
.fc-queue { display: flex; gap: 24px; }
.fc-q__n {
font-size: 20px; font-weight: 700; line-height: 1.1;
font-family: 'JetBrains Mono', monospace;
}
.fc-q__l {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-defect {
display: flex; align-items: center; gap: 12px;
background: rgb(var(--v-theme-surface-light)); border-radius: 8px;
padding: 6px 10px;
}
.fc-defect__thumb {
flex: 0 0 44px; width: 44px; height: 44px; border-radius: 6px;
overflow: hidden; display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.25);
}
.fc-defect__thumb img { width: 100%; height: 100%; object-fit: cover; }
.fc-defect__meta { flex: 1; min-width: 0; }
.fc-defect__err {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
</style>