This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
GallerySubscriber/frontend/src/views/Downloads.vue
T
bvandeusen 0f8dbfe5c2 feat(ui): add exclude superseded failures filter toggle to Downloads
Adds a new filter toggle to the Downloads view that allows users to hide
superseded failed downloads. The filter is enabled by default and passes
exclude_superseded=true to the API when active.

Changes:
- Add filterExcludeSuperseded ref initialized to true
- Add filterExcludeSuperseded to watch dependencies
- Include exclude_superseded param in loadDownloads() when true
- Add v-switch toggle UI control below date filters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:32:41 -04:00

506 lines
16 KiB
Vue

<template>
<div>
<!-- Header with Stats and Actions -->
<v-row class="mb-4" align="center">
<v-col cols="12" md="6">
<h1 class="text-h5 mb-2">Downloads</h1>
<div v-if="downloadsStore.stats" class="d-flex flex-wrap ga-2">
<v-chip color="warning" variant="tonal" size="small">
<v-icon start size="small">mdi-clock-outline</v-icon>
{{ downloadsStore.stats.queued || 0 }} Queued
</v-chip>
<v-chip color="info" variant="tonal" size="small">
<v-icon start size="small" class="mdi-spin">mdi-loading</v-icon>
{{ downloadsStore.stats.running || 0 }} Running
</v-chip>
<v-chip color="success" variant="tonal" size="small">
<v-icon start size="small">mdi-check-circle</v-icon>
{{ downloadsStore.stats.completed || 0 }} Completed
</v-chip>
<v-chip color="error" variant="tonal" size="small">
<v-icon start size="small">mdi-alert-circle</v-icon>
{{ downloadsStore.stats.failed || 0 }} Failed
</v-chip>
</div>
</v-col>
<v-col cols="12" md="6" class="d-flex justify-end ga-2 flex-wrap">
<v-btn
variant="outlined"
size="small"
@click="refresh"
:loading="downloadsStore.loading"
>
<v-icon start>mdi-refresh</v-icon>
Refresh
</v-btn>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
variant="outlined"
size="small"
color="warning"
v-bind="props"
>
<v-icon start>mdi-wrench</v-icon>
Maintenance
<v-icon end>mdi-chevron-down</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item @click="resetOrphanedJobs">
<v-list-item-title>Reset Orphaned Running Jobs</v-list-item-title>
<v-list-item-subtitle>Reset jobs stuck in "running" status</v-list-item-subtitle>
</v-list-item>
<v-list-item @click="requeueStaleJobs">
<v-list-item-title>Re-queue Stale Queued Jobs</v-list-item-title>
<v-list-item-subtitle>Re-send lost Celery tasks for queued jobs</v-list-item-subtitle>
</v-list-item>
<v-divider />
<v-list-item @click="exportFailedLogs">
<v-list-item-title>
<v-icon start size="small">mdi-download</v-icon>
Export Failed Logs
</v-list-item-title>
<v-list-item-subtitle>Download JSON for error analysis</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-menu>
</v-col>
</v-row>
<!-- Filters -->
<v-row class="mb-4">
<v-col cols="12" md="3">
<v-select
v-model="filterStatus"
:items="statusOptions"
label="Status"
density="compact"
variant="outlined"
clearable
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="filterSourceId"
:items="sourceOptions"
label="Source"
density="compact"
variant="outlined"
clearable
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field
v-model="filterFromDate"
label="From Date"
type="date"
density="compact"
variant="outlined"
clearable
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field
v-model="filterToDate"
label="To Date"
type="date"
density="compact"
variant="outlined"
clearable
/>
</v-col>
</v-row>
<!-- Superseded filter -->
<v-row class="mb-4">
<v-col cols="12">
<v-switch
v-model="filterExcludeSuperseded"
label="Hide superseded failures"
density="compact"
hide-details
color="primary"
/>
</v-col>
</v-row>
<!-- Downloads Table -->
<v-card>
<v-data-table
:headers="headers"
:items="downloadsStore.downloads"
:loading="downloadsStore.loading"
:items-per-page="20"
class="elevation-1"
>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="tonal"
>
<v-icon start size="small" :class="{ 'mdi-spin': item.status === 'running' }">
{{ getStatusIcon(item.status) }}
</v-icon>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.source="{ item }">
<span class="text-caption">{{ getSourceLabel(item.source_id) }}</span>
</template>
<template v-slot:item.url="{ item }">
<span :title="item.url" class="text-caption">{{ truncateUrl(item.url) }}</span>
</template>
<template v-slot:item.error_type="{ item }">
<v-chip
v-if="item.error_type"
color="error"
size="small"
variant="tonal"
>
{{ formatErrorType(item.error_type) }}
</v-chip>
<span v-else class="text-grey">-</span>
</template>
<template v-slot:item.file_count="{ item }">
<v-chip v-if="item.file_count > 0" color="success" size="small">
{{ item.file_count }} files
</v-chip>
<span v-else class="text-grey">0</span>
</template>
<template v-slot:item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<template v-slot:item.duration="{ item }">
{{ formatDuration(item) }}
</template>
<template v-slot:item.actions="{ item }">
<v-btn
v-if="item.status === 'failed'"
icon
size="small"
variant="text"
color="primary"
@click="retryDownload(item)"
:loading="retryingIds.includes(item.id)"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent">Retry</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
variant="text"
@click="showDetails(item)"
>
<v-icon>mdi-information</v-icon>
<v-tooltip activator="parent">Details</v-tooltip>
</v-btn>
</template>
<template v-slot:no-data>
<div class="text-center pa-4">
<v-icon size="48" color="grey-lighten-1">mdi-download-off</v-icon>
<p class="text-grey mt-2">No downloads found</p>
</div>
</template>
</v-data-table>
</v-card>
<!-- Details Dialog -->
<v-dialog v-model="detailsDialog" max-width="700">
<v-card v-if="selectedDownload">
<v-card-title>
Download Details
<v-chip
:color="getStatusColor(selectedDownload.status)"
size="small"
class="ml-2"
>
{{ selectedDownload.status }}
</v-chip>
</v-card-title>
<v-card-text>
<v-table density="compact">
<tbody>
<tr>
<td class="font-weight-bold" width="150">Source</td>
<td>{{ getSourceLabel(selectedDownload.source_id) }}</td>
</tr>
<tr>
<td class="font-weight-bold">URL</td>
<td style="word-break: break-all">{{ selectedDownload.url }}</td>
</tr>
<tr>
<td class="font-weight-bold">Files Downloaded</td>
<td>{{ selectedDownload.file_count }}</td>
</tr>
<tr>
<td class="font-weight-bold">Created</td>
<td>{{ formatDate(selectedDownload.created_at) }}</td>
</tr>
<tr>
<td class="font-weight-bold">Started</td>
<td>{{ formatDate(selectedDownload.started_at) }}</td>
</tr>
<tr>
<td class="font-weight-bold">Completed</td>
<td>{{ formatDate(selectedDownload.completed_at) }}</td>
</tr>
<tr v-if="selectedDownload.error_type">
<td class="font-weight-bold">Error Type</td>
<td class="text-error">{{ formatErrorType(selectedDownload.error_type) }}</td>
</tr>
<tr v-if="selectedDownload.error_message">
<td class="font-weight-bold">Error Message</td>
<td class="text-error">{{ selectedDownload.error_message }}</td>
</tr>
</tbody>
</v-table>
<v-expansion-panels class="mt-4" v-if="selectedDownload.metadata">
<v-expansion-panel v-if="selectedDownload.metadata.stdout">
<v-expansion-panel-title>Output Log</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stdout }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel v-if="selectedDownload.metadata.stderr">
<v-expansion-panel-title>Error Log</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption text-error" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stderr }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
v-if="selectedDownload.status === 'failed'"
color="primary"
@click="retryDownload(selectedDownload); detailsDialog = false"
>
Retry Download
</v-btn>
<v-btn variant="text" @click="detailsDialog = false">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useDownloadsStore } from '../stores/downloads'
import { useSourcesStore } from '../stores/sources'
import { useNotificationStore } from '../stores/notifications'
const downloadsStore = useDownloadsStore()
const sourcesStore = useSourcesStore()
const notifications = useNotificationStore()
const filterStatus = ref(null)
const filterSourceId = ref(null)
const filterFromDate = ref(null)
const filterToDate = ref(null)
const filterExcludeSuperseded = ref(true)
const detailsDialog = ref(false)
const selectedDownload = ref(null)
const retryingIds = ref([])
let refreshInterval = null
const statusOptions = [
{ title: 'Queued', value: 'queued' },
{ title: 'Running', value: 'running' },
{ title: 'Completed', value: 'completed' },
{ title: 'Failed', value: 'failed' },
{ title: 'Skipped', value: 'skipped' },
]
const sourceOptions = computed(() =>
sourcesStore.sources.map(s => ({
title: `${s.subscription_name || 'Unknown'}:${s.platform}`,
value: s.id
}))
)
const headers = [
{ title: 'Status', key: 'status', width: 120 },
{ title: 'Source', key: 'source', width: 180 },
{ title: 'URL', key: 'url' },
{ title: 'Error', key: 'error_type', width: 130 },
{ title: 'Files', key: 'file_count', width: 80 },
{ title: 'Date', key: 'created_at', width: 160 },
{ title: 'Duration', key: 'duration', width: 90 },
{ title: 'Actions', key: 'actions', width: 90, sortable: false },
]
onMounted(() => {
refresh()
sourcesStore.fetchSources()
// Auto-refresh every 30 seconds if there are running/queued jobs
refreshInterval = setInterval(() => {
if (downloadsStore.stats?.running > 0 || downloadsStore.stats?.queued > 0) {
loadDownloads()
downloadsStore.fetchStats()
}
}, 30000)
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
watch([filterStatus, filterSourceId, filterFromDate, filterToDate, filterExcludeSuperseded], () => {
loadDownloads()
})
async function refresh() {
await Promise.all([
loadDownloads(),
downloadsStore.fetchStats()
])
}
async function loadDownloads() {
const params = {}
if (filterStatus.value) params.status = filterStatus.value
if (filterSourceId.value) params.source_id = filterSourceId.value
if (filterFromDate.value) params.from_date = filterFromDate.value
if (filterToDate.value) params.to_date = filterToDate.value
if (filterExcludeSuperseded.value) params.exclude_superseded = true
await downloadsStore.fetchDownloads(params)
}
async function resetOrphanedJobs() {
try {
const result = await downloadsStore.resetOrphaned()
notifications.success(`Reset ${result.data.reset_count} orphaned jobs`)
await refresh()
} catch (error) {
notifications.error(`Failed to reset orphaned jobs: ${error.message}`)
}
}
async function requeueStaleJobs() {
try {
const result = await downloadsStore.requeueStale()
notifications.success(`Re-queued ${result.data.requeued_count} stale jobs`)
await refresh()
} catch (error) {
notifications.error(`Failed to re-queue stale jobs: ${error.message}`)
}
}
async function exportFailedLogs() {
try {
// Open download in new tab - the API returns a file attachment
const baseUrl = import.meta.env.VITE_API_URL || '/api'
const url = `${baseUrl}/downloads/export-failed-logs?limit=100&days=30&include_success=true`
window.open(url, '_blank')
notifications.success('Exporting failed logs...')
} catch (error) {
notifications.error(`Failed to export logs: ${error.message}`)
}
}
function getStatusIcon(status) {
const icons = {
completed: 'mdi-check-circle',
failed: 'mdi-alert-circle',
running: 'mdi-loading',
queued: 'mdi-clock-outline',
skipped: 'mdi-skip-next',
}
return icons[status] || 'mdi-help-circle'
}
function getStatusColor(status) {
const colors = {
completed: 'success',
failed: 'error',
running: 'info',
queued: 'warning',
skipped: 'grey',
}
return colors[status] || 'grey'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
function formatDuration(download) {
if (!download.started_at || !download.completed_at) return '-'
const start = new Date(download.started_at)
const end = new Date(download.completed_at)
const seconds = Math.round((end - start) / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
return `${minutes}m ${seconds % 60}s`
}
function formatErrorType(errorType) {
if (!errorType) return ''
return errorType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
function truncateUrl(url) {
if (!url) return ''
if (url.length <= 40) return url
return url.substring(0, 37) + '...'
}
function getSourceLabel(sourceId) {
if (!sourceId) return 'N/A'
const source = sourcesStore.sources.find(s => s.id === sourceId)
if (source) {
return `${source.subscription_name || 'Unknown'}:${source.platform}`
}
return `Source #${sourceId}`
}
function showDetails(download) {
selectedDownload.value = download
detailsDialog.value = true
}
async function retryDownload(download) {
retryingIds.value.push(download.id)
try {
await downloadsStore.retryDownload(download.id)
notifications.success('Download queued for retry')
await refresh()
} catch (error) {
notifications.error(`Failed to retry: ${error.message}`)
} finally {
retryingIds.value = retryingIds.value.filter(id => id !== download.id)
}
}
</script>
<style scoped>
.mdi-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>