f05aaa707b
Alembic 0032 adds Source.error_type (varchar(32), indexed).
_update_source_health stamps it alongside last_error on status='error'
and clears it on 'ok'. SourceRecord/to_dict exposes it.
FailingSourcesCard renders a colored chip next to the consecutive-
failures count, with a tooltip explaining the suggested operator
action. Color reflects intent:
- warning (yellow) — operator action needed (auth_error)
- info (blue) — backend-paced (rate_limited / timeout /
network_error / partial / tier_limited)
- error (red) — likely terminal without intervention
(not_found / access_denied / validation_failed /
unsupported_url / http_error / unknown_error)
Audit 2026-06-02: the backend computed 13 ErrorType categories but
only the free-text last_error reached the operator. Bulk-triage by
class ("all auth_error → rotate cookies", "12 rate_limited → just
wait") required opening Logs per row.
171 lines
5.9 KiB
Vue
171 lines
5.9 KiB
Vue
<template>
|
||
<v-card v-if="sources.length" variant="tonal" color="error" class="fc-fail mb-4">
|
||
<div class="fc-fail__head" @click="open = !open">
|
||
<v-icon size="small">mdi-alert-circle</v-icon>
|
||
<span class="fc-fail__title">
|
||
{{ sources.length }}
|
||
{{ sources.length === 1 ? 'source is' : 'sources are' }} failing
|
||
</span>
|
||
<v-spacer />
|
||
<v-btn
|
||
size="small" variant="text" prepend-icon="mdi-refresh"
|
||
:loading="retryingAll"
|
||
@click.stop="$emit('retry-all', sources)"
|
||
>
|
||
Retry all
|
||
</v-btn>
|
||
<v-icon size="small">{{ open ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
|
||
</div>
|
||
|
||
<v-expand-transition>
|
||
<div v-if="open" class="fc-fail__body">
|
||
<div v-for="s in sources" :key="s.id" class="fc-fail__row">
|
||
<PlatformChip :platform="s.platform" size="x-small" />
|
||
<span class="fc-fail__artist">{{ s.artist_name || `#${s.artist_id}` }}</span>
|
||
<v-chip size="x-small" color="error" variant="flat" label class="fc-fail__count">
|
||
{{ s.consecutive_failures }}× failed
|
||
</v-chip>
|
||
<v-chip
|
||
v-if="s.error_type"
|
||
size="x-small" variant="outlined" label
|
||
:color="errorTypeColor(s.error_type)"
|
||
class="fc-fail__class"
|
||
:title="errorTypeHint(s.error_type)"
|
||
>
|
||
{{ s.error_type }}
|
||
</v-chip>
|
||
<span class="fc-fail__err" :title="s.last_error || ''">
|
||
{{ s.last_error || 'no error message recorded' }}
|
||
</span>
|
||
<v-spacer />
|
||
<v-btn
|
||
size="x-small" variant="text" prepend-icon="mdi-text-box-search-outline"
|
||
:loading="logLoadingIds.has(s.id)"
|
||
@click="onViewLogs(s)"
|
||
title="Show the most recent download event's stdout/stderr/error"
|
||
>
|
||
Logs
|
||
</v-btn>
|
||
<v-btn
|
||
size="x-small" variant="text" prepend-icon="mdi-refresh"
|
||
:loading="retryingIds.has(s.id)"
|
||
@click="$emit('retry', s)"
|
||
>
|
||
Retry
|
||
</v-btn>
|
||
</div>
|
||
</div>
|
||
</v-expand-transition>
|
||
</v-card>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref } from 'vue'
|
||
import PlatformChip from './PlatformChip.vue'
|
||
|
||
defineProps({
|
||
// source records from /api/sources?failing=true
|
||
sources: { type: Array, default: () => [] },
|
||
retryingIds: { type: Set, default: () => new Set() },
|
||
retryingAll: { type: Boolean, default: false },
|
||
})
|
||
const emit = defineEmits(['retry', 'retry-all', 'view-logs'])
|
||
|
||
const open = ref(true)
|
||
// Per-row loading flag so the spinner lives on the row whose Logs
|
||
// button was clicked, not on every row.
|
||
const logLoadingIds = ref(new Set())
|
||
|
||
// Audit 2026-06-02: surface the ErrorType taxonomy as a colored chip
|
||
// next to the consecutive-failures count so operators can bulk-triage
|
||
// by error class. Color reflects "what to do next":
|
||
// warning (yellow) — auth/cookie issue: operator should rotate
|
||
// info (blue) — backend-paced (cooldown / rate limit / timeout)
|
||
// error (red) — likely terminal without operator intervention
|
||
const ERROR_TYPE_COLOR = {
|
||
auth_error: 'warning',
|
||
rate_limited: 'info',
|
||
timeout: 'info',
|
||
network_error: 'info',
|
||
not_found: 'error',
|
||
access_denied: 'error',
|
||
validation_failed: 'error',
|
||
unsupported_url: 'error',
|
||
http_error: 'error',
|
||
unknown_error: 'error',
|
||
partial: 'info',
|
||
tier_limited: 'info',
|
||
no_new_content: 'info',
|
||
}
|
||
const ERROR_TYPE_HINT = {
|
||
auth_error: 'Cookies likely expired — re-upload in Credentials.',
|
||
rate_limited: 'Platform-wide cooldown active. Will retry after it expires.',
|
||
timeout: 'Subprocess exceeded its time budget. Often retries cleanly.',
|
||
network_error: 'Transient network issue. Will retry on next tick.',
|
||
not_found: 'URL 404 — creator may have renamed or deleted.',
|
||
access_denied: 'Subscription tier may not grant this content.',
|
||
validation_failed: 'Downloaded files were quarantined by the validator.',
|
||
http_error: 'Generic HTTP error — see Logs.',
|
||
unsupported_url: 'gallery-dl does not support this URL pattern.',
|
||
unknown_error: 'Could not classify — see Logs.',
|
||
}
|
||
function errorTypeColor(t) { return ERROR_TYPE_COLOR[t] || 'error' }
|
||
function errorTypeHint(t) { return ERROR_TYPE_HINT[t] || '' }
|
||
async function onViewLogs(s) {
|
||
if (logLoadingIds.value.has(s.id)) return
|
||
logLoadingIds.value = new Set(logLoadingIds.value).add(s.id)
|
||
try {
|
||
await emit('view-logs', s)
|
||
} finally {
|
||
const next = new Set(logLoadingIds.value)
|
||
next.delete(s.id)
|
||
logLoadingIds.value = next
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.fc-fail { border-radius: 8px; }
|
||
.fc-fail__head {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 10px 14px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.fc-fail__title { font-weight: 600; }
|
||
.fc-fail__body {
|
||
padding: 0 14px 10px;
|
||
display: flex; flex-direction: column;
|
||
/* Borders, not gap, so the row separators are visible inside the
|
||
* tonal error card (where surface tints get washed out and gap is
|
||
* just empty space). */
|
||
}
|
||
.fc-fail__row {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 10px;
|
||
border-radius: 4px;
|
||
/* Hover-darken gives the eye a horizontal track from the artist
|
||
* name on the left to the Logs/Retry buttons on the right.
|
||
* Bottom border replaces the previous-too-subtle zebra striping —
|
||
* inside the tonal error card, surface-tint contrast was negligible.
|
||
* Operator-flagged 2026-06-01 (twice). */
|
||
border-bottom: 1px solid rgb(255 255 255 / 0.08);
|
||
transition: background 80ms ease;
|
||
}
|
||
.fc-fail__row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.fc-fail__row:hover {
|
||
background: rgb(0 0 0 / 0.25);
|
||
}
|
||
.fc-fail__artist { font-weight: 600; white-space: nowrap; }
|
||
.fc-fail__count { flex: 0 0 auto; }
|
||
.fc-fail__class { flex: 0 0 auto; }
|
||
.fc-fail__err {
|
||
color: rgb(var(--v-theme-on-surface-variant));
|
||
font-size: 0.8rem;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
max-width: 46ch;
|
||
}
|
||
</style>
|