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
bvandeusen 851df294f2 feat(dashboard): revamp with at-a-glance strip, sparkline, modal, disk bar
Replace the four stat cards with a compact three-card strip: 7-day activity
with inline SVG sparkline (new ActivitySparkline), Running Now / Next Check
with per-source countdown list, and System with credential health + disk
usage bar.

- Add GET /downloads/activity-timeline — per-day completed/failed/files
  counts, pre-filled with zero buckets so the sparkline always has N points.
- Report filesystem-level usage via shutil.disk_usage in storage rollup,
  plus a live fallback in GET /settings so the capacity bar works before
  the first Celery rollup runs and for cached rows that predate the field.
- Extract Download Details into a reusable DownloadDetailsModal component
  and wire Recent Activity rows to open it (previously Downloads-page only).
- Compute next scheduled check per source from global schedule_interval;
  surface credential expiration/missing alerts scoped to platforms that
  actually have enabled sources.
- Two-speed polling (5s when downloads are active, 30s idle) with Page
  Visibility awareness so background tabs don't churn the API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 19:32:59 -04:00

1127 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>