4bff1d8558
Five extension-miss findings from the 2026-06-02 audit, where a status
value was added on one side but a downstream consumer didn't pick it up.
- download_service._phase3_persist: explicit branches for
ImportResult.status in ('failed','refreshed'). For 'failed' (archive
probe crash from _import_archive), unlink the source file so the
filesystem scanner doesn't re-import and re-crash on the same
archive forever. 'refreshed' is currently unreachable from the
download path (no deep=True) but matches the importer's documented
contract; treat as 'attached'.
- gallery-dl backfill auto-complete now gates on dl_result.success +
no error_type, not just return_code==0 + files_downloaded==0.
VALIDATION_FAILED exits the subprocess with returncode=0 and
files_downloaded=0 when every file was quarantined, matching the
prior predicate exactly and zeroing the operator's armed backfill
budget on the FIRST quarantine run instead of decrementing.
- attach_in_place archive dispatch now threads artist + source_row
through _import_archive (and _import_media for archive members)
and _supersede. The path-walk fallback (_resolve_artist) is still
used by filesystem-import; the download path now binds
ImageProvenance to the explicit subscription Source instead of
rediscovering by (artist_id, platform).
- Three FE handlers now recognize status:'deferred' from
/api/sources/<id>/check: SubscriptionsTab.onCheck (was toasting
"event #undefined"), SubscriptionsTab.checkAll (was counting
deferred as queued), DownloadEventRow.onRetry (was saying
"re-queued" when nothing was). Pattern matches DownloadsTab.onRetryAll
which already had it.
- celery_signals._queue_for now maps backup/admin/library_audit
prefixes to 'maintenance' (matching task_routes). TaskRun.queue
was returning 'default' for those rows, so per-queue dashboard
filters and per-queue threshold overrides (added in G3) silently
missed them.
227 lines
6.9 KiB
Vue
227 lines
6.9 KiB
Vue
<template>
|
|
<div
|
|
class="fc-dl-row"
|
|
:class="[`fc-dl-row--${event.status || 'unknown'}`]"
|
|
@click="$emit('open', event.id)"
|
|
>
|
|
<!-- Colored left edge marks the run's status; matches the row's
|
|
status-chip color but reads at a glance without needing to
|
|
parse the chip text. -->
|
|
<div class="fc-dl-row__bar" />
|
|
|
|
<v-chip
|
|
:color="statusColor"
|
|
size="small"
|
|
variant="tonal"
|
|
:prepend-icon="statusIcon"
|
|
class="fc-dl-row__status"
|
|
>{{ statusLabel }}</v-chip>
|
|
|
|
<RouterLink
|
|
v-if="event.artist_slug"
|
|
:to="`/artist/${event.artist_slug}`"
|
|
class="fc-dl-row__artist"
|
|
@click.stop
|
|
>{{ event.artist_name }}</RouterLink>
|
|
<span v-else class="fc-dl-row__artist fc-dl-row__artist--missing">—</span>
|
|
|
|
<PlatformChip
|
|
v-if="event.platform"
|
|
:platform="event.platform"
|
|
size="x-small"
|
|
class="fc-dl-row__platform"
|
|
/>
|
|
<span v-else class="fc-dl-row__platform-missing">—</span>
|
|
|
|
<span class="fc-dl-row__time" :title="event.started_at">
|
|
{{ fmtTime(event.started_at) }}
|
|
</span>
|
|
|
|
<v-chip
|
|
v-if="event.files_count > 0"
|
|
size="x-small" variant="tonal" color="info"
|
|
prepend-icon="mdi-image-multiple"
|
|
class="fc-dl-row__files"
|
|
>{{ event.files_count }}</v-chip>
|
|
<span v-else class="fc-dl-row__no-files" aria-label="no new files">·</span>
|
|
|
|
<span class="fc-dl-row__duration">
|
|
{{ fmtDuration(event.summary?.duration_seconds) }}
|
|
</span>
|
|
|
|
<v-chip
|
|
v-if="event.error"
|
|
color="error" size="x-small" variant="tonal"
|
|
prepend-icon="mdi-alert-octagon"
|
|
class="fc-dl-row__error"
|
|
:title="event.error"
|
|
>{{ truncateError(event.error) }}</v-chip>
|
|
<span v-else class="fc-dl-row__error-spacer" />
|
|
|
|
<div class="fc-dl-row__actions" @click.stop>
|
|
<v-btn
|
|
v-if="event.status === 'error' && event.source_id"
|
|
icon size="x-small" variant="text" color="warning"
|
|
:loading="retrying"
|
|
@click.stop="onRetry"
|
|
>
|
|
<v-icon size="small">mdi-refresh</v-icon>
|
|
<v-tooltip activator="parent" location="top">Retry source check</v-tooltip>
|
|
</v-btn>
|
|
<v-btn icon size="x-small" variant="text" @click.stop="$emit('open', event.id)">
|
|
<v-icon size="small">mdi-information-outline</v-icon>
|
|
<v-tooltip activator="parent" location="top">Details</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
v-if="event.artist_slug"
|
|
icon size="x-small" variant="text"
|
|
:to="`/artist/${event.artist_slug}`"
|
|
@click.stop
|
|
>
|
|
<v-icon size="small">mdi-account-circle</v-icon>
|
|
<v-tooltip activator="parent" location="top">Open artist</v-tooltip>
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { toast } from '../../utils/toast.js'
|
|
import { computed, ref } from 'vue'
|
|
import { RouterLink } from 'vue-router'
|
|
|
|
import PlatformChip from '../subscriptions/PlatformChip.vue'
|
|
import { useSourcesStore } from '../../stores/sources.js'
|
|
import { downloadStatusColor, downloadStatusIcon, downloadStatusLabel } from '../../utils/downloadStatus.js'
|
|
import { formatDateTime } from '../../utils/date.js'
|
|
|
|
const props = defineProps({ event: { type: Object, required: true } })
|
|
defineEmits(['open'])
|
|
|
|
const sourcesStore = useSourcesStore()
|
|
const retrying = ref(false)
|
|
|
|
const statusColor = computed(() => downloadStatusColor(props.event.status))
|
|
const statusIcon = computed(() => downloadStatusIcon(props.event.status))
|
|
const statusLabel = computed(() => downloadStatusLabel(props.event.status))
|
|
|
|
function fmtTime(iso) {
|
|
return iso ? formatDateTime(iso) : '—'
|
|
}
|
|
function fmtDuration(sec) {
|
|
if (sec == null) return '—'
|
|
if (sec < 60) return `${sec.toFixed(1)}s`
|
|
const m = Math.floor(sec / 60), s = Math.floor(sec % 60)
|
|
return `${m}m ${s}s`
|
|
}
|
|
function truncateError(msg) {
|
|
const s = String(msg || '')
|
|
if (s.length <= 60) return s
|
|
return s.slice(0, 57) + '…'
|
|
}
|
|
|
|
async function onRetry() {
|
|
if (!props.event.source_id) return
|
|
retrying.value = true
|
|
try {
|
|
const body = await sourcesStore.checkNow(props.event.source_id)
|
|
// Audit 2026-06-02: the previous handler unconditionally toasted
|
|
// "re-queued" even when the platform was in cooldown (202 +
|
|
// status='deferred'). Operator thought work was in flight when
|
|
// nothing was actually enqueued.
|
|
if (body?.status === 'deferred') {
|
|
toast({ text: 'Retry deferred — platform in cooldown', type: 'info' })
|
|
} else {
|
|
toast({ text: 'Source check re-queued', type: 'success' })
|
|
}
|
|
} catch (e) {
|
|
const isInFlight = !!e?.body?.download_event_id
|
|
toast({
|
|
text: isInFlight ? 'Already running' : `Retry failed: ${e?.detail || e?.message || e}`,
|
|
type: isInFlight ? 'info' : 'error',
|
|
})
|
|
} finally {
|
|
retrying.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-dl-row {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns:
|
|
/* bar */ 4px
|
|
/* status */ 120px
|
|
/* artist */ minmax(120px, 1.2fr)
|
|
/* plat */ 140px
|
|
/* time */ 140px
|
|
/* files */ 60px
|
|
/* dur */ 70px
|
|
/* error */ minmax(0, 1.5fr)
|
|
/* actions*/ 120px;
|
|
gap: 0.6rem;
|
|
align-items: center;
|
|
padding: 0.55rem 0.75rem 0.55rem 0;
|
|
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
|
|
cursor: pointer;
|
|
transition: background 0.12s ease;
|
|
}
|
|
.fc-dl-row:hover {
|
|
background: rgb(var(--v-theme-on-surface) / 0.04);
|
|
}
|
|
.fc-dl-row__bar {
|
|
width: 4px;
|
|
align-self: stretch;
|
|
border-radius: 0 2px 2px 0;
|
|
}
|
|
.fc-dl-row--ok .fc-dl-row__bar { background: rgb(var(--v-theme-success)); }
|
|
.fc-dl-row--error .fc-dl-row__bar { background: rgb(var(--v-theme-error)); }
|
|
.fc-dl-row--running .fc-dl-row__bar { background: rgb(var(--v-theme-info)); }
|
|
.fc-dl-row--skipped .fc-dl-row__bar { background: rgb(var(--v-theme-warning)); }
|
|
.fc-dl-row--pending .fc-dl-row__bar { background: rgb(var(--v-theme-on-surface-variant) / 0.4); }
|
|
|
|
.fc-dl-row__status { justify-self: start; }
|
|
.fc-dl-row__artist {
|
|
color: rgb(var(--v-theme-on-surface));
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.fc-dl-row__artist--missing,
|
|
.fc-dl-row__platform-missing,
|
|
.fc-dl-row__no-files {
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
opacity: 0.5;
|
|
}
|
|
.fc-dl-row__artist:hover { color: rgb(var(--v-theme-accent)); }
|
|
.fc-dl-row__platform { justify-self: start; }
|
|
.fc-dl-row__time,
|
|
.fc-dl-row__duration {
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
font-size: 0.85rem;
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
.fc-dl-row__no-files {
|
|
text-align: center;
|
|
font-size: 1.1rem;
|
|
}
|
|
.fc-dl-row__error {
|
|
justify-self: start;
|
|
max-width: 100%;
|
|
}
|
|
.fc-dl-row__error :deep(.v-chip__content) {
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.fc-dl-row__error-spacer { /* keeps the grid column reserved */ }
|
|
|
|
.fc-dl-row__actions {
|
|
display: flex; gap: 2px;
|
|
justify-self: end;
|
|
opacity: 0.5;
|
|
transition: opacity 0.12s ease;
|
|
}
|
|
.fc-dl-row:hover .fc-dl-row__actions { opacity: 1; }
|
|
</style>
|