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:
2026-04-20 19:32:59 -04:00
parent 86bf43c80a
commit 851df294f2
9 changed files with 1085 additions and 357 deletions
@@ -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 &amp; Warnings
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
class="ml-2"
@click.stop="copyToClipboard(download.metadata.stderr_errors_warnings, 'errors &amp; 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>
+2
View File
@@ -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 }),
+9
View File
@@ -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
View File
@@ -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>
+18 -183
View File
@@ -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 &amp; Warnings
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
class="ml-2"
@click.stop="copyToClipboard(selectedDownload.metadata.stderr_errors_warnings, 'errors &amp; 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>