feat(dashboard): revamp with at-a-glance strip, sparkline, modal, disk bar
Replace the four stat cards with a compact three-card strip: 7-day activity with inline SVG sparkline (new ActivitySparkline), Running Now / Next Check with per-source countdown list, and System with credential health + disk usage bar. - Add GET /downloads/activity-timeline — per-day completed/failed/files counts, pre-filled with zero buckets so the sparkline always has N points. - Report filesystem-level usage via shutil.disk_usage in storage rollup, plus a live fallback in GET /settings so the capacity bar works before the first Celery rollup runs and for cached rows that predate the field. - Extract Download Details into a reusable DownloadDetailsModal component and wire Recent Activity rows to open it (previously Downloads-page only). - Compute next scheduled check per source from global schedule_interval; surface credential expiration/missing alerts scoped to platforms that actually have enabled sources. - Two-speed polling (5s when downloads are active, 30s idle) with Page Visibility awareness so background tabs don't churn the API. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="activity-sparkline" :style="{ height: `${height}px` }">
|
||||
<svg
|
||||
v-if="hasData"
|
||||
:viewBox="`0 0 ${width} ${height}`"
|
||||
preserveAspectRatio="none"
|
||||
width="100%"
|
||||
:height="height"
|
||||
role="img"
|
||||
aria-label="7-day activity"
|
||||
>
|
||||
<polyline
|
||||
v-if="completedPath"
|
||||
:points="completedPath"
|
||||
fill="none"
|
||||
stroke="rgb(var(--v-theme-success))"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
v-if="failedPath"
|
||||
:points="failedPath"
|
||||
fill="none"
|
||||
stroke="rgb(var(--v-theme-error))"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-dasharray="3,3"
|
||||
/>
|
||||
<g v-for="(pt, i) in filePoints" :key="i">
|
||||
<circle
|
||||
:cx="pt.x"
|
||||
:cy="pt.y"
|
||||
r="2"
|
||||
fill="rgb(var(--v-theme-success))"
|
||||
>
|
||||
<title>{{ pt.date }}: {{ pt.files }} files, {{ pt.completed }} runs{{ pt.failed ? `, ${pt.failed} failed` : '' }}</title>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
<div v-else class="d-flex align-center justify-center text-caption text-medium-emphasis" :style="{ height: `${height}px` }">
|
||||
No activity in the last {{ series.length }} days
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
series: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 54,
|
||||
},
|
||||
})
|
||||
|
||||
const width = 300
|
||||
const padX = 2
|
||||
const padY = 4
|
||||
|
||||
const hasData = computed(() =>
|
||||
props.series.some((b) => (b.files || 0) > 0 || (b.completed || 0) > 0 || (b.failed || 0) > 0)
|
||||
)
|
||||
|
||||
const maxValue = computed(() => {
|
||||
let max = 0
|
||||
for (const b of props.series) {
|
||||
if ((b.files || 0) > max) max = b.files || 0
|
||||
if ((b.completed || 0) > max) max = b.completed || 0
|
||||
if ((b.failed || 0) > max) max = b.failed || 0
|
||||
}
|
||||
return max || 1
|
||||
})
|
||||
|
||||
function scale(i, value) {
|
||||
const n = props.series.length
|
||||
const x = n <= 1 ? width / 2 : padX + (i * (width - padX * 2)) / (n - 1)
|
||||
const y = props.height - padY - (value / maxValue.value) * (props.height - padY * 2)
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
const completedPath = computed(() => {
|
||||
if (!props.series.some((b) => (b.completed || 0) > 0)) return ''
|
||||
return props.series
|
||||
.map((b, i) => {
|
||||
const { x, y } = scale(i, b.completed || 0)
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const failedPath = computed(() => {
|
||||
if (!props.series.some((b) => (b.failed || 0) > 0)) return ''
|
||||
return props.series
|
||||
.map((b, i) => {
|
||||
const { x, y } = scale(i, b.failed || 0)
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const filePoints = computed(() =>
|
||||
props.series.map((b, i) => {
|
||||
const { x, y } = scale(i, b.files || 0)
|
||||
return { x, y, files: b.files || 0, completed: b.completed || 0, failed: b.failed || 0, date: b.date }
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-sparkline {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" max-width="700">
|
||||
<v-card v-if="download">
|
||||
<v-card-title>
|
||||
Download Details
|
||||
<v-chip
|
||||
:color="getStatusColor(download.status)"
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ download.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(download) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">URL</td>
|
||||
<td style="word-break: break-all">{{ download.url }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Files Downloaded</td>
|
||||
<td>{{ runStats?.downloaded_count ?? download.file_count }}</td>
|
||||
</tr>
|
||||
<tr v-if="runStats">
|
||||
<td class="font-weight-bold">Skipped</td>
|
||||
<td>{{ runStats.skipped_count }} <span class="text-grey text-caption">(already in archive)</span></td>
|
||||
</tr>
|
||||
<tr v-if="runStats && runStats.per_item_failures > 0">
|
||||
<td class="font-weight-bold">Per-item Failures</td>
|
||||
<td>{{ runStats.per_item_failures }} <span class="text-grey text-caption">(single items gallery-dl skipped over)</span></td>
|
||||
</tr>
|
||||
<tr v-if="runStats && runStats.tier_gated_count > 0">
|
||||
<td class="font-weight-bold">Tier-Gated Posts</td>
|
||||
<td>
|
||||
{{ runStats.tier_gated_count }}
|
||||
<span class="text-grey text-caption">(not visible at current subscription tier)</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="runStats && runStats.warning_count > 0">
|
||||
<td class="font-weight-bold">Warnings</td>
|
||||
<td>{{ runStats.warning_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Duration</td>
|
||||
<td>{{ formatDuration(download) }}</td>
|
||||
</tr>
|
||||
<tr v-if="runStats">
|
||||
<td class="font-weight-bold">Exit Code</td>
|
||||
<td>
|
||||
<code :class="runStats.exit_code === 0 ? 'text-success' : 'text-warning'">{{ runStats.exit_code }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Created</td>
|
||||
<td>{{ formatDate(download.created_at) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Started</td>
|
||||
<td>{{ formatDate(download.started_at) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Completed</td>
|
||||
<td>{{ formatDate(download.completed_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="download.error_type">
|
||||
<td class="font-weight-bold">Error Type</td>
|
||||
<td :class="errorTextClass(download.error_type)">
|
||||
{{ formatErrorType(download.error_type) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="download.error_message">
|
||||
<td class="font-weight-bold">Error Message</td>
|
||||
<td :class="errorTextClass(download.error_type)">
|
||||
{{ download.error_message }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<v-expansion-panels class="mt-4" v-model="openLogPanels" multiple v-if="download.metadata">
|
||||
<v-expansion-panel v-if="download.metadata.stderr_errors_warnings">
|
||||
<v-expansion-panel-title>
|
||||
Errors & Warnings
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="copyToClipboard(download.metadata.stderr_errors_warnings, 'errors & warnings')"
|
||||
>
|
||||
<v-icon size="small">mdi-content-copy</v-icon>
|
||||
<v-tooltip activator="parent">Copy</v-tooltip>
|
||||
</v-btn>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<pre class="text-caption text-error" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ download.metadata.stderr_errors_warnings }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel v-if="download.metadata.stdout">
|
||||
<v-expansion-panel-title>
|
||||
Output Log (stdout)
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="copyToClipboard(download.metadata.stdout, 'stdout')"
|
||||
>
|
||||
<v-icon size="small">mdi-content-copy</v-icon>
|
||||
<v-tooltip activator="parent">Copy</v-tooltip>
|
||||
</v-btn>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ download.metadata.stdout }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel v-if="download.metadata.stderr">
|
||||
<v-expansion-panel-title>
|
||||
Verbose Log (stderr)
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="copyToClipboard(download.metadata.stderr, 'verbose log')"
|
||||
>
|
||||
<v-icon size="small">mdi-content-copy</v-icon>
|
||||
<v-tooltip activator="parent">Copy</v-tooltip>
|
||||
</v-btn>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-text-field
|
||||
v-model="verboseLogFilter"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
placeholder="Filter lines (substring match)"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
class="mb-2"
|
||||
/>
|
||||
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ filteredVerboseLog || '(no matching lines)' }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="download.status === 'failed'"
|
||||
color="primary"
|
||||
@click="$emit('retry', download)"
|
||||
>
|
||||
Retry Download
|
||||
</v-btn>
|
||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, required: true },
|
||||
download: { type: Object, default: null },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue', 'retry'])
|
||||
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const verboseLogFilter = ref('')
|
||||
const openLogPanels = ref([])
|
||||
|
||||
watch(
|
||||
() => props.download,
|
||||
(next) => {
|
||||
verboseLogFilter.value = ''
|
||||
openLogPanels.value = next?.metadata?.stderr_errors_warnings ? [0] : []
|
||||
}
|
||||
)
|
||||
|
||||
const runStats = computed(() => props.download?.metadata?.run_stats || null)
|
||||
|
||||
const filteredVerboseLog = computed(() => {
|
||||
const stderr = props.download?.metadata?.stderr || ''
|
||||
const query = verboseLogFilter.value.trim().toLowerCase()
|
||||
if (!query) return stderr
|
||||
return stderr
|
||||
.split('\n')
|
||||
.filter((line) => line.toLowerCase().includes(query))
|
||||
.join('\n')
|
||||
})
|
||||
|
||||
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) return '-'
|
||||
const start = new Date(download.started_at)
|
||||
const end = download.status === 'running' || !download.completed_at
|
||||
? new Date()
|
||||
: new Date(download.completed_at)
|
||||
const seconds = Math.round((end - start) / 1000)
|
||||
if (seconds < 0) return '-'
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
}
|
||||
|
||||
function formatErrorType(errorType) {
|
||||
if (!errorType) return ''
|
||||
return errorType.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
||||
}
|
||||
|
||||
const INFO_ERROR_TYPES = new Set(['no_new_content', 'tier_limited'])
|
||||
|
||||
function errorTextClass(errorType) {
|
||||
if (INFO_ERROR_TYPES.has(errorType)) return 'text-warning'
|
||||
return 'text-error'
|
||||
}
|
||||
|
||||
function getSourceLabel(item) {
|
||||
if (item && typeof item === 'object') {
|
||||
if (item.subscription_name && item.platform) {
|
||||
return `${item.subscription_name}:${item.platform}`
|
||||
}
|
||||
if (item.platform) {
|
||||
return `(unnamed):${item.platform}`
|
||||
}
|
||||
}
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
async function copyToClipboard(text, label = 'content') {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
notifications.success(`Copied ${label} to clipboard`)
|
||||
} catch (err) {
|
||||
notifications.error(`Copy failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -38,6 +38,7 @@ export const downloadsApi = {
|
||||
retryFailedBulk: (recentWindowHours = 24) => api.post('/downloads/retry-failed-bulk', { recent_window_hours: recentWindowHours }),
|
||||
stats: (params = {}) => api.get('/downloads/stats', { params }),
|
||||
recentActivity: (params = {}) => api.get('/downloads/recent-activity', { params }),
|
||||
activityTimeline: (params = {}) => api.get('/downloads/activity-timeline', { params }),
|
||||
resetOrphaned: (thresholdMinutes = 0) => api.post('/downloads/reset-orphaned', { threshold_minutes: thresholdMinutes }),
|
||||
requeueStale: (thresholdMinutes = 0) => api.post('/downloads/requeue-stale', { threshold_minutes: thresholdMinutes }),
|
||||
}
|
||||
@@ -56,6 +57,7 @@ export const settingsApi = {
|
||||
update: (data) => api.patch('/settings', data),
|
||||
getGalleryDL: () => api.get('/settings/gallery-dl'),
|
||||
updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }),
|
||||
resetGalleryDL: () => api.post('/settings/gallery-dl/reset'),
|
||||
getApiKey: () => api.get('/settings/api-key'),
|
||||
regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
|
||||
getLogs: (params = {}) => api.get('/settings/logs', { params }),
|
||||
|
||||
@@ -39,6 +39,13 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
const activityTimeline = ref(null)
|
||||
async function fetchActivityTimeline(params = {}) {
|
||||
const response = await downloadsApi.activityTimeline(params)
|
||||
activityTimeline.value = response.data
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function retryDownload(id) {
|
||||
return await downloadsApi.retry(id)
|
||||
}
|
||||
@@ -59,6 +66,7 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
downloads,
|
||||
recentActivity,
|
||||
stats,
|
||||
activityTimeline,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
@@ -66,6 +74,7 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
fetchDownloads,
|
||||
fetchRecentActivity,
|
||||
fetchStats,
|
||||
fetchActivityTimeline,
|
||||
retryDownload,
|
||||
retryFailedBulk,
|
||||
resetOrphaned,
|
||||
|
||||
+534
-169
@@ -1,52 +1,212 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- At-a-glance strip: activity, upcoming checks, system health -->
|
||||
<v-row>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text style="border-left: 4px solid rgb(var(--v-theme-primary)); padding-left: 20px">
|
||||
<div class="text-h3 font-weight-bold">{{ sourcesStore.sources.length }}</div>
|
||||
<div class="text-caption text-medium-emphasis">Total Sources</div>
|
||||
<!-- Activity (last 7 days) with sparkline -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card class="h-100">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" class="mr-2" color="primary">mdi-chart-line</v-icon>
|
||||
<div class="text-caption text-medium-emphasis text-uppercase">Activity · 7 days</div>
|
||||
<v-spacer />
|
||||
<v-chip
|
||||
v-if="liveActivity"
|
||||
size="x-small"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
class="live-chip"
|
||||
>
|
||||
<span class="live-dot mr-1" /> live
|
||||
</v-chip>
|
||||
</div>
|
||||
<ActivitySparkline
|
||||
:series="activitySeries"
|
||||
:height="54"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="d-flex align-center ga-4 text-caption">
|
||||
<div>
|
||||
<span class="text-h6 font-weight-bold text-success">
|
||||
{{ formatNumber(activityTotals.files) }}
|
||||
</span>
|
||||
<span class="text-medium-emphasis ml-1">new files</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-h6 font-weight-bold">
|
||||
{{ activityTotals.completed }}
|
||||
</span>
|
||||
<span class="text-medium-emphasis ml-1">runs</span>
|
||||
</div>
|
||||
<div v-if="activityTotals.failed">
|
||||
<span class="text-h6 font-weight-bold text-error">
|
||||
{{ activityTotals.failed }}
|
||||
</span>
|
||||
<span class="text-medium-emphasis ml-1">failed</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text style="border-left: 4px solid rgb(var(--v-theme-success)); padding-left: 20px">
|
||||
<div class="text-h3 font-weight-bold">{{ stats?.completed || 0 }}</div>
|
||||
<div class="text-caption text-medium-emphasis">Completed Downloads</div>
|
||||
<!-- Upcoming / In-flight -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card class="h-100">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" class="mr-2" color="info">mdi-timer-sand</v-icon>
|
||||
<div class="text-caption text-medium-emphasis text-uppercase">
|
||||
<span v-if="activeDownloadsCount">Running now</span>
|
||||
<span v-else>Next scheduled check</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeDownloadsCount" class="d-flex align-center">
|
||||
<div class="text-h5 font-weight-bold">
|
||||
{{ activeDownloadsCount }}
|
||||
<span class="text-body-2 text-medium-emphasis font-weight-regular">
|
||||
in flight
|
||||
</span>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-icon color="info" class="mdi-spin">mdi-loading</v-icon>
|
||||
</div>
|
||||
<div v-else-if="nextCheckCountdown" class="text-h5 font-weight-bold">
|
||||
{{ nextCheckCountdown }}
|
||||
<span class="text-body-2 text-medium-emphasis font-weight-regular ml-1">
|
||||
until next run
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="text-body-2 text-medium-emphasis">
|
||||
No sources scheduled
|
||||
</div>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<div class="text-caption text-medium-emphasis mb-1">
|
||||
Up next
|
||||
</div>
|
||||
<div v-if="upcomingSources.length" class="upcoming-list">
|
||||
<div
|
||||
v-for="source in upcomingSources"
|
||||
:key="source.id"
|
||||
class="d-flex align-center text-caption py-1"
|
||||
>
|
||||
<v-icon
|
||||
:color="getPlatformColor(source.platform)"
|
||||
size="x-small"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ getPlatformIcon(source.platform) }}
|
||||
</v-icon>
|
||||
<span class="upcoming-label">{{ getSourceLabel(source) }}</span>
|
||||
<v-spacer />
|
||||
<span class="text-medium-emphasis">
|
||||
{{ source.nextCheckLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-caption text-medium-emphasis">
|
||||
All caught up
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text style="border-left: 4px solid rgb(var(--v-theme-error)); padding-left: 20px">
|
||||
<div class="text-h3 font-weight-bold">{{ stats?.active_failed || 0 }}</div>
|
||||
<div class="text-caption text-medium-emphasis">Failed Downloads</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<!-- System health: credentials + disk -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card class="h-100">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" class="mr-2" color="warning">mdi-shield-check</v-icon>
|
||||
<div class="text-caption text-medium-emphasis text-uppercase">System</div>
|
||||
<v-spacer />
|
||||
<v-chip
|
||||
v-if="credentialAlerts.length"
|
||||
size="x-small"
|
||||
:color="credentialAlerts.some(a => a.severity === 'error') ? 'error' : 'warning'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ credentialAlerts.length }} alert{{ credentialAlerts.length !== 1 ? 's' : '' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text style="border-left: 4px solid rgb(var(--v-theme-warning)); padding-left: 20px">
|
||||
<div class="text-h3 font-weight-bold">{{ activeDownloadsCount }}</div>
|
||||
<div class="text-caption text-medium-emphasis">Active Downloads</div>
|
||||
<div class="text-caption text-medium-emphasis" v-if="stats?.queued || stats?.running">
|
||||
{{ stats?.queued || 0 }} queued · {{ stats?.running || 0 }} running
|
||||
<div class="text-caption text-medium-emphasis mb-1">Credentials</div>
|
||||
<div class="d-flex flex-wrap ga-1 mb-3">
|
||||
<v-chip
|
||||
v-for="platform in supportedPlatforms"
|
||||
:key="platform"
|
||||
:color="credentialChipColor(platform)"
|
||||
:variant="credentialsStore.hasPlatformCredentials(platform) ? 'tonal' : 'outlined'"
|
||||
size="x-small"
|
||||
:to="credentialsStore.hasPlatformCredentials(platform) ? undefined : '/credentials'"
|
||||
>
|
||||
<v-icon start size="x-small">{{ getPlatformIcon(platform) }}</v-icon>
|
||||
{{ platform }}
|
||||
<v-icon
|
||||
v-if="credentialWarningFor(platform)"
|
||||
end
|
||||
size="x-small"
|
||||
>
|
||||
{{ credentialWarningFor(platform).severity === 'error' ? 'mdi-alert-circle' : 'mdi-alert' }}
|
||||
</v-icon>
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="alert in credentialAlerts"
|
||||
:key="alert.platform"
|
||||
class="text-caption mb-1"
|
||||
:class="alert.severity === 'error' ? 'text-error' : 'text-warning'"
|
||||
>
|
||||
<v-icon size="x-small" class="mr-1">
|
||||
{{ alert.severity === 'error' ? 'mdi-alert-circle' : 'mdi-alert' }}
|
||||
</v-icon>
|
||||
{{ alert.message }}
|
||||
</div>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<div class="d-flex align-center mb-1">
|
||||
<div class="text-caption text-medium-emphasis">Disk</div>
|
||||
<v-spacer />
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
<template v-if="storageStats?.disk_free_formatted">
|
||||
{{ storageStats.disk_free_formatted }} free
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="storageStats?.disk_percent_used || 0"
|
||||
:color="diskBarColor"
|
||||
bg-color="grey-lighten-2"
|
||||
height="8"
|
||||
rounded
|
||||
/>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
<template v-if="storageStats?.disk_used_formatted">
|
||||
{{ storageStats.disk_used_formatted }} of {{ storageStats.disk_total_formatted }}
|
||||
used ({{ storageStats.disk_percent_used }}%)
|
||||
</template>
|
||||
<template v-else-if="storageStats?.total_size_formatted">
|
||||
Downloads: {{ storageStats.total_size_formatted }}
|
||||
<span v-if="storageStats?.file_count">
|
||||
({{ storageStats.file_count.toLocaleString() }} files)
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>Calculating…</template>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Quick Actions & Status -->
|
||||
<!-- Action bar -->
|
||||
<v-row class="mt-4">
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<!-- Row 1: Action buttons -->
|
||||
<v-card-text class="d-flex align-center ga-3 flex-wrap pb-3">
|
||||
<v-card-text class="d-flex align-center ga-3 flex-wrap">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-refresh"
|
||||
@@ -86,59 +246,27 @@
|
||||
</template>
|
||||
Reset Stuck ({{ stuckRunningCount }})
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- Row 2: Status info -->
|
||||
<v-card-text class="d-flex align-center ga-2 flex-wrap pt-3">
|
||||
<v-icon size="small">mdi-key</v-icon>
|
||||
<span class="text-body-2 text-medium-emphasis mr-1">Auth:</span>
|
||||
<v-chip
|
||||
v-for="platform in supportedPlatforms"
|
||||
:key="platform"
|
||||
:color="credentialsStore.hasPlatformCredentials(platform) ? 'success' : 'grey'"
|
||||
:variant="credentialsStore.hasPlatformCredentials(platform) ? 'tonal' : 'outlined'"
|
||||
size="x-small"
|
||||
:to="credentialsStore.hasPlatformCredentials(platform) ? undefined : '/credentials'"
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
:loading="manualRefreshing"
|
||||
@click="manualRefresh"
|
||||
title="Refresh now"
|
||||
>
|
||||
<v-icon start size="x-small">{{ getPlatformIcon(platform) }}</v-icon>
|
||||
{{ platform }}
|
||||
</v-chip>
|
||||
|
||||
<v-divider vertical class="mx-2" />
|
||||
|
||||
<div class="text-body-2 text-medium-emphasis d-flex align-center">
|
||||
<v-icon size="small" class="mr-1">mdi-harddisk</v-icon>
|
||||
Storage: {{ storageStats?.total_size_formatted || 'Calculating...' }}
|
||||
<span v-if="storageStats?.file_count" class="ml-2">
|
||||
({{ storageStats.file_count.toLocaleString() }} files)
|
||||
</span>
|
||||
<v-tooltip v-if="storageStats?.calculated_at" location="bottom">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props" size="x-small" class="ml-2">mdi-information-outline</v-icon>
|
||||
</template>
|
||||
<span>Updated: {{ formatDate(storageStats.calculated_at) }}</span>
|
||||
</v-tooltip>
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-1"
|
||||
:loading="refreshingStorage"
|
||||
@click="refreshStorageStats"
|
||||
title="Refresh storage stats"
|
||||
>
|
||||
<v-icon size="small">mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Updated {{ lastRefreshLabel }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Active + Recent Activity -->
|
||||
<v-row class="mt-4">
|
||||
<!-- Active Downloads (queued + running, auto-refresh) -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card class="h-100 d-flex flex-column">
|
||||
<v-card-title class="d-flex align-center">
|
||||
@@ -166,6 +294,7 @@
|
||||
v-for="dl in visibleActiveDownloads"
|
||||
:key="dl.id"
|
||||
:class="{ 'download-running': dl.status === 'running' }"
|
||||
@click="showDetails(dl)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="dl.status === 'running' ? 'info' : 'warning'" size="small">
|
||||
@@ -196,7 +325,6 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<v-col cols="12" md="8">
|
||||
<v-card class="h-100 d-flex flex-column">
|
||||
<v-card-title>
|
||||
@@ -208,7 +336,11 @@
|
||||
<v-card-text class="flex-grow-1">
|
||||
<v-list v-if="recentActivityGrouped.length">
|
||||
<template v-for="entry in visibleRecentActivity" :key="entry.key">
|
||||
<v-list-item v-if="entry.kind === 'single'">
|
||||
<v-list-item
|
||||
v-if="entry.kind === 'single'"
|
||||
link
|
||||
@click="showDetails(entry.download)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="getStatusColor(entry.download.status)">
|
||||
{{ getStatusIcon(entry.download.status) }}
|
||||
@@ -233,7 +365,11 @@
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-else>
|
||||
<v-list-item
|
||||
v-else
|
||||
link
|
||||
@click="showDetails(entry.latest)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="error">mdi-alert-circle</v-icon>
|
||||
</template>
|
||||
@@ -264,8 +400,8 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Sources needing attention -->
|
||||
<v-row class="mt-4">
|
||||
<!-- Sources needing attention -->
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
@@ -275,7 +411,6 @@
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- Platform Health bars -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<div class="text-caption text-medium-emphasis">Platform Health</div>
|
||||
@@ -297,18 +432,20 @@
|
||||
</v-icon>
|
||||
<div class="text-body-2 platform-label">{{ row.platform }}</div>
|
||||
<v-progress-linear
|
||||
:model-value="row.failing > 0 ? (row.failing / row.total) * 100 : 100"
|
||||
:color="row.failing > 0 ? 'error' : 'success'"
|
||||
:model-value="row.total > 0 ? ((row.total - row.failing) / row.total) * 100 : 0"
|
||||
color="success"
|
||||
bg-color="error"
|
||||
bg-opacity="1"
|
||||
height="12"
|
||||
rounded
|
||||
class="mx-3 flex-grow-1"
|
||||
>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ row.failing }} of {{ row.total }} {{ row.platform }} source{{ row.total !== 1 ? 's' : '' }} failing
|
||||
{{ row.total - row.failing }} of {{ row.total }} {{ row.platform }} source{{ row.total !== 1 ? 's' : '' }} healthy<template v-if="row.failing"> ({{ row.failing }} failing)</template>
|
||||
</v-tooltip>
|
||||
</v-progress-linear>
|
||||
<div class="text-caption text-medium-emphasis count-label">
|
||||
{{ row.failing }}/{{ row.total }}
|
||||
{{ row.total - row.failing }}/{{ row.total }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -321,7 +458,6 @@
|
||||
|
||||
<v-divider class="my-3" />
|
||||
|
||||
<!-- Failing Sources list -->
|
||||
<v-table v-if="failingSources.length" density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -362,6 +498,12 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<DownloadDetailsModal
|
||||
v-model="detailsDialog"
|
||||
:download="selectedDownload"
|
||||
@retry="handleRetryFromModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -372,7 +514,9 @@ import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useCredentialsStore } from '../stores/credentials'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { settingsApi, downloadsApi } from '../services/api'
|
||||
import { settingsApi, downloadsApi, credentialsApi } from '../services/api'
|
||||
import ActivitySparkline from '../components/ActivitySparkline.vue'
|
||||
import DownloadDetailsModal from '../components/DownloadDetailsModal.vue'
|
||||
|
||||
const sourcesStore = useSourcesStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
@@ -380,24 +524,36 @@ const credentialsStore = useCredentialsStore()
|
||||
const notifications = useNotificationStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// Supported platforms for credential status
|
||||
const supportedPlatforms = ['patreon', 'subscribestar', 'hentaifoundry', 'discord', 'pixiv', 'deviantart']
|
||||
|
||||
// State
|
||||
const checkingAll = ref(false)
|
||||
const retryingAll = ref(false)
|
||||
const resettingOrphaned = ref(false)
|
||||
const manualRefreshing = ref(false)
|
||||
const storageStats = ref(null)
|
||||
const activeDownloads = ref([])
|
||||
const loadingStats = ref(true)
|
||||
const loadingActivity = ref(true)
|
||||
const loadingStorage = ref(true)
|
||||
const refreshingStorage = ref(false)
|
||||
const detailsDialog = ref(false)
|
||||
const selectedDownload = ref(null)
|
||||
const credentialItems = ref([])
|
||||
// Tick every 30s so "X min until next run" and "Updated 2 min ago" stay fresh
|
||||
// without needing a fetch.
|
||||
const now = ref(new Date())
|
||||
const lastRefreshedAt = ref(null)
|
||||
let refreshInterval = null
|
||||
let tickInterval = null
|
||||
let visibilityHandler = null
|
||||
|
||||
// Computed
|
||||
const stats = computed(() => downloadsStore.stats)
|
||||
const recentActivity = computed(() => downloadsStore.recentActivity)
|
||||
const activityTimeline = computed(() => downloadsStore.activityTimeline)
|
||||
|
||||
const activitySeries = computed(() => activityTimeline.value?.series || [])
|
||||
const activityTotals = computed(() =>
|
||||
activityTimeline.value?.totals || { completed: 0, failed: 0, files: 0 }
|
||||
)
|
||||
const liveActivity = computed(() => activeDownloadsCount.value > 0)
|
||||
|
||||
const recentActivityGrouped = computed(() => {
|
||||
const singles = []
|
||||
@@ -432,85 +588,179 @@ const hiddenRecentCount = computed(() =>
|
||||
Math.max(0, recentActivityGrouped.value.length - DASHBOARD_LIST_LIMIT)
|
||||
)
|
||||
|
||||
// Read threshold from settings, default 5 if unset or invalid
|
||||
const failureThreshold = computed(() => {
|
||||
const n = Number(settingsStore.settings['dashboard.failure_threshold'])
|
||||
return Number.isFinite(n) && n >= 1 ? n : 5
|
||||
})
|
||||
|
||||
// Per-platform health for platforms with ≥1 enabled source
|
||||
const scheduleIntervalSeconds = computed(() => {
|
||||
const n = Number(settingsStore.settings['download.schedule_interval'])
|
||||
return Number.isFinite(n) && n > 0 ? n : 28800
|
||||
})
|
||||
|
||||
// Annotate enabled sources with next_check derived from last_check + interval.
|
||||
// Never-checked sources are treated as immediately due so they sort to the top.
|
||||
const sourcesWithSchedule = computed(() => {
|
||||
const nowTime = now.value.getTime()
|
||||
const intervalMs = scheduleIntervalSeconds.value * 1000
|
||||
return sourcesStore.sources
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => {
|
||||
const lastCheckMs = s.last_check ? new Date(s.last_check).getTime() : null
|
||||
const nextMs = lastCheckMs !== null ? lastCheckMs + intervalMs : nowTime
|
||||
return { ...s, nextCheckMs: nextMs, nextCheckLabel: formatCountdown(nextMs - nowTime) }
|
||||
})
|
||||
.sort((a, b) => a.nextCheckMs - b.nextCheckMs)
|
||||
})
|
||||
|
||||
const upcomingSources = computed(() => sourcesWithSchedule.value.slice(0, 4))
|
||||
|
||||
const nextCheckCountdown = computed(() => {
|
||||
const next = sourcesWithSchedule.value[0]
|
||||
if (!next) return null
|
||||
const delta = next.nextCheckMs - now.value.getTime()
|
||||
return formatCountdown(delta)
|
||||
})
|
||||
|
||||
const platformHealth = computed(() => {
|
||||
const enabled = sourcesStore.sources.filter(s => s.enabled)
|
||||
const enabled = sourcesStore.sources.filter((s) => s.enabled)
|
||||
return supportedPlatforms
|
||||
.map(platform => {
|
||||
const sources = enabled.filter(s => s.platform === platform)
|
||||
.map((platform) => {
|
||||
const sources = enabled.filter((s) => s.platform === platform)
|
||||
if (sources.length === 0) return null
|
||||
const failing = sources.filter(
|
||||
s => (s.error_count || 0) >= failureThreshold.value
|
||||
(s) => (s.error_count || 0) >= failureThreshold.value
|
||||
).length
|
||||
return { platform, total: sources.length, failing }
|
||||
})
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
// Sources currently in failing state, most-recent-check first
|
||||
const failingSources = computed(() => {
|
||||
return sourcesStore.sources
|
||||
.filter(s => s.enabled && (s.error_count || 0) >= failureThreshold.value)
|
||||
const failingSources = computed(() =>
|
||||
sourcesStore.sources
|
||||
.filter((s) => s.enabled && (s.error_count || 0) >= failureThreshold.value)
|
||||
.sort((a, b) => new Date(b.last_check || 0) - new Date(a.last_check || 0))
|
||||
})
|
||||
)
|
||||
|
||||
const totalFailingCount = computed(() => failingSources.value.length)
|
||||
|
||||
const failedCount = computed(() => stats.value?.active_failed || 0)
|
||||
const activeDownloadsCount = computed(() =>
|
||||
(stats.value?.queued || 0) + (stats.value?.running || 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)
|
||||
|
||||
// Credential alerts: "expiring soon" (warning) within 7 days, "expired" (error)
|
||||
// past now, or "missing" for platforms with enabled sources and no credential.
|
||||
const CREDENTIAL_WARN_DAYS = 7
|
||||
const credentialAlerts = computed(() => {
|
||||
const alerts = []
|
||||
const nowMs = now.value.getTime()
|
||||
const warnMs = nowMs + CREDENTIAL_WARN_DAYS * 86400000
|
||||
|
||||
// Expiration alerts from stored credentials
|
||||
for (const cred of credentialItems.value) {
|
||||
if (!cred.expires_at) continue
|
||||
const expMs = new Date(cred.expires_at).getTime()
|
||||
if (expMs <= nowMs) {
|
||||
alerts.push({
|
||||
platform: cred.platform,
|
||||
severity: 'error',
|
||||
message: `${cred.platform} credentials expired ${formatRelativeTime(cred.expires_at)}`,
|
||||
})
|
||||
} else if (expMs <= warnMs) {
|
||||
alerts.push({
|
||||
platform: cred.platform,
|
||||
severity: 'warning',
|
||||
message: `${cred.platform} credentials expire ${formatCountdown(expMs - nowMs)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// "Missing" alerts only for platforms that actually have enabled sources —
|
||||
// otherwise we'd nag about platforms the user doesn't use.
|
||||
const usedPlatforms = new Set(
|
||||
sourcesStore.sources.filter((s) => s.enabled).map((s) => s.platform)
|
||||
)
|
||||
for (const platform of usedPlatforms) {
|
||||
if (!credentialsStore.hasPlatformCredentials(platform)) {
|
||||
alerts.push({
|
||||
platform,
|
||||
severity: 'warning',
|
||||
message: `${platform} has enabled sources but no credentials stored`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return alerts
|
||||
})
|
||||
|
||||
function credentialWarningFor(platform) {
|
||||
return credentialAlerts.value.find((a) => a.platform === platform)
|
||||
}
|
||||
|
||||
function credentialChipColor(platform) {
|
||||
const alert = credentialWarningFor(platform)
|
||||
if (alert?.severity === 'error') return 'error'
|
||||
if (alert?.severity === 'warning') return 'warning'
|
||||
if (credentialsStore.hasPlatformCredentials(platform)) return 'success'
|
||||
return 'grey'
|
||||
}
|
||||
|
||||
const diskBarColor = computed(() => {
|
||||
const pct = storageStats.value?.disk_percent_used
|
||||
if (pct == null) return 'primary'
|
||||
if (pct >= 90) return 'error'
|
||||
if (pct >= 75) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const lastRefreshLabel = computed(() => {
|
||||
if (!lastRefreshedAt.value) return 'just now'
|
||||
// Force reactivity on `now` so the label recomputes as time passes.
|
||||
const _ = now.value
|
||||
return formatRelativeTime(lastRefreshedAt.value)
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Load data in background - DON'T await, let UI render immediately
|
||||
loadDashboardData()
|
||||
// Start auto-refresh for running downloads
|
||||
startAutoRefresh()
|
||||
startTick()
|
||||
attachVisibilityHandler()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
stopTick()
|
||||
detachVisibilityHandler()
|
||||
})
|
||||
|
||||
function loadDashboardData() {
|
||||
// Fire off all requests in parallel, don't block UI
|
||||
// Each section handles its own loading state
|
||||
sourcesStore.fetchSources().catch(e => console.error('Failed to fetch sources:', e))
|
||||
credentialsStore.fetchCredentials().catch(e => console.error('Failed to fetch credentials:', e))
|
||||
settingsStore.fetchSettings().catch(e => console.error('Failed to fetch settings:', e))
|
||||
sourcesStore.fetchSources().catch((e) => console.error('Failed to fetch sources:', e))
|
||||
credentialsStore.fetchCredentials().catch((e) => console.error('Failed to fetch credentials:', e))
|
||||
settingsStore.fetchSettings().catch((e) => console.error('Failed to fetch settings:', e))
|
||||
|
||||
downloadsStore.fetchStats()
|
||||
.catch(e => console.error('Failed to fetch stats:', e))
|
||||
.finally(() => loadingStats.value = false)
|
||||
|
||||
downloadsStore.fetchRecentActivity({ limit: 10 })
|
||||
.catch(e => console.error('Failed to fetch recent activity:', e))
|
||||
.finally(() => loadingActivity.value = false)
|
||||
downloadsStore.fetchStats().catch((e) => console.error('Failed to fetch stats:', e))
|
||||
downloadsStore
|
||||
.fetchRecentActivity({ limit: 10 })
|
||||
.catch((e) => console.error('Failed to fetch recent activity:', e))
|
||||
downloadsStore
|
||||
.fetchActivityTimeline({ days: 7 })
|
||||
.catch((e) => console.error('Failed to fetch activity timeline:', e))
|
||||
|
||||
fetchActiveDownloads()
|
||||
|
||||
// Storage stats can be slow - load independently
|
||||
fetchStorageStats()
|
||||
fetchCredentialItems()
|
||||
lastRefreshedAt.value = new Date()
|
||||
}
|
||||
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
// 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')
|
||||
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 active downloads:', e)
|
||||
@@ -518,50 +768,104 @@ async function fetchActiveDownloads() {
|
||||
}
|
||||
|
||||
async function fetchStorageStats() {
|
||||
loadingStorage.value = true
|
||||
try {
|
||||
const response = await settingsApi.get()
|
||||
storageStats.value = response.data.storage_stats
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch storage stats:', e)
|
||||
} finally {
|
||||
loadingStorage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStorageStats() {
|
||||
refreshingStorage.value = true
|
||||
async function fetchCredentialItems() {
|
||||
try {
|
||||
await settingsApi.refreshStorageStats()
|
||||
notifications.success('Storage stats refresh queued')
|
||||
// Wait a moment then fetch updated stats
|
||||
setTimeout(() => fetchStorageStats(), 3000)
|
||||
const response = await credentialsApi.list()
|
||||
credentialItems.value = response.data.items || []
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh storage stats:', e)
|
||||
notifications.error('Failed to refresh storage stats')
|
||||
} finally {
|
||||
refreshingStorage.value = false
|
||||
console.error('Failed to fetch credentials:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Two-speed polling: 5s when something's running, 30s otherwise. Tucked behind
|
||||
// the Page Visibility API so we don't hammer the API when the tab is hidden.
|
||||
function startAutoRefresh() {
|
||||
// Refresh active downloads every 5 seconds when there are active downloads
|
||||
refreshInterval = setInterval(async () => {
|
||||
if (activeDownloads.value.length > 0 || activeDownloadsCount.value > 0) {
|
||||
await fetchActiveDownloads()
|
||||
await downloadsStore.fetchStats()
|
||||
await downloadsStore.fetchRecentActivity({ limit: 10 })
|
||||
scheduleNextRefresh()
|
||||
}
|
||||
|
||||
function scheduleNextRefresh() {
|
||||
stopAutoRefresh()
|
||||
const delay = activeDownloadsCount.value > 0 ? 5000 : 30000
|
||||
refreshInterval = setTimeout(async () => {
|
||||
if (!document.hidden) {
|
||||
await refreshLive()
|
||||
}
|
||||
}, 5000)
|
||||
scheduleNextRefresh()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
clearTimeout(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLive() {
|
||||
try {
|
||||
await Promise.all([
|
||||
downloadsStore.fetchStats(),
|
||||
downloadsStore.fetchRecentActivity({ limit: 10 }),
|
||||
downloadsStore.fetchActivityTimeline({ days: 7 }),
|
||||
fetchActiveDownloads(),
|
||||
sourcesStore.fetchSources(),
|
||||
])
|
||||
lastRefreshedAt.value = new Date()
|
||||
} catch (e) {
|
||||
console.error('Auto-refresh failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function startTick() {
|
||||
tickInterval = setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
function stopTick() {
|
||||
if (tickInterval) {
|
||||
clearInterval(tickInterval)
|
||||
tickInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
function attachVisibilityHandler() {
|
||||
visibilityHandler = () => {
|
||||
if (!document.hidden) {
|
||||
// Tab just regained focus — pull a fresh snapshot immediately so the
|
||||
// user never sees stale data after a long break.
|
||||
refreshLive()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler)
|
||||
}
|
||||
|
||||
function detachVisibilityHandler() {
|
||||
if (visibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', visibilityHandler)
|
||||
visibilityHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
async function manualRefresh() {
|
||||
manualRefreshing.value = true
|
||||
try {
|
||||
await refreshLive()
|
||||
await fetchStorageStats()
|
||||
await fetchCredentialItems()
|
||||
} finally {
|
||||
manualRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshActive() {
|
||||
await fetchActiveDownloads()
|
||||
}
|
||||
@@ -569,19 +873,18 @@ async function refreshActive() {
|
||||
async function checkAllSources() {
|
||||
checkingAll.value = true
|
||||
try {
|
||||
const enabledSources = sourcesStore.sources.filter(s => s.enabled)
|
||||
const enabledSources = sourcesStore.sources.filter((s) => s.enabled)
|
||||
const results = await Promise.allSettled(
|
||||
enabledSources.map(source => sourcesStore.triggerCheck(source.id))
|
||||
enabledSources.map((source) => sourcesStore.triggerCheck(source.id))
|
||||
)
|
||||
const queued = results.filter(r => r.status === 'fulfilled').length
|
||||
const failed = results.filter(r => r.status === 'rejected').length
|
||||
const queued = results.filter((r) => r.status === 'fulfilled').length
|
||||
const failed = results.filter((r) => r.status === 'rejected').length
|
||||
if (failed > 0) {
|
||||
notifications.success(`Queued ${queued} sources (${failed} failed to queue)`)
|
||||
} else {
|
||||
notifications.success(`Queued checks for ${queued} sources`)
|
||||
}
|
||||
await fetchActiveDownloads()
|
||||
await downloadsStore.fetchStats()
|
||||
await refreshLive()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to check sources: ${error.message}`)
|
||||
} finally {
|
||||
@@ -606,8 +909,7 @@ async function retryAllFailed() {
|
||||
if (noSourceSkipped) parts.push(`${noSourceSkipped} orphaned, skipped`)
|
||||
notifications.success(parts.join(' · '))
|
||||
|
||||
await downloadsStore.fetchStats()
|
||||
await fetchActiveDownloads()
|
||||
await refreshLive()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to retry downloads: ${error.message}`)
|
||||
} finally {
|
||||
@@ -618,10 +920,9 @@ async function retryAllFailed() {
|
||||
async function resetOrphanedJobs() {
|
||||
resettingOrphaned.value = true
|
||||
try {
|
||||
const response = await downloadsApi.resetOrphaned(0) // 0 = reset all running
|
||||
const response = await downloadsApi.resetOrphaned(0)
|
||||
notifications.success(response.data.message || `Reset ${response.data.reset_count} stuck jobs`)
|
||||
await downloadsStore.fetchStats()
|
||||
await fetchActiveDownloads()
|
||||
await refreshLive()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to reset stuck jobs: ${error.message}`)
|
||||
} finally {
|
||||
@@ -629,6 +930,23 @@ async function resetOrphanedJobs() {
|
||||
}
|
||||
}
|
||||
|
||||
function showDetails(download) {
|
||||
selectedDownload.value = download
|
||||
detailsDialog.value = true
|
||||
}
|
||||
|
||||
async function handleRetryFromModal(download) {
|
||||
detailsDialog.value = false
|
||||
try {
|
||||
await downloadsStore.retryDownload(download.id)
|
||||
notifications.success('Download queued for retry')
|
||||
await refreshLive()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to retry: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Formatting helpers
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
completed: 'mdi-check-circle',
|
||||
@@ -685,19 +1003,47 @@ function formatDate(dateStr) {
|
||||
function formatRelativeTime(dateStr) {
|
||||
if (!dateStr) return 'Unknown'
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
const diffMs = now.value - date
|
||||
const past = diffMs >= 0
|
||||
const absMs = Math.abs(diffMs)
|
||||
const diffMins = Math.floor(absMs / 60000)
|
||||
const diffHours = Math.floor(absMs / 3600000)
|
||||
const diffDays = Math.floor(absMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins} min${diffMins !== 1 ? 's' : ''} ago`
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`
|
||||
if (diffMins < 1) return past ? 'just now' : 'soon'
|
||||
const suffix = past ? 'ago' : 'from now'
|
||||
if (diffMins < 60) return `${diffMins} min${diffMins !== 1 ? 's' : ''} ${suffix}`
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ${suffix}`
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ${suffix}`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
// Humanize a duration in ms as "in 12 min" / "in 3 hours" / "now" (or similar
|
||||
// past form). Past deltas read as "overdue".
|
||||
function formatCountdown(deltaMs) {
|
||||
if (deltaMs <= 0) {
|
||||
const overdueMins = Math.floor(-deltaMs / 60000)
|
||||
if (overdueMins < 1) return 'now'
|
||||
if (overdueMins < 60) return `overdue ${overdueMins}m`
|
||||
const h = Math.floor(overdueMins / 60)
|
||||
return `overdue ${h}h`
|
||||
}
|
||||
const mins = Math.floor(deltaMs / 60000)
|
||||
if (mins < 1) return 'now'
|
||||
if (mins < 60) return `in ${mins}m`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `in ${hours}h ${mins % 60}m`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `in ${days}d ${hours % 24}h`
|
||||
}
|
||||
|
||||
function formatNumber(n) {
|
||||
if (n == null) return '0'
|
||||
if (n < 1000) return String(n)
|
||||
if (n < 10000) return `${(n / 1000).toFixed(1)}k`
|
||||
return `${Math.round(n / 1000)}k`
|
||||
}
|
||||
|
||||
function truncate(str, length) {
|
||||
if (!str) return ''
|
||||
if (str.length <= length) return str
|
||||
@@ -708,7 +1054,6 @@ function getDownloadLabel(download) {
|
||||
if (download.subscription_name && download.platform) {
|
||||
return `${download.subscription_name}:${download.platform}`
|
||||
}
|
||||
// Fallback to truncated URL if source info not available
|
||||
return truncate(download.url, 35)
|
||||
}
|
||||
|
||||
@@ -721,8 +1066,7 @@ async function retrySource(source) {
|
||||
try {
|
||||
await sourcesStore.triggerCheck(source.id)
|
||||
notifications.success(`Queued check for ${getSourceLabel(source)}`)
|
||||
// Refresh recent activity after retrying
|
||||
await downloadsStore.fetchRecentActivity({ limit: 10 })
|
||||
await refreshLive()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to queue check: ${error.message}`)
|
||||
}
|
||||
@@ -737,7 +1081,7 @@ async function retrySource(source) {
|
||||
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.platform-label {
|
||||
@@ -758,4 +1102,25 @@ async function retrySource(source) {
|
||||
}
|
||||
.legend-swatch--ok { background-color: rgb(var(--v-theme-success)); }
|
||||
.legend-swatch--bad { background-color: rgb(var(--v-theme-error)); }
|
||||
|
||||
.upcoming-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--v-theme-success));
|
||||
animation: pulse-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(1.3); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
<template v-slot:item.error_type="{ item }">
|
||||
<v-chip
|
||||
v-if="item.error_type"
|
||||
color="error"
|
||||
:color="errorChipColor(item.error_type)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
@@ -274,161 +274,11 @@
|
||||
</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) }}</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>{{ runStats?.downloaded_count ?? selectedDownload.file_count }}</td>
|
||||
</tr>
|
||||
<tr v-if="runStats">
|
||||
<td class="font-weight-bold">Skipped</td>
|
||||
<td>{{ runStats.skipped_count }} <span class="text-grey text-caption">(already in archive)</span></td>
|
||||
</tr>
|
||||
<tr v-if="runStats && runStats.per_item_failures > 0">
|
||||
<td class="font-weight-bold">Per-item Failures</td>
|
||||
<td>{{ runStats.per_item_failures }} <span class="text-grey text-caption">(single items gallery-dl skipped over)</span></td>
|
||||
</tr>
|
||||
<tr v-if="runStats && runStats.warning_count > 0">
|
||||
<td class="font-weight-bold">Warnings</td>
|
||||
<td>{{ runStats.warning_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Duration</td>
|
||||
<td>{{ formatDuration(selectedDownload) }}</td>
|
||||
</tr>
|
||||
<tr v-if="runStats">
|
||||
<td class="font-weight-bold">Exit Code</td>
|
||||
<td>
|
||||
<code :class="runStats.exit_code === 0 ? 'text-success' : 'text-warning'">{{ runStats.exit_code }}</code>
|
||||
</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-model="openLogPanels" multiple v-if="selectedDownload.metadata">
|
||||
<v-expansion-panel v-if="selectedDownload.metadata.stderr_errors_warnings">
|
||||
<v-expansion-panel-title>
|
||||
Errors & Warnings
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="copyToClipboard(selectedDownload.metadata.stderr_errors_warnings, 'errors & warnings')"
|
||||
>
|
||||
<v-icon size="small">mdi-content-copy</v-icon>
|
||||
<v-tooltip activator="parent">Copy</v-tooltip>
|
||||
</v-btn>
|
||||
</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_errors_warnings }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel v-if="selectedDownload.metadata.stdout">
|
||||
<v-expansion-panel-title>
|
||||
Output Log (stdout)
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="copyToClipboard(selectedDownload.metadata.stdout, 'stdout')"
|
||||
>
|
||||
<v-icon size="small">mdi-content-copy</v-icon>
|
||||
<v-tooltip activator="parent">Copy</v-tooltip>
|
||||
</v-btn>
|
||||
</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>
|
||||
Verbose Log (stderr)
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="copyToClipboard(selectedDownload.metadata.stderr, 'verbose log')"
|
||||
>
|
||||
<v-icon size="small">mdi-content-copy</v-icon>
|
||||
<v-tooltip activator="parent">Copy</v-tooltip>
|
||||
</v-btn>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-text-field
|
||||
v-model="verboseLogFilter"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
placeholder="Filter lines (substring match)"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
class="mb-2"
|
||||
/>
|
||||
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ filteredVerboseLog || '(no matching lines)' }}</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>
|
||||
<DownloadDetailsModal
|
||||
v-model="detailsDialog"
|
||||
:download="selectedDownload"
|
||||
@retry="handleRetryFromModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -438,6 +288,7 @@ import { useRoute } from 'vue-router'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import DownloadDetailsModal from '../components/DownloadDetailsModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
@@ -453,8 +304,6 @@ const filterExcludeSuperseded = ref(true)
|
||||
const detailsDialog = ref(false)
|
||||
const selectedDownload = ref(null)
|
||||
const retryingIds = ref([])
|
||||
const verboseLogFilter = ref('')
|
||||
const openLogPanels = ref([])
|
||||
let refreshInterval = null
|
||||
|
||||
const statusOptions = [
|
||||
@@ -645,6 +494,12 @@ function formatErrorType(errorType) {
|
||||
return errorType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
|
||||
function errorChipColor(errorType) {
|
||||
if (errorType === 'tier_limited') return 'warning'
|
||||
if (errorType === 'no_new_content') return 'info'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function truncateUrl(url) {
|
||||
if (!url) return ''
|
||||
if (url.length <= 40) return url
|
||||
@@ -671,34 +526,9 @@ function getSourceLabel(item) {
|
||||
return `Source #${sourceId}`
|
||||
}
|
||||
|
||||
const runStats = computed(() => selectedDownload.value?.metadata?.run_stats || null)
|
||||
|
||||
const filteredVerboseLog = computed(() => {
|
||||
const stderr = selectedDownload.value?.metadata?.stderr || ''
|
||||
const query = verboseLogFilter.value.trim().toLowerCase()
|
||||
if (!query) return stderr
|
||||
return stderr
|
||||
.split('\n')
|
||||
.filter(line => line.toLowerCase().includes(query))
|
||||
.join('\n')
|
||||
})
|
||||
|
||||
function showDetails(download) {
|
||||
selectedDownload.value = download
|
||||
detailsDialog.value = true
|
||||
verboseLogFilter.value = ''
|
||||
// Open the errors/warnings panel by default when there's anything worth seeing.
|
||||
openLogPanels.value = download?.metadata?.stderr_errors_warnings ? [0] : []
|
||||
}
|
||||
|
||||
async function copyToClipboard(text, label = 'content') {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
notifications.success(`Copied ${label} to clipboard`)
|
||||
} catch (err) {
|
||||
notifications.error(`Copy failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function retryDownload(download) {
|
||||
@@ -713,6 +543,11 @@ async function retryDownload(download) {
|
||||
retryingIds.value = retryingIds.value.filter(id => id !== download.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetryFromModal(download) {
|
||||
detailsDialog.value = false
|
||||
await retryDownload(download)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user