This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
GallerySubscriber/frontend/src/views/Dashboard.vue
T

661 lines
23 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="totalFailingCount">
{{ totalFailingCount }}
</v-chip>
</v-card-title>
<v-card-text>
<!-- Platform Health bars -->
<div class="mb-4">
<div class="text-caption text-medium-emphasis mb-2">Platform Health</div>
<div
v-for="row in platformHealth"
:key="row.platform"
class="d-flex align-center mb-2"
>
<v-icon :color="getPlatformColor(row.platform)" size="small" class="mr-2">
{{ getPlatformIcon(row.platform) }}
</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'"
height="12"
rounded
class="mx-3 flex-grow-1"
/>
<div class="text-caption text-medium-emphasis count-label">
{{ row.failing }}/{{ row.total }}
</div>
</div>
<div
v-if="!platformHealth.length"
class="text-caption text-medium-emphasis text-center py-2"
>
No enabled sources
</div>
</div>
<v-divider class="my-3" />
<!-- Failing Sources list -->
<v-table v-if="failingSources.length" density="compact">
<thead>
<tr>
<th>Source</th>
<th>Consecutive failures</th>
<th>Last check</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="source in failingSources" :key="source.id">
<td>{{ getSourceLabel(source) }}</td>
<td>
<v-chip color="error" size="small">{{ source.error_count }}</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-caption text-medium-emphasis text-center py-3">
No sources in failing state
</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)
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; }
}
.platform-label {
min-width: 110px;
text-transform: capitalize;
}
.count-label {
min-width: 50px;
text-align: right;
}
</style>