0f8dbfe5c2
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>
506 lines
16 KiB
Vue
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>
|