ongoing polish in queue handling
This commit is contained in:
@@ -37,6 +37,7 @@ export const downloadsApi = {
|
||||
retry: (id) => api.post(`/downloads/${id}/retry`),
|
||||
stats: (params = {}) => api.get('/downloads/stats', { params }),
|
||||
recentActivity: (params = {}) => api.get('/downloads/recent-activity', { params }),
|
||||
resetOrphaned: (thresholdMinutes = 0) => api.post('/downloads/reset-orphaned', { threshold_minutes: thresholdMinutes }),
|
||||
}
|
||||
|
||||
// Credentials API
|
||||
|
||||
@@ -51,8 +51,11 @@
|
||||
<v-icon>mdi-clock-outline</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h4">{{ stats?.pending || 0 }}</div>
|
||||
<div class="text-caption">Pending Downloads</div>
|
||||
<div class="text-h4">{{ activeDownloadsCount }}</div>
|
||||
<div class="text-caption">Active Downloads</div>
|
||||
<div class="text-caption text-grey" v-if="stats?.queued || stats?.running">
|
||||
{{ stats?.queued || 0 }} queued, {{ stats?.running || 0 }} running
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -81,6 +84,16 @@
|
||||
>
|
||||
Retry All Failed ({{ failedCount }})
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-broom"
|
||||
:loading="resettingOrphaned"
|
||||
:disabled="!stuckRunningCount"
|
||||
@click="resetOrphanedJobs"
|
||||
>
|
||||
Reset Stuck ({{ stuckRunningCount }})
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<div class="text-body-2 text-grey d-flex align-center">
|
||||
<v-icon size="small" class="mr-1">mdi-harddisk</v-icon>
|
||||
@@ -112,41 +125,43 @@
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<!-- Running Downloads (auto-refresh) -->
|
||||
<!-- Active Downloads (queued + running, auto-refresh) -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2" :class="{ 'mdi-spin': runningDownloads.length }">
|
||||
{{ runningDownloads.length ? 'mdi-loading' : 'mdi-download' }}
|
||||
<v-icon class="mr-2" :class="{ 'mdi-spin': activeDownloads.length }">
|
||||
{{ activeDownloads.length ? 'mdi-loading' : 'mdi-download' }}
|
||||
</v-icon>
|
||||
Running
|
||||
<v-chip v-if="runningDownloads.length" size="small" color="info" class="ml-2">
|
||||
{{ runningDownloads.length }}
|
||||
Active
|
||||
<v-chip v-if="activeDownloads.length" size="small" color="info" class="ml-2">
|
||||
{{ activeDownloads.length }}
|
||||
</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="runningDownloads.length"
|
||||
v-if="activeDownloads.length"
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="refreshRunning"
|
||||
@click="refreshActive"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="runningDownloads.length" density="compact">
|
||||
<v-list-item v-for="dl in runningDownloads" :key="dl.id">
|
||||
<v-list v-if="activeDownloads.length" density="compact">
|
||||
<v-list-item v-for="dl in activeDownloads" :key="dl.id">
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="dl.status === 'running' ? 'info' : 'warning'" size="small">
|
||||
{{ dl.status === 'running' ? 'mdi-loading mdi-spin' : 'mdi-clock-outline' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">
|
||||
{{ truncate(dl.url, 30) }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<v-progress-linear
|
||||
indeterminate
|
||||
color="info"
|
||||
height="4"
|
||||
class="mt-1"
|
||||
/>
|
||||
<v-chip size="x-small" :color="dl.status === 'running' ? 'info' : 'warning'" variant="tonal">
|
||||
{{ dl.status }}
|
||||
</v-chip>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -276,7 +291,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { settingsApi } from '../services/api'
|
||||
import { settingsApi, downloadsApi } from '../services/api'
|
||||
|
||||
const sourcesStore = useSourcesStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
@@ -285,8 +300,9 @@ const notifications = useNotificationStore()
|
||||
// State
|
||||
const checkingAll = ref(false)
|
||||
const retryingAll = ref(false)
|
||||
const resettingOrphaned = ref(false)
|
||||
const storageStats = ref(null)
|
||||
const runningDownloads = ref([])
|
||||
const activeDownloads = ref([])
|
||||
const loadingStats = ref(true)
|
||||
const loadingActivity = ref(true)
|
||||
const loadingStorage = ref(true)
|
||||
@@ -300,6 +316,11 @@ const sourcesWithErrors = computed(() =>
|
||||
sourcesStore.sources.filter(s => s.error_count > 0).slice(0, 5)
|
||||
)
|
||||
const failedCount = computed(() => stats.value?.failed || 0)
|
||||
const activeDownloadsCount = computed(() =>
|
||||
(stats.value?.queued || 0) + (stats.value?.running || 0)
|
||||
)
|
||||
// Running jobs that might be stuck (shows count for UI, actual stuck detection happens server-side)
|
||||
const stuckRunningCount = computed(() => stats.value?.running || 0)
|
||||
|
||||
onMounted(() => {
|
||||
// Load data in background - DON'T await, let UI render immediately
|
||||
@@ -325,18 +346,25 @@ function loadDashboardData() {
|
||||
.catch(e => console.error('Failed to fetch recent activity:', e))
|
||||
.finally(() => loadingActivity.value = false)
|
||||
|
||||
fetchRunningDownloads()
|
||||
fetchActiveDownloads()
|
||||
|
||||
// Storage stats can be slow - load independently
|
||||
fetchStorageStats()
|
||||
}
|
||||
|
||||
async function fetchRunningDownloads() {
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
const response = await downloadsStore.fetchDownloads({ status: 'running', per_page: 20 })
|
||||
runningDownloads.value = response.items.filter(d => d.status === 'running')
|
||||
// Fetch both queued and running downloads
|
||||
const [queuedResponse, runningResponse] = await Promise.all([
|
||||
downloadsStore.fetchDownloads({ status: 'queued', per_page: 20 }),
|
||||
downloadsStore.fetchDownloads({ status: 'running', per_page: 20 }),
|
||||
])
|
||||
// Combine and sort: running first, then queued
|
||||
const running = runningResponse.items.filter(d => d.status === 'running')
|
||||
const queued = queuedResponse.items.filter(d => d.status === 'queued')
|
||||
activeDownloads.value = [...running, ...queued]
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch running downloads:', e)
|
||||
console.error('Failed to fetch active downloads:', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,10 +396,10 @@ async function refreshStorageStats() {
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
// Refresh running downloads every 5 seconds when there are active downloads
|
||||
// Refresh active downloads every 5 seconds when there are active downloads
|
||||
refreshInterval = setInterval(async () => {
|
||||
if (runningDownloads.value.length > 0 || stats.value?.pending > 0) {
|
||||
await fetchRunningDownloads()
|
||||
if (activeDownloads.value.length > 0 || activeDownloadsCount.value > 0) {
|
||||
await fetchActiveDownloads()
|
||||
await downloadsStore.fetchStats()
|
||||
await downloadsStore.fetchRecentActivity({ limit: 10 })
|
||||
}
|
||||
@@ -385,8 +413,8 @@ function stopAutoRefresh() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRunning() {
|
||||
await fetchRunningDownloads()
|
||||
async function refreshActive() {
|
||||
await fetchActiveDownloads()
|
||||
}
|
||||
|
||||
async function checkAllSources() {
|
||||
@@ -403,7 +431,8 @@ async function checkAllSources() {
|
||||
}
|
||||
}
|
||||
notifications.success(`Queued checks for ${queued} sources`)
|
||||
await fetchRunningDownloads()
|
||||
await fetchActiveDownloads()
|
||||
await downloadsStore.fetchStats()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to check sources: ${error.message}`)
|
||||
} finally {
|
||||
@@ -427,7 +456,7 @@ async function retryAllFailed() {
|
||||
}
|
||||
notifications.success(`Queued retry for ${retried} downloads`)
|
||||
await downloadsStore.fetchStats()
|
||||
await fetchRunningDownloads()
|
||||
await fetchActiveDownloads()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to retry downloads: ${error.message}`)
|
||||
} finally {
|
||||
@@ -435,11 +464,26 @@ async function retryAllFailed() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetOrphanedJobs() {
|
||||
resettingOrphaned.value = true
|
||||
try {
|
||||
const response = await downloadsApi.resetOrphaned(0) // 0 = reset all running
|
||||
notifications.success(response.data.message || `Reset ${response.data.reset_count} stuck jobs`)
|
||||
await downloadsStore.fetchStats()
|
||||
await fetchActiveDownloads()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to reset stuck jobs: ${error.message}`)
|
||||
} finally {
|
||||
resettingOrphaned.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
completed: 'mdi-check-circle',
|
||||
failed: 'mdi-alert-circle',
|
||||
running: 'mdi-loading mdi-spin',
|
||||
queued: 'mdi-clock-outline',
|
||||
pending: 'mdi-clock-outline',
|
||||
skipped: 'mdi-skip-next',
|
||||
}
|
||||
@@ -451,6 +495,7 @@ function getStatusColor(status) {
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
running: 'info',
|
||||
queued: 'warning',
|
||||
pending: 'warning',
|
||||
skipped: 'grey',
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
hint="Maximum number of concurrent downloads (1-10)"
|
||||
hint="Maximum concurrent downloads (1-10). Requires container restart to take effect."
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
@@ -54,7 +54,7 @@
|
||||
label="Schedule Interval (seconds)"
|
||||
type="number"
|
||||
min="300"
|
||||
hint="How often to check for new content (minimum 5 minutes)"
|
||||
:hint="scheduleIntervalHint"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
@@ -419,6 +419,18 @@ const sortedAllPackages = computed(() => {
|
||||
return sorted
|
||||
})
|
||||
|
||||
const scheduleIntervalHint = computed(() => {
|
||||
const seconds = settings.value['download.schedule_interval'] || 3600
|
||||
if (seconds < 60) return `Check every ${seconds} seconds`
|
||||
if (seconds < 3600) return `Check every ${Math.round(seconds / 60)} minutes`
|
||||
if (seconds < 86400) {
|
||||
const hours = seconds / 3600
|
||||
return `Check every ${hours % 1 === 0 ? hours : hours.toFixed(1)} hours`
|
||||
}
|
||||
const days = seconds / 86400
|
||||
return `Check every ${days % 1 === 0 ? days : days.toFixed(1)} days`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Don't block UI - load data in background
|
||||
loadAll()
|
||||
|
||||
Reference in New Issue
Block a user