- {{ stats?.queued || 0 }} queued · {{ stats?.running || 0 }} running
+
Credentials
+
+
+ {{ getPlatformIcon(platform) }}
+ {{ platform }}
+
+ {{ credentialWarningFor(platform).severity === 'error' ? 'mdi-alert-circle' : 'mdi-alert' }}
+
+
+
+
+
+
+ {{ alert.severity === 'error' ? 'mdi-alert-circle' : 'mdi-alert' }}
+
+ {{ alert.message }}
+
+
+
+
+
+
Disk
+
+
+
+ {{ storageStats.disk_free_formatted }} free
+
+ —
+
+
+
+
+
+ {{ storageStats.disk_used_formatted }} of {{ storageStats.disk_total_formatted }}
+ used ({{ storageStats.disk_percent_used }}%)
+
+
+ Downloads: {{ storageStats.total_size_formatted }}
+
+ ({{ storageStats.file_count.toLocaleString() }} files)
+
+
+ Calculating…
-
+
-
-
+
Reset Stuck ({{ stuckRunningCount }})
-
-
-
-
-
-
- mdi-key
- Auth:
-
+
- {{ getPlatformIcon(platform) }}
- {{ platform }}
-
-
-
-
-
-
mdi-harddisk
- Storage: {{ storageStats?.total_size_formatted || 'Calculating...' }}
-
- ({{ storageStats.file_count.toLocaleString() }} files)
-
-
-
- mdi-information-outline
-
- Updated: {{ formatDate(storageStats.calculated_at) }}
-
-
- mdi-refresh
-
+
mdi-refresh
+
+
+ Updated {{ lastRefreshLabel }}
+
-
@@ -166,6 +294,7 @@
v-for="dl in visibleActiveDownloads"
:key="dl.id"
:class="{ 'download-running': dl.status === 'running' }"
+ @click="showDetails(dl)"
>
@@ -196,7 +325,6 @@
-
@@ -208,7 +336,11 @@
-
+
{{ getStatusIcon(entry.download.status) }}
@@ -233,7 +365,11 @@
-
+
mdi-alert-circle
@@ -264,8 +400,8 @@
+
-
@@ -275,7 +411,6 @@
-
Platform Health
@@ -297,18 +432,20 @@
{{ row.platform }}
- {{ 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 ({{ row.failing }} failing)
- {{ row.failing }}/{{ row.total }}
+ {{ row.total - row.failing }}/{{ row.total }}
-
@@ -362,6 +498,12 @@
+
+
@@ -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); }
+}
diff --git a/frontend/src/views/Downloads.vue b/frontend/src/views/Downloads.vue
index de508d0..5c4a2fa 100644
--- a/frontend/src/views/Downloads.vue
+++ b/frontend/src/views/Downloads.vue
@@ -217,7 +217,7 @@
@@ -274,161 +274,11 @@
-
-
-
-
- Download Details
-
- {{ selectedDownload.status }}
-
-
-
-
-
-
-
- | Source |
- {{ getSourceLabel(selectedDownload) }} |
-
-
- | URL |
- {{ selectedDownload.url }} |
-
-
- | Files Downloaded |
- {{ runStats?.downloaded_count ?? selectedDownload.file_count }} |
-
-
- | Skipped |
- {{ runStats.skipped_count }} (already in archive) |
-
-
- | Per-item Failures |
- {{ runStats.per_item_failures }} (single items gallery-dl skipped over) |
-
-
- | Warnings |
- {{ runStats.warning_count }} |
-
-
- | Duration |
- {{ formatDuration(selectedDownload) }} |
-
-
- | Exit Code |
-
- {{ runStats.exit_code }}
- |
-
-
- | Created |
- {{ formatDate(selectedDownload.created_at) }} |
-
-
- | Started |
- {{ formatDate(selectedDownload.started_at) }} |
-
-
- | Completed |
- {{ formatDate(selectedDownload.completed_at) }} |
-
-
- | Error Type |
- {{ formatErrorType(selectedDownload.error_type) }} |
-
-
- | Error Message |
- {{ selectedDownload.error_message }} |
-
-
-
-
-
-
-
- Errors & Warnings
-
- mdi-content-copy
- Copy
-
-
-
- {{ selectedDownload.metadata.stderr_errors_warnings }}
-
-
-
-
- Output Log (stdout)
-
- mdi-content-copy
- Copy
-
-
-
- {{ selectedDownload.metadata.stdout }}
-
-
-
-
- Verbose Log (stderr)
-
- mdi-content-copy
- Copy
-
-
-
-
- {{ filteredVerboseLog || '(no matching lines)' }}
-
-
-
-
-
-
-
-
- Retry Download
-
- Close
-
-
-
+
@@ -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)
+}