851df294f2
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>
1127 lines
38 KiB
Vue
1127 lines
38 KiB
Vue
<template>
|
||
<div>
|
||
<!-- At-a-glance strip: activity, upcoming checks, system health -->
|
||
<v-row>
|
||
<!-- 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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- 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>
|
||
|
||
<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>
|
||
|
||
<!-- Action bar -->
|
||
<v-row class="mt-4">
|
||
<v-col cols="12">
|
||
<v-card>
|
||
<v-card-text class="d-flex align-center ga-3 flex-wrap">
|
||
<v-btn
|
||
color="primary"
|
||
prepend-icon="mdi-refresh"
|
||
:loading="checkingAll"
|
||
@click="checkAllSources"
|
||
>
|
||
<template #loader>
|
||
<v-progress-circular indeterminate size="18" width="2" class="mr-2" />
|
||
Checking…
|
||
</template>
|
||
Check All Sources
|
||
</v-btn>
|
||
<v-btn
|
||
color="warning"
|
||
prepend-icon="mdi-replay"
|
||
:loading="retryingAll"
|
||
:disabled="!failedCount"
|
||
@click="retryAllFailed"
|
||
>
|
||
<template #loader>
|
||
<v-progress-circular indeterminate size="18" width="2" class="mr-2" />
|
||
Retrying…
|
||
</template>
|
||
Retry All Failed
|
||
</v-btn>
|
||
<v-btn
|
||
color="secondary"
|
||
variant="outlined"
|
||
prepend-icon="mdi-broom"
|
||
:loading="resettingOrphaned"
|
||
:disabled="!stuckRunningCount"
|
||
@click="resetOrphanedJobs"
|
||
>
|
||
<template #loader>
|
||
<v-progress-circular indeterminate size="18" width="2" class="mr-2" />
|
||
Resetting…
|
||
</template>
|
||
Reset Stuck ({{ stuckRunningCount }})
|
||
</v-btn>
|
||
<v-spacer />
|
||
<v-btn
|
||
icon
|
||
size="small"
|
||
variant="text"
|
||
:loading="manualRefreshing"
|
||
@click="manualRefresh"
|
||
title="Refresh now"
|
||
>
|
||
<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">
|
||
<v-col cols="12" md="4">
|
||
<v-card class="h-100 d-flex flex-column">
|
||
<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 class="flex-grow-1">
|
||
<v-list v-if="activeDownloads.length" density="compact">
|
||
<v-list-item
|
||
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">
|
||
{{ 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-actions>
|
||
<v-btn variant="text" :to="hiddenActiveCount ? '/downloads?status=queued' : '/downloads'">
|
||
{{ hiddenActiveCount ? `View all downloads (+${hiddenActiveCount} more queued)` : 'View all downloads' }}
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-col>
|
||
|
||
<v-col cols="12" md="8">
|
||
<v-card class="h-100 d-flex flex-column">
|
||
<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 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'"
|
||
link
|
||
@click="showDetails(entry.download)"
|
||
>
|
||
<template v-slot:prepend>
|
||
<v-icon :color="getStatusColor(entry.download.status)">
|
||
{{ getStatusIcon(entry.download.status) }}
|
||
</v-icon>
|
||
</template>
|
||
<v-list-item-title>
|
||
{{ getDownloadLabel(entry.download) }}
|
||
</v-list-item-title>
|
||
<v-list-item-subtitle>
|
||
{{ formatRelativeTime(entry.download.created_at) }}
|
||
<span v-if="entry.download.file_count > 0" class="text-success ml-2">
|
||
{{ entry.download.file_count }} new file{{ entry.download.file_count !== 1 ? 's' : '' }}
|
||
</span>
|
||
</v-list-item-subtitle>
|
||
<template v-slot:append>
|
||
<v-chip
|
||
:color="getStatusColor(entry.download.status)"
|
||
size="small"
|
||
variant="tonal"
|
||
>
|
||
{{ entry.download.status === 'completed' ? 'new content' : entry.download.status }}
|
||
</v-chip>
|
||
</template>
|
||
</v-list-item>
|
||
<v-list-item
|
||
v-else
|
||
link
|
||
@click="showDetails(entry.latest)"
|
||
>
|
||
<template v-slot:prepend>
|
||
<v-icon color="error">mdi-alert-circle</v-icon>
|
||
</template>
|
||
<v-list-item-title>{{ entry.label }}</v-list-item-title>
|
||
<v-list-item-subtitle>
|
||
{{ entry.items.length }} failure{{ entry.items.length !== 1 ? 's' : '' }} · latest {{ formatRelativeTime(entry.latest.created_at) }}
|
||
</v-list-item-subtitle>
|
||
<template v-slot:append>
|
||
<v-chip color="error" size="small" variant="tonal">
|
||
{{ entry.items.length }}× failed
|
||
</v-chip>
|
||
</template>
|
||
</v-list-item>
|
||
</template>
|
||
</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">
|
||
{{ hiddenRecentCount ? `View all downloads (+${hiddenRecentCount} more)` : 'View all downloads' }}
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<!-- Sources needing attention -->
|
||
<v-row class="mt-4">
|
||
<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>
|
||
<div class="mb-4">
|
||
<div class="d-flex align-center mb-2">
|
||
<div class="text-caption text-medium-emphasis">Platform Health</div>
|
||
<v-spacer />
|
||
<div class="d-flex align-center text-caption text-medium-emphasis">
|
||
<span class="legend-swatch legend-swatch--ok mr-1" />
|
||
<span class="mr-3">healthy</span>
|
||
<span class="legend-swatch legend-swatch--bad mr-1" />
|
||
<span>failing</span>
|
||
</div>
|
||
</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.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.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.total - 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" />
|
||
|
||
<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><span :title="formatDate(source.last_check)">{{ source.last_check ? formatRelativeTime(source.last_check) : 'Never' }}</span></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>
|
||
|
||
<DownloadDetailsModal
|
||
v-model="detailsDialog"
|
||
:download="selectedDownload"
|
||
@retry="handleRetryFromModal"
|
||
/>
|
||
</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, credentialsApi } from '../services/api'
|
||
import ActivitySparkline from '../components/ActivitySparkline.vue'
|
||
import DownloadDetailsModal from '../components/DownloadDetailsModal.vue'
|
||
|
||
const sourcesStore = useSourcesStore()
|
||
const downloadsStore = useDownloadsStore()
|
||
const credentialsStore = useCredentialsStore()
|
||
const notifications = useNotificationStore()
|
||
const settingsStore = useSettingsStore()
|
||
|
||
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 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 = []
|
||
const groups = new Map()
|
||
for (const d of recentActivity.value) {
|
||
if (d.status === 'failed') {
|
||
const key = `${d.subscription_name || '?'}:${d.platform || '?'}`
|
||
const g = groups.get(key) || { kind: 'group', key, label: key, items: [], latest: d }
|
||
g.items.push(d)
|
||
if (new Date(d.created_at) > new Date(g.latest.created_at)) g.latest = d
|
||
groups.set(key, g)
|
||
} else {
|
||
singles.push({ kind: 'single', key: `d-${d.id}`, download: d })
|
||
}
|
||
}
|
||
const out = [...singles, ...groups.values()]
|
||
out.sort((a, b) => {
|
||
const tA = new Date(a.kind === 'single' ? a.download.created_at : a.latest.created_at)
|
||
const tB = new Date(b.kind === 'single' ? b.download.created_at : b.latest.created_at)
|
||
return tB - tA
|
||
})
|
||
return out
|
||
})
|
||
|
||
const DASHBOARD_LIST_LIMIT = 5
|
||
const visibleActiveDownloads = computed(() => activeDownloads.value.slice(0, DASHBOARD_LIST_LIMIT))
|
||
const hiddenActiveCount = computed(() =>
|
||
Math.max(0, activeDownloads.value.length - DASHBOARD_LIST_LIMIT)
|
||
)
|
||
const visibleRecentActivity = computed(() => recentActivityGrouped.value.slice(0, DASHBOARD_LIST_LIMIT))
|
||
const hiddenRecentCount = computed(() =>
|
||
Math.max(0, recentActivityGrouped.value.length - DASHBOARD_LIST_LIMIT)
|
||
)
|
||
|
||
const failureThreshold = computed(() => {
|
||
const n = Number(settingsStore.settings['dashboard.failure_threshold'])
|
||
return Number.isFinite(n) && n >= 1 ? n : 5
|
||
})
|
||
|
||
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)
|
||
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)
|
||
})
|
||
|
||
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 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(() => {
|
||
loadDashboardData()
|
||
startAutoRefresh()
|
||
startTick()
|
||
attachVisibilityHandler()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopAutoRefresh()
|
||
stopTick()
|
||
detachVisibilityHandler()
|
||
})
|
||
|
||
function loadDashboardData() {
|
||
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))
|
||
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()
|
||
fetchStorageStats()
|
||
fetchCredentialItems()
|
||
lastRefreshedAt.value = new Date()
|
||
}
|
||
|
||
async function fetchActiveDownloads() {
|
||
try {
|
||
const [queuedResponse, runningResponse] = await Promise.all([
|
||
downloadsStore.fetchDownloads({ status: 'queued', per_page: 20 }),
|
||
downloadsStore.fetchDownloads({ status: 'running', per_page: 20 }),
|
||
])
|
||
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() {
|
||
try {
|
||
const response = await settingsApi.get()
|
||
storageStats.value = response.data.storage_stats
|
||
} catch (e) {
|
||
console.error('Failed to fetch storage stats:', e)
|
||
}
|
||
}
|
||
|
||
async function fetchCredentialItems() {
|
||
try {
|
||
const response = await credentialsApi.list()
|
||
credentialItems.value = response.data.items || []
|
||
} catch (e) {
|
||
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() {
|
||
scheduleNextRefresh()
|
||
}
|
||
|
||
function scheduleNextRefresh() {
|
||
stopAutoRefresh()
|
||
const delay = activeDownloadsCount.value > 0 ? 5000 : 30000
|
||
refreshInterval = setTimeout(async () => {
|
||
if (!document.hidden) {
|
||
await refreshLive()
|
||
}
|
||
scheduleNextRefresh()
|
||
}, delay)
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (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()
|
||
}
|
||
|
||
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 refreshLive()
|
||
} 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.retryFailedBulk()
|
||
const {
|
||
retried,
|
||
skipped_duplicate_source: dupSkipped = 0,
|
||
skipped_recent_success: recentSkipped = 0,
|
||
skipped_no_source: noSourceSkipped = 0,
|
||
} = response.data
|
||
|
||
const parts = [`Queued ${retried} ${retried === 1 ? 'retry' : 'retries'}`]
|
||
if (dupSkipped) parts.push(`${dupSkipped} duplicate-source skipped`)
|
||
if (recentSkipped) parts.push(`${recentSkipped} recently succeeded, skipped`)
|
||
if (noSourceSkipped) parts.push(`${noSourceSkipped} orphaned, skipped`)
|
||
notifications.success(parts.join(' · '))
|
||
|
||
await refreshLive()
|
||
} 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)
|
||
notifications.success(response.data.message || `Reset ${response.data.reset_count} stuck jobs`)
|
||
await refreshLive()
|
||
} catch (error) {
|
||
notifications.error(`Failed to reset stuck jobs: ${error.message}`)
|
||
} finally {
|
||
resettingOrphaned.value = false
|
||
}
|
||
}
|
||
|
||
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',
|
||
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 getPlatformColor(platform) {
|
||
const colors = {
|
||
patreon: 'red',
|
||
subscribestar: 'amber',
|
||
hentaifoundry: 'purple',
|
||
discord: 'indigo',
|
||
pixiv: 'blue',
|
||
deviantart: 'green',
|
||
}
|
||
return colors[platform] || 'grey'
|
||
}
|
||
|
||
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 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 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
|
||
return str.substring(0, length) + '...'
|
||
}
|
||
|
||
function getDownloadLabel(download) {
|
||
if (download.subscription_name && download.platform) {
|
||
return `${download.subscription_name}:${download.platform}`
|
||
}
|
||
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)}`)
|
||
await refreshLive()
|
||
} 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;
|
||
}
|
||
|
||
.legend-swatch {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 2px;
|
||
}
|
||
.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>
|