7680f03560
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
644 lines
22 KiB
Vue
644 lines
22 KiB
Vue
<template>
|
|
<div>
|
|
<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>
|
|
</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>
|
|
</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>
|
|
|
|
<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>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Quick Actions & Status -->
|
|
<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-btn
|
|
color="primary"
|
|
prepend-icon="mdi-refresh"
|
|
:loading="checkingAll"
|
|
@click="checkAllSources"
|
|
>
|
|
Check All Sources
|
|
</v-btn>
|
|
<v-btn
|
|
color="warning"
|
|
prepend-icon="mdi-replay"
|
|
:loading="retryingAll"
|
|
:disabled="!failedCount"
|
|
@click="retryAllFailed"
|
|
>
|
|
Retry All Failed ({{ failedCount }})
|
|
</v-btn>
|
|
<v-btn
|
|
color="secondary"
|
|
variant="outlined"
|
|
prepend-icon="mdi-broom"
|
|
:loading="resettingOrphaned"
|
|
:disabled="!stuckRunningCount"
|
|
@click="resetOrphanedJobs"
|
|
>
|
|
Reset Stuck ({{ stuckRunningCount }})
|
|
</v-btn>
|
|
</v-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-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>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-row class="mt-4">
|
|
<!-- Active Downloads (queued + running, auto-refresh) -->
|
|
<v-col cols="12" md="4">
|
|
<v-card>
|
|
<v-card-title class="d-flex align-center">
|
|
<v-icon class="mr-2" :class="{ 'mdi-spin': activeDownloads.length }">
|
|
{{ activeDownloads.length ? 'mdi-loading' : 'mdi-download' }}
|
|
</v-icon>
|
|
Active
|
|
<v-chip v-if="activeDownloads.length" size="small" color="info" class="ml-2">
|
|
{{ activeDownloads.length }}
|
|
</v-chip>
|
|
<v-spacer />
|
|
<v-btn
|
|
v-if="activeDownloads.length"
|
|
icon
|
|
size="x-small"
|
|
variant="text"
|
|
@click="refreshActive"
|
|
>
|
|
<v-icon>mdi-refresh</v-icon>
|
|
</v-btn>
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<v-list v-if="activeDownloads.length" density="compact">
|
|
<v-list-item
|
|
v-for="dl in activeDownloads"
|
|
:key="dl.id"
|
|
:class="{ 'download-running': dl.status === 'running' }"
|
|
>
|
|
<template v-slot:prepend>
|
|
<v-icon :color="dl.status === 'running' ? 'info' : 'warning'" size="small">
|
|
{{ dl.status === 'running' ? 'mdi-loading mdi-spin' : 'mdi-clock-outline' }}
|
|
</v-icon>
|
|
</template>
|
|
<v-list-item-title class="text-body-2">
|
|
{{ getDownloadLabel(dl) }}
|
|
</v-list-item-title>
|
|
<v-list-item-subtitle>
|
|
<v-chip size="x-small" :color="dl.status === 'running' ? 'info' : 'warning'" variant="tonal">
|
|
{{ dl.status }}
|
|
</v-chip>
|
|
</v-list-item-subtitle>
|
|
</v-list-item>
|
|
</v-list>
|
|
<div v-else class="text-center py-8">
|
|
<v-icon size="48" color="secondary" :style="{ opacity: 0.5 }">mdi-check-circle-outline</v-icon>
|
|
<div class="text-body-1 mt-2">No active downloads</div>
|
|
<div class="text-caption text-medium-emphasis mt-1">All sources are idle</div>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
|
|
<!-- Recent Activity -->
|
|
<v-col cols="12" md="8">
|
|
<v-card>
|
|
<v-card-title>
|
|
Recent Activity
|
|
<v-chip v-if="recentActivity.length" size="small" class="ml-2" variant="tonal">
|
|
Last 7 days
|
|
</v-chip>
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<v-list v-if="recentActivity.length">
|
|
<v-list-item
|
|
v-for="download in recentActivity"
|
|
:key="download.id"
|
|
>
|
|
<template v-slot:prepend>
|
|
<v-icon :color="getStatusColor(download.status)">
|
|
{{ getStatusIcon(download.status) }}
|
|
</v-icon>
|
|
</template>
|
|
<v-list-item-title>
|
|
{{ getDownloadLabel(download) }}
|
|
</v-list-item-title>
|
|
<v-list-item-subtitle>
|
|
{{ formatRelativeTime(download.created_at) }}
|
|
<span v-if="download.file_count > 0" class="text-success ml-2">
|
|
{{ download.file_count }} new file{{ download.file_count !== 1 ? 's' : '' }}
|
|
</span>
|
|
</v-list-item-subtitle>
|
|
<template v-slot:append>
|
|
<v-chip
|
|
:color="getStatusColor(download.status)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ download.status === 'completed' ? 'new content' : download.status }}
|
|
</v-chip>
|
|
</template>
|
|
</v-list-item>
|
|
</v-list>
|
|
<div v-else class="text-center py-8">
|
|
<v-icon size="48" color="secondary" :style="{ opacity: 0.5 }">mdi-history</v-icon>
|
|
<div class="text-body-1 mt-2">No recent activity</div>
|
|
<div class="text-caption text-medium-emphasis mt-1">Failures and new downloads appear here</div>
|
|
</div>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-btn variant="text" to="/downloads">View All Downloads</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-row class="mt-4">
|
|
<!-- Sources needing attention -->
|
|
<v-col cols="12">
|
|
<v-card>
|
|
<v-card-title>
|
|
Sources Needing Attention
|
|
<v-chip class="ml-2" color="error" size="small" v-if="sourcesWithErrors.length">
|
|
{{ sourcesWithErrors.length }}
|
|
</v-chip>
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<v-table v-if="sourcesWithErrors.length">
|
|
<thead>
|
|
<tr>
|
|
<th>Source</th>
|
|
<th>Last Error</th>
|
|
<th>Last Check</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="source in sourcesWithErrors" :key="source.id">
|
|
<td>{{ getSourceLabel(source) }}</td>
|
|
<td>
|
|
<v-chip color="error" size="small">
|
|
{{ source.last_error_type || 'failed' }}
|
|
</v-chip>
|
|
</td>
|
|
<td>{{ formatDate(source.last_check) }}</td>
|
|
<td>
|
|
<v-btn
|
|
size="small"
|
|
variant="text"
|
|
color="primary"
|
|
@click="retrySource(source)"
|
|
>
|
|
Retry
|
|
</v-btn>
|
|
<v-btn
|
|
size="small"
|
|
variant="text"
|
|
to="/subscriptions"
|
|
>
|
|
View
|
|
<v-tooltip activator="parent">View in Subscriptions</v-tooltip>
|
|
</v-btn>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
<div v-else class="text-center py-8">
|
|
<v-icon size="48" color="secondary" :style="{ opacity: 0.5 }">mdi-check-all</v-icon>
|
|
<div class="text-body-1 mt-2">All sources healthy</div>
|
|
<div class="text-caption text-medium-emphasis mt-1">No failures in the last 7 days</div>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import { useSourcesStore } from '../stores/sources'
|
|
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'
|
|
|
|
const sourcesStore = useSourcesStore()
|
|
const downloadsStore = useDownloadsStore()
|
|
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 storageStats = ref(null)
|
|
const activeDownloads = ref([])
|
|
const loadingStats = ref(true)
|
|
const loadingActivity = ref(true)
|
|
const loadingStorage = ref(true)
|
|
const refreshingStorage = ref(false)
|
|
let refreshInterval = null
|
|
|
|
// Computed
|
|
const stats = computed(() => downloadsStore.stats)
|
|
const recentActivity = computed(() => downloadsStore.recentActivity)
|
|
|
|
// Read threshold from settings, default 5 if unset
|
|
const failureThreshold = computed(() =>
|
|
settingsStore.settings['dashboard.failure_threshold'] ?? 5
|
|
)
|
|
|
|
// Per-platform health for platforms with ≥1 enabled source
|
|
const platformHealth = computed(() => {
|
|
const enabled = sourcesStore.sources.filter(s => s.enabled)
|
|
return supportedPlatforms
|
|
.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
|
|
).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)
|
|
.sort((a, b) => new Date(b.last_check || 0) - new Date(a.last_check || 0))
|
|
})
|
|
|
|
const totalFailingCount = computed(() => failingSources.value.length)
|
|
|
|
// Sources with recent failures - derived from recent activity
|
|
// Only shows sources where a recent download failed (not cumulative error_count)
|
|
const sourcesWithErrors = computed(() => {
|
|
const failedDownloads = recentActivity.value.filter(d => d.status === 'failed')
|
|
// Group by source_id and keep unique sources with their most recent failure
|
|
const sourceMap = new Map()
|
|
for (const dl of failedDownloads) {
|
|
if (dl.source_id && !sourceMap.has(dl.source_id)) {
|
|
sourceMap.set(dl.source_id, {
|
|
id: dl.source_id,
|
|
subscription_name: dl.subscription_name,
|
|
platform: dl.platform,
|
|
last_error_type: dl.error_type,
|
|
last_check: dl.created_at,
|
|
})
|
|
}
|
|
}
|
|
return Array.from(sourceMap.values()).slice(0, 5)
|
|
})
|
|
|
|
const failedCount = computed(() => stats.value?.active_failed || 0)
|
|
const activeDownloadsCount = computed(() =>
|
|
(stats.value?.queued || 0) + (stats.value?.running || 0)
|
|
)
|
|
// Running jobs that might be stuck (shows count for UI, actual stuck detection happens server-side)
|
|
const stuckRunningCount = computed(() => stats.value?.running || 0)
|
|
|
|
onMounted(() => {
|
|
// Load data in background - DON'T await, let UI render immediately
|
|
loadDashboardData()
|
|
// Start auto-refresh for running downloads
|
|
startAutoRefresh()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
stopAutoRefresh()
|
|
})
|
|
|
|
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))
|
|
|
|
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)
|
|
|
|
fetchActiveDownloads()
|
|
|
|
// Storage stats can be slow - load independently
|
|
fetchStorageStats()
|
|
}
|
|
|
|
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')
|
|
activeDownloads.value = [...running, ...queued]
|
|
} catch (e) {
|
|
console.error('Failed to fetch active downloads:', e)
|
|
}
|
|
}
|
|
|
|
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
|
|
try {
|
|
await settingsApi.refreshStorageStats()
|
|
notifications.success('Storage stats refresh queued')
|
|
// Wait a moment then fetch updated stats
|
|
setTimeout(() => fetchStorageStats(), 3000)
|
|
} catch (e) {
|
|
console.error('Failed to refresh storage stats:', e)
|
|
notifications.error('Failed to refresh storage stats')
|
|
} finally {
|
|
refreshingStorage.value = false
|
|
}
|
|
}
|
|
|
|
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 })
|
|
}
|
|
}, 5000)
|
|
}
|
|
|
|
function stopAutoRefresh() {
|
|
if (refreshInterval) {
|
|
clearInterval(refreshInterval)
|
|
refreshInterval = null
|
|
}
|
|
}
|
|
|
|
async function refreshActive() {
|
|
await fetchActiveDownloads()
|
|
}
|
|
|
|
async function checkAllSources() {
|
|
checkingAll.value = true
|
|
try {
|
|
const enabledSources = sourcesStore.sources.filter(s => s.enabled)
|
|
const results = await Promise.allSettled(
|
|
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
|
|
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()
|
|
} catch (error) {
|
|
notifications.error(`Failed to check sources: ${error.message}`)
|
|
} finally {
|
|
checkingAll.value = false
|
|
}
|
|
}
|
|
|
|
async function retryAllFailed() {
|
|
retryingAll.value = true
|
|
try {
|
|
const response = await downloadsStore.fetchDownloads({ status: 'failed', per_page: 100, exclude_superseded: true })
|
|
const failed = response.items
|
|
let retried = 0
|
|
for (const dl of failed) {
|
|
try {
|
|
await downloadsStore.retryDownload(dl.id)
|
|
retried++
|
|
} catch (e) {
|
|
console.error(`Failed to retry ${dl.id}:`, e)
|
|
}
|
|
}
|
|
notifications.success(`Queued retry for ${retried} downloads`)
|
|
await downloadsStore.fetchStats()
|
|
await fetchActiveDownloads()
|
|
} catch (error) {
|
|
notifications.error(`Failed to retry downloads: ${error.message}`)
|
|
} finally {
|
|
retryingAll.value = false
|
|
}
|
|
}
|
|
|
|
async function resetOrphanedJobs() {
|
|
resettingOrphaned.value = true
|
|
try {
|
|
const response = await downloadsApi.resetOrphaned(0) // 0 = reset all running
|
|
notifications.success(response.data.message || `Reset ${response.data.reset_count} stuck jobs`)
|
|
await downloadsStore.fetchStats()
|
|
await fetchActiveDownloads()
|
|
} catch (error) {
|
|
notifications.error(`Failed to reset stuck jobs: ${error.message}`)
|
|
} finally {
|
|
resettingOrphaned.value = false
|
|
}
|
|
}
|
|
|
|
function getStatusIcon(status) {
|
|
const icons = {
|
|
completed: 'mdi-check-circle',
|
|
failed: 'mdi-alert-circle',
|
|
running: 'mdi-loading mdi-spin',
|
|
queued: 'mdi-clock-outline',
|
|
pending: 'mdi-clock-outline',
|
|
skipped: 'mdi-skip-next',
|
|
}
|
|
return icons[status] || 'mdi-help-circle'
|
|
}
|
|
|
|
function getPlatformIcon(platform) {
|
|
const icons = {
|
|
patreon: 'mdi-patreon',
|
|
subscribestar: 'mdi-star',
|
|
hentaifoundry: 'mdi-palette',
|
|
discord: 'mdi-discord',
|
|
pixiv: 'mdi-alpha-p-box',
|
|
deviantart: 'mdi-deviantart',
|
|
}
|
|
return icons[platform] || 'mdi-web'
|
|
}
|
|
|
|
function getStatusColor(status) {
|
|
const colors = {
|
|
completed: 'success',
|
|
failed: 'error',
|
|
running: 'info',
|
|
queued: 'warning',
|
|
pending: 'warning',
|
|
skipped: 'grey',
|
|
}
|
|
return colors[status] || 'grey'
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return 'Never'
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
|
|
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)
|
|
|
|
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`
|
|
return date.toLocaleDateString()
|
|
}
|
|
|
|
function truncate(str, length) {
|
|
if (!str) return ''
|
|
if (str.length <= length) return str
|
|
return str.substring(0, length) + '...'
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
function getSourceLabel(source) {
|
|
const name = source.subscription_name || source.subscription?.name || 'Unknown'
|
|
return `${name}:${source.platform}`
|
|
}
|
|
|
|
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 })
|
|
} catch (error) {
|
|
notifications.error(`Failed to queue check: ${error.message}`)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.download-running {
|
|
border-left: 3px solid rgb(var(--v-theme-primary));
|
|
animation: pulse-border 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse-border {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.4; }
|
|
}
|
|
</style>
|