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>
This commit is contained in:
2026-04-20 19:32:59 -04:00
parent 86bf43c80a
commit 851df294f2
9 changed files with 1085 additions and 357 deletions
+64
View File
@@ -307,6 +307,70 @@ async def recent_activity():
}) })
@bp.route("/activity-timeline", methods=["GET"])
async def activity_timeline():
"""Return per-day counts of completed, failed, and new-files over the last N days.
Used by the Dashboard sparkline to show run throughput at a glance.
Failed counts exclude superseded rows so the line reflects *meaningful*
failures, matching the Recent Activity filter.
"""
days = max(1, min(int(request.args.get("days", 7)), 90))
cutoff = utcnow() - timedelta(days=days)
async with current_app.db_engine.connect() as conn:
async with AsyncSession(bind=conn) as session:
# Bucket by date (UTC) using func.date(). Group by status so we can
# separate completed/failed series. SUM(file_count) gives throughput.
day = func.date(Download.created_at).label("day")
query = (
select(
day,
Download.status,
func.count(Download.id).label("count"),
func.coalesce(func.sum(Download.file_count), 0).label("file_count"),
)
.where(
Download.created_at >= cutoff,
or_(
Download.status != DownloadStatus.FAILED,
Download.superseded == False,
),
)
.group_by(day, Download.status)
.order_by(day)
)
result = await session.execute(query)
rows = result.all()
# Pre-fill every day so the sparkline has a point for each bucket
# even when nothing happened.
today = utcnow().date()
buckets = {}
for i in range(days):
d = (today - timedelta(days=days - 1 - i)).isoformat()
buckets[d] = {"date": d, "completed": 0, "failed": 0, "files": 0}
for row in rows:
d = row.day.isoformat() if hasattr(row.day, "isoformat") else str(row.day)
bucket = buckets.setdefault(
d, {"date": d, "completed": 0, "failed": 0, "files": 0}
)
if row.status == DownloadStatus.COMPLETED:
bucket["completed"] += row.count
bucket["files"] += int(row.file_count or 0)
elif row.status == DownloadStatus.FAILED:
bucket["failed"] += row.count
series = [buckets[k] for k in sorted(buckets.keys())]
totals = {
"completed": sum(b["completed"] for b in series),
"failed": sum(b["failed"] for b in series),
"files": sum(b["files"] for b in series),
}
return jsonify({"days": days, "series": series, "totals": totals})
@bp.route("/reset-orphaned", methods=["POST"]) @bp.route("/reset-orphaned", methods=["POST"])
async def reset_orphaned_downloads(): async def reset_orphaned_downloads():
"""Reset orphaned running downloads back to queued. """Reset orphaned running downloads back to queued.
+44 -5
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.setting import Setting, DEFAULT_SETTINGS, EXTENSION_API_KEY_SETTING, STORAGE_STATS_SETTING, generate_api_key from app.models.setting import Setting, DEFAULT_SETTINGS, EXTENSION_API_KEY_SETTING, STORAGE_STATS_SETTING, generate_api_key
from app.config import get_settings from app.config import get_settings
from app.services.gallery_dl import GalleryDLService
bp = Blueprint("settings", __name__) bp = Blueprint("settings", __name__)
@@ -23,15 +24,24 @@ async def get_cached_storage_stats(session: AsyncSession) -> dict:
) )
setting = result.scalar_one_or_none() setting = result.scalar_one_or_none()
if setting and setting.value: from app.tasks.maintenance import _disk_usage_stats
return setting.value disk_path = Path(get_settings().download_path)
# No cached stats yet - return placeholder if setting and setting.value:
cached = dict(setting.value)
# Older cached rollups predate the disk_* fields; top them up live so
# the capacity bar works without waiting for the next Celery run.
if "disk_percent_used" not in cached:
cached.update(_disk_usage_stats(disk_path))
return cached
# No cached stats yet - return placeholder plus live disk usage.
return { return {
"total_size": 0, "total_size": 0,
"total_size_formatted": "Calculating...", "total_size_formatted": "Calculating...",
"file_count": 0, "file_count": 0,
"calculated_at": None, "calculated_at": None,
**_disk_usage_stats(disk_path),
} }
@@ -178,16 +188,45 @@ async def get_gallery_dl_config():
config_path = Path(config.config_path) / "gallery-dl.conf" config_path = Path(config.config_path) / "gallery-dl.conf"
if not config_path.exists(): if not config_path.exists():
return jsonify({"config": {}, "message": "No configuration file found"}) defaults = GalleryDLService()._get_default_config()
return jsonify({
"config": defaults,
"is_default": True,
"message": "No configuration file found — showing computed defaults. Save to persist.",
})
try: try:
with open(config_path) as f: with open(config_path) as f:
gallery_dl_config = json.load(f) gallery_dl_config = json.load(f)
return jsonify({"config": gallery_dl_config}) return jsonify({"config": gallery_dl_config, "is_default": False})
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return jsonify({"error": f"Invalid JSON in config file: {e}"}), 500 return jsonify({"error": f"Invalid JSON in config file: {e}"}), 500
@bp.route("/gallery-dl/reset", methods=["POST"])
async def reset_gallery_dl_config():
"""Write computed defaults to gallery-dl.conf, replacing any existing file.
Users can use this to recover after misconfiguring the conf via the
Settings view. The defaults come from GalleryDLService._get_default_config,
which includes per-platform sections (extractor.discord, extractor.patreon,
etc.) with sane gallery-dl-compatible values.
"""
config = get_settings()
config_path = Path(config.config_path) / "gallery-dl.conf"
config_path.parent.mkdir(parents=True, exist_ok=True)
defaults = GalleryDLService()._get_default_config()
try:
with open(config_path, "w") as f:
json.dump(defaults, f, indent=4)
current_app.logger.info("Reset gallery-dl configuration to defaults")
return jsonify({"message": "Configuration reset to defaults", "config": defaults})
except Exception as e:
current_app.logger.error(f"Failed to write gallery-dl defaults: {e}")
return jsonify({"error": f"Failed to write defaults: {e}"}), 500
@bp.route("/gallery-dl", methods=["PUT"]) @bp.route("/gallery-dl", methods=["PUT"])
async def update_gallery_dl_config(): async def update_gallery_dl_config():
"""Update gallery-dl configuration.""" """Update gallery-dl configuration."""
+25
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
import os import os
import shutil
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -38,6 +39,29 @@ def format_size(size_bytes: int) -> str:
return f"{size_bytes:.1f} PB" return f"{size_bytes:.1f} PB"
def _disk_usage_stats(path: Path) -> dict:
"""Return filesystem-level usage for the volume hosting `path`.
Uses shutil.disk_usage so we report the actual mount's free space (the
number that matters for capacity planning), not just what our downloads
subtree consumes.
"""
try:
usage = shutil.disk_usage(path)
return {
"disk_total": usage.total,
"disk_used": usage.used,
"disk_free": usage.free,
"disk_total_formatted": format_size(usage.total),
"disk_used_formatted": format_size(usage.used),
"disk_free_formatted": format_size(usage.free),
"disk_percent_used": round(usage.used / usage.total * 100, 1) if usage.total else 0,
}
except OSError as e:
logger.warning(f"Could not read disk usage for {path}: {e}")
return {}
def calculate_storage_stats() -> dict: def calculate_storage_stats() -> dict:
"""Calculate storage statistics for the downloads directory. """Calculate storage statistics for the downloads directory.
@@ -80,6 +104,7 @@ def calculate_storage_stats() -> dict:
"total_size_formatted": format_size(total_size), "total_size_formatted": format_size(total_size),
"file_count": file_count, "file_count": file_count,
"calculated_at": datetime.utcnow().isoformat(), "calculated_at": datetime.utcnow().isoformat(),
**_disk_usage_stats(downloads_path),
} }
@@ -0,0 +1,120 @@
<template>
<div class="activity-sparkline" :style="{ height: `${height}px` }">
<svg
v-if="hasData"
:viewBox="`0 0 ${width} ${height}`"
preserveAspectRatio="none"
width="100%"
:height="height"
role="img"
aria-label="7-day activity"
>
<polyline
v-if="completedPath"
:points="completedPath"
fill="none"
stroke="rgb(var(--v-theme-success))"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
v-if="failedPath"
:points="failedPath"
fill="none"
stroke="rgb(var(--v-theme-error))"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="3,3"
/>
<g v-for="(pt, i) in filePoints" :key="i">
<circle
:cx="pt.x"
:cy="pt.y"
r="2"
fill="rgb(var(--v-theme-success))"
>
<title>{{ pt.date }}: {{ pt.files }} files, {{ pt.completed }} runs{{ pt.failed ? `, ${pt.failed} failed` : '' }}</title>
</circle>
</g>
</svg>
<div v-else class="d-flex align-center justify-center text-caption text-medium-emphasis" :style="{ height: `${height}px` }">
No activity in the last {{ series.length }} days
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
series: {
type: Array,
required: true,
},
height: {
type: Number,
default: 54,
},
})
const width = 300
const padX = 2
const padY = 4
const hasData = computed(() =>
props.series.some((b) => (b.files || 0) > 0 || (b.completed || 0) > 0 || (b.failed || 0) > 0)
)
const maxValue = computed(() => {
let max = 0
for (const b of props.series) {
if ((b.files || 0) > max) max = b.files || 0
if ((b.completed || 0) > max) max = b.completed || 0
if ((b.failed || 0) > max) max = b.failed || 0
}
return max || 1
})
function scale(i, value) {
const n = props.series.length
const x = n <= 1 ? width / 2 : padX + (i * (width - padX * 2)) / (n - 1)
const y = props.height - padY - (value / maxValue.value) * (props.height - padY * 2)
return { x, y }
}
const completedPath = computed(() => {
if (!props.series.some((b) => (b.completed || 0) > 0)) return ''
return props.series
.map((b, i) => {
const { x, y } = scale(i, b.completed || 0)
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
})
const failedPath = computed(() => {
if (!props.series.some((b) => (b.failed || 0) > 0)) return ''
return props.series
.map((b, i) => {
const { x, y } = scale(i, b.failed || 0)
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
})
const filePoints = computed(() =>
props.series.map((b, i) => {
const { x, y } = scale(i, b.files || 0)
return { x, y, files: b.files || 0, completed: b.completed || 0, failed: b.failed || 0, date: b.date }
})
)
</script>
<style scoped>
.activity-sparkline {
width: 100%;
display: block;
}
</style>
@@ -0,0 +1,269 @@
<template>
<v-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" max-width="700">
<v-card v-if="download">
<v-card-title>
Download Details
<v-chip
:color="getStatusColor(download.status)"
size="small"
class="ml-2"
>
{{ download.status }}
</v-chip>
</v-card-title>
<v-card-text>
<v-table density="compact">
<tbody>
<tr>
<td class="font-weight-bold" width="150">Source</td>
<td>{{ getSourceLabel(download) }}</td>
</tr>
<tr>
<td class="font-weight-bold">URL</td>
<td style="word-break: break-all">{{ download.url }}</td>
</tr>
<tr>
<td class="font-weight-bold">Files Downloaded</td>
<td>{{ runStats?.downloaded_count ?? download.file_count }}</td>
</tr>
<tr v-if="runStats">
<td class="font-weight-bold">Skipped</td>
<td>{{ runStats.skipped_count }} <span class="text-grey text-caption">(already in archive)</span></td>
</tr>
<tr v-if="runStats && runStats.per_item_failures > 0">
<td class="font-weight-bold">Per-item Failures</td>
<td>{{ runStats.per_item_failures }} <span class="text-grey text-caption">(single items gallery-dl skipped over)</span></td>
</tr>
<tr v-if="runStats && runStats.tier_gated_count > 0">
<td class="font-weight-bold">Tier-Gated Posts</td>
<td>
{{ runStats.tier_gated_count }}
<span class="text-grey text-caption">(not visible at current subscription tier)</span>
</td>
</tr>
<tr v-if="runStats && runStats.warning_count > 0">
<td class="font-weight-bold">Warnings</td>
<td>{{ runStats.warning_count }}</td>
</tr>
<tr>
<td class="font-weight-bold">Duration</td>
<td>{{ formatDuration(download) }}</td>
</tr>
<tr v-if="runStats">
<td class="font-weight-bold">Exit Code</td>
<td>
<code :class="runStats.exit_code === 0 ? 'text-success' : 'text-warning'">{{ runStats.exit_code }}</code>
</td>
</tr>
<tr>
<td class="font-weight-bold">Created</td>
<td>{{ formatDate(download.created_at) }}</td>
</tr>
<tr>
<td class="font-weight-bold">Started</td>
<td>{{ formatDate(download.started_at) }}</td>
</tr>
<tr>
<td class="font-weight-bold">Completed</td>
<td>{{ formatDate(download.completed_at) }}</td>
</tr>
<tr v-if="download.error_type">
<td class="font-weight-bold">Error Type</td>
<td :class="errorTextClass(download.error_type)">
{{ formatErrorType(download.error_type) }}
</td>
</tr>
<tr v-if="download.error_message">
<td class="font-weight-bold">Error Message</td>
<td :class="errorTextClass(download.error_type)">
{{ download.error_message }}
</td>
</tr>
</tbody>
</v-table>
<v-expansion-panels class="mt-4" v-model="openLogPanels" multiple v-if="download.metadata">
<v-expansion-panel v-if="download.metadata.stderr_errors_warnings">
<v-expansion-panel-title>
Errors &amp; Warnings
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
class="ml-2"
@click.stop="copyToClipboard(download.metadata.stderr_errors_warnings, 'errors &amp; warnings')"
>
<v-icon size="small">mdi-content-copy</v-icon>
<v-tooltip activator="parent">Copy</v-tooltip>
</v-btn>
</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption text-error" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ download.metadata.stderr_errors_warnings }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel v-if="download.metadata.stdout">
<v-expansion-panel-title>
Output Log (stdout)
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
class="ml-2"
@click.stop="copyToClipboard(download.metadata.stdout, 'stdout')"
>
<v-icon size="small">mdi-content-copy</v-icon>
<v-tooltip activator="parent">Copy</v-tooltip>
</v-btn>
</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ download.metadata.stdout }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel v-if="download.metadata.stderr">
<v-expansion-panel-title>
Verbose Log (stderr)
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
class="ml-2"
@click.stop="copyToClipboard(download.metadata.stderr, 'verbose log')"
>
<v-icon size="small">mdi-content-copy</v-icon>
<v-tooltip activator="parent">Copy</v-tooltip>
</v-btn>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-text-field
v-model="verboseLogFilter"
density="compact"
variant="outlined"
hide-details
clearable
placeholder="Filter lines (substring match)"
prepend-inner-icon="mdi-magnify"
class="mb-2"
/>
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ filteredVerboseLog || '(no matching lines)' }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
v-if="download.status === 'failed'"
color="primary"
@click="$emit('retry', download)"
>
Retry Download
</v-btn>
<v-btn variant="text" @click="$emit('update:modelValue', false)">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useNotificationStore } from '../stores/notifications'
const props = defineProps({
modelValue: { type: Boolean, required: true },
download: { type: Object, default: null },
})
defineEmits(['update:modelValue', 'retry'])
const notifications = useNotificationStore()
const verboseLogFilter = ref('')
const openLogPanels = ref([])
watch(
() => props.download,
(next) => {
verboseLogFilter.value = ''
openLogPanels.value = next?.metadata?.stderr_errors_warnings ? [0] : []
}
)
const runStats = computed(() => props.download?.metadata?.run_stats || null)
const filteredVerboseLog = computed(() => {
const stderr = props.download?.metadata?.stderr || ''
const query = verboseLogFilter.value.trim().toLowerCase()
if (!query) return stderr
return stderr
.split('\n')
.filter((line) => line.toLowerCase().includes(query))
.join('\n')
})
function getStatusColor(status) {
const colors = {
completed: 'success',
failed: 'error',
running: 'info',
queued: 'warning',
skipped: 'grey',
}
return colors[status] || 'grey'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
function formatDuration(download) {
if (!download.started_at) return '-'
const start = new Date(download.started_at)
const end = download.status === 'running' || !download.completed_at
? new Date()
: new Date(download.completed_at)
const seconds = Math.round((end - start) / 1000)
if (seconds < 0) return '-'
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}
function formatErrorType(errorType) {
if (!errorType) return ''
return errorType.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
}
const INFO_ERROR_TYPES = new Set(['no_new_content', 'tier_limited'])
function errorTextClass(errorType) {
if (INFO_ERROR_TYPES.has(errorType)) return 'text-warning'
return 'text-error'
}
function getSourceLabel(item) {
if (item && typeof item === 'object') {
if (item.subscription_name && item.platform) {
return `${item.subscription_name}:${item.platform}`
}
if (item.platform) {
return `(unnamed):${item.platform}`
}
}
return 'N/A'
}
async function copyToClipboard(text, label = 'content') {
if (!text) return
try {
await navigator.clipboard.writeText(text)
notifications.success(`Copied ${label} to clipboard`)
} catch (err) {
notifications.error(`Copy failed: ${err.message}`)
}
}
</script>
+2
View File
@@ -38,6 +38,7 @@ export const downloadsApi = {
retryFailedBulk: (recentWindowHours = 24) => api.post('/downloads/retry-failed-bulk', { recent_window_hours: recentWindowHours }), retryFailedBulk: (recentWindowHours = 24) => api.post('/downloads/retry-failed-bulk', { recent_window_hours: recentWindowHours }),
stats: (params = {}) => api.get('/downloads/stats', { params }), stats: (params = {}) => api.get('/downloads/stats', { params }),
recentActivity: (params = {}) => api.get('/downloads/recent-activity', { params }), recentActivity: (params = {}) => api.get('/downloads/recent-activity', { params }),
activityTimeline: (params = {}) => api.get('/downloads/activity-timeline', { params }),
resetOrphaned: (thresholdMinutes = 0) => api.post('/downloads/reset-orphaned', { threshold_minutes: thresholdMinutes }), resetOrphaned: (thresholdMinutes = 0) => api.post('/downloads/reset-orphaned', { threshold_minutes: thresholdMinutes }),
requeueStale: (thresholdMinutes = 0) => api.post('/downloads/requeue-stale', { threshold_minutes: thresholdMinutes }), requeueStale: (thresholdMinutes = 0) => api.post('/downloads/requeue-stale', { threshold_minutes: thresholdMinutes }),
} }
@@ -56,6 +57,7 @@ export const settingsApi = {
update: (data) => api.patch('/settings', data), update: (data) => api.patch('/settings', data),
getGalleryDL: () => api.get('/settings/gallery-dl'), getGalleryDL: () => api.get('/settings/gallery-dl'),
updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }), updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }),
resetGalleryDL: () => api.post('/settings/gallery-dl/reset'),
getApiKey: () => api.get('/settings/api-key'), getApiKey: () => api.get('/settings/api-key'),
regenerateApiKey: () => api.post('/settings/api-key/regenerate'), regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
getLogs: (params = {}) => api.get('/settings/logs', { params }), getLogs: (params = {}) => api.get('/settings/logs', { params }),
+9
View File
@@ -39,6 +39,13 @@ export const useDownloadsStore = defineStore('downloads', () => {
return response.data return response.data
} }
const activityTimeline = ref(null)
async function fetchActivityTimeline(params = {}) {
const response = await downloadsApi.activityTimeline(params)
activityTimeline.value = response.data
return response.data
}
async function retryDownload(id) { async function retryDownload(id) {
return await downloadsApi.retry(id) return await downloadsApi.retry(id)
} }
@@ -59,6 +66,7 @@ export const useDownloadsStore = defineStore('downloads', () => {
downloads, downloads,
recentActivity, recentActivity,
stats, stats,
activityTimeline,
loading, loading,
total, total,
currentPage, currentPage,
@@ -66,6 +74,7 @@ export const useDownloadsStore = defineStore('downloads', () => {
fetchDownloads, fetchDownloads,
fetchRecentActivity, fetchRecentActivity,
fetchStats, fetchStats,
fetchActivityTimeline,
retryDownload, retryDownload,
retryFailedBulk, retryFailedBulk,
resetOrphaned, resetOrphaned,
+534 -169
View File
@@ -1,52 +1,212 @@
<template> <template>
<div> <div>
<!-- At-a-glance strip: activity, upcoming checks, system health -->
<v-row> <v-row>
<v-col cols="12" md="3"> <!-- Activity (last 7 days) with sparkline -->
<v-card> <v-col cols="12" md="4">
<v-card-text style="border-left: 4px solid rgb(var(--v-theme-primary)); padding-left: 20px"> <v-card class="h-100">
<div class="text-h3 font-weight-bold">{{ sourcesStore.sources.length }}</div> <v-card-text>
<div class="text-caption text-medium-emphasis">Total Sources</div> <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-text>
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" md="3"> <!-- Upcoming / In-flight -->
<v-card> <v-col cols="12" md="4">
<v-card-text style="border-left: 4px solid rgb(var(--v-theme-success)); padding-left: 20px"> <v-card class="h-100">
<div class="text-h3 font-weight-bold">{{ stats?.completed || 0 }}</div> <v-card-text>
<div class="text-caption text-medium-emphasis">Completed Downloads</div> <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-text>
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" md="3"> <!-- System health: credentials + disk -->
<v-card> <v-col cols="12" md="4">
<v-card-text style="border-left: 4px solid rgb(var(--v-theme-error)); padding-left: 20px"> <v-card class="h-100">
<div class="text-h3 font-weight-bold">{{ stats?.active_failed || 0 }}</div> <v-card-text>
<div class="text-caption text-medium-emphasis">Failed Downloads</div> <div class="d-flex align-center mb-2">
</v-card-text> <v-icon size="small" class="mr-2" color="warning">mdi-shield-check</v-icon>
</v-card> <div class="text-caption text-medium-emphasis text-uppercase">System</div>
</v-col> <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>
<v-col cols="12" md="3"> <div class="text-caption text-medium-emphasis mb-1">Credentials</div>
<v-card> <div class="d-flex flex-wrap ga-1 mb-3">
<v-card-text style="border-left: 4px solid rgb(var(--v-theme-warning)); padding-left: 20px"> <v-chip
<div class="text-h3 font-weight-bold">{{ activeDownloadsCount }}</div> v-for="platform in supportedPlatforms"
<div class="text-caption text-medium-emphasis">Active Downloads</div> :key="platform"
<div class="text-caption text-medium-emphasis" v-if="stats?.queued || stats?.running"> :color="credentialChipColor(platform)"
{{ stats?.queued || 0 }} queued · {{ stats?.running || 0 }} running :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> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
</v-row> </v-row>
<!-- Quick Actions & Status --> <!-- Action bar -->
<v-row class="mt-4"> <v-row class="mt-4">
<v-col cols="12"> <v-col cols="12">
<v-card> <v-card>
<!-- Row 1: Action buttons --> <v-card-text class="d-flex align-center ga-3 flex-wrap">
<v-card-text class="d-flex align-center ga-3 flex-wrap pb-3">
<v-btn <v-btn
color="primary" color="primary"
prepend-icon="mdi-refresh" prepend-icon="mdi-refresh"
@@ -86,59 +246,27 @@
</template> </template>
Reset Stuck ({{ stuckRunningCount }}) Reset Stuck ({{ stuckRunningCount }})
</v-btn> </v-btn>
</v-card-text> <v-spacer />
<v-btn
<v-divider /> icon
size="small"
<!-- Row 2: Status info --> variant="text"
<v-card-text class="d-flex align-center ga-2 flex-wrap pt-3"> :loading="manualRefreshing"
<v-icon size="small">mdi-key</v-icon> @click="manualRefresh"
<span class="text-body-2 text-medium-emphasis mr-1">Auth:</span> title="Refresh now"
<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> <v-icon>mdi-refresh</v-icon>
{{ platform }} </v-btn>
</v-chip> <div class="text-caption text-medium-emphasis">
Updated {{ lastRefreshLabel }}
<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> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
</v-row> </v-row>
<!-- Active + Recent Activity -->
<v-row class="mt-4"> <v-row class="mt-4">
<!-- Active Downloads (queued + running, auto-refresh) -->
<v-col cols="12" md="4"> <v-col cols="12" md="4">
<v-card class="h-100 d-flex flex-column"> <v-card class="h-100 d-flex flex-column">
<v-card-title class="d-flex align-center"> <v-card-title class="d-flex align-center">
@@ -166,6 +294,7 @@
v-for="dl in visibleActiveDownloads" v-for="dl in visibleActiveDownloads"
:key="dl.id" :key="dl.id"
:class="{ 'download-running': dl.status === 'running' }" :class="{ 'download-running': dl.status === 'running' }"
@click="showDetails(dl)"
> >
<template v-slot:prepend> <template v-slot:prepend>
<v-icon :color="dl.status === 'running' ? 'info' : 'warning'" size="small"> <v-icon :color="dl.status === 'running' ? 'info' : 'warning'" size="small">
@@ -196,7 +325,6 @@
</v-card> </v-card>
</v-col> </v-col>
<!-- Recent Activity -->
<v-col cols="12" md="8"> <v-col cols="12" md="8">
<v-card class="h-100 d-flex flex-column"> <v-card class="h-100 d-flex flex-column">
<v-card-title> <v-card-title>
@@ -208,7 +336,11 @@
<v-card-text class="flex-grow-1"> <v-card-text class="flex-grow-1">
<v-list v-if="recentActivityGrouped.length"> <v-list v-if="recentActivityGrouped.length">
<template v-for="entry in visibleRecentActivity" :key="entry.key"> <template v-for="entry in visibleRecentActivity" :key="entry.key">
<v-list-item v-if="entry.kind === 'single'"> <v-list-item
v-if="entry.kind === 'single'"
link
@click="showDetails(entry.download)"
>
<template v-slot:prepend> <template v-slot:prepend>
<v-icon :color="getStatusColor(entry.download.status)"> <v-icon :color="getStatusColor(entry.download.status)">
{{ getStatusIcon(entry.download.status) }} {{ getStatusIcon(entry.download.status) }}
@@ -233,7 +365,11 @@
</v-chip> </v-chip>
</template> </template>
</v-list-item> </v-list-item>
<v-list-item v-else> <v-list-item
v-else
link
@click="showDetails(entry.latest)"
>
<template v-slot:prepend> <template v-slot:prepend>
<v-icon color="error">mdi-alert-circle</v-icon> <v-icon color="error">mdi-alert-circle</v-icon>
</template> </template>
@@ -264,8 +400,8 @@
</v-col> </v-col>
</v-row> </v-row>
<!-- Sources needing attention -->
<v-row class="mt-4"> <v-row class="mt-4">
<!-- Sources needing attention -->
<v-col cols="12"> <v-col cols="12">
<v-card> <v-card>
<v-card-title> <v-card-title>
@@ -275,7 +411,6 @@
</v-chip> </v-chip>
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<!-- Platform Health bars -->
<div class="mb-4"> <div class="mb-4">
<div class="d-flex align-center mb-2"> <div class="d-flex align-center mb-2">
<div class="text-caption text-medium-emphasis">Platform Health</div> <div class="text-caption text-medium-emphasis">Platform Health</div>
@@ -297,18 +432,20 @@
</v-icon> </v-icon>
<div class="text-body-2 platform-label">{{ row.platform }}</div> <div class="text-body-2 platform-label">{{ row.platform }}</div>
<v-progress-linear <v-progress-linear
:model-value="row.failing > 0 ? (row.failing / row.total) * 100 : 100" :model-value="row.total > 0 ? ((row.total - row.failing) / row.total) * 100 : 0"
:color="row.failing > 0 ? 'error' : 'success'" color="success"
bg-color="error"
bg-opacity="1"
height="12" height="12"
rounded rounded
class="mx-3 flex-grow-1" class="mx-3 flex-grow-1"
> >
<v-tooltip activator="parent" location="top"> <v-tooltip activator="parent" location="top">
{{ row.failing }} of {{ row.total }} {{ row.platform }} source{{ row.total !== 1 ? 's' : '' }} failing {{ row.total - row.failing }} of {{ row.total }} {{ row.platform }} source{{ row.total !== 1 ? 's' : '' }} healthy<template v-if="row.failing"> ({{ row.failing }} failing)</template>
</v-tooltip> </v-tooltip>
</v-progress-linear> </v-progress-linear>
<div class="text-caption text-medium-emphasis count-label"> <div class="text-caption text-medium-emphasis count-label">
{{ row.failing }}/{{ row.total }} {{ row.total - row.failing }}/{{ row.total }}
</div> </div>
</div> </div>
<div <div
@@ -321,7 +458,6 @@
<v-divider class="my-3" /> <v-divider class="my-3" />
<!-- Failing Sources list -->
<v-table v-if="failingSources.length" density="compact"> <v-table v-if="failingSources.length" density="compact">
<thead> <thead>
<tr> <tr>
@@ -362,6 +498,12 @@
</v-card> </v-card>
</v-col> </v-col>
</v-row> </v-row>
<DownloadDetailsModal
v-model="detailsDialog"
:download="selectedDownload"
@retry="handleRetryFromModal"
/>
</div> </div>
</template> </template>
@@ -372,7 +514,9 @@ import { useDownloadsStore } from '../stores/downloads'
import { useCredentialsStore } from '../stores/credentials' import { useCredentialsStore } from '../stores/credentials'
import { useNotificationStore } from '../stores/notifications' import { useNotificationStore } from '../stores/notifications'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { settingsApi, downloadsApi } from '../services/api' import { settingsApi, downloadsApi, credentialsApi } from '../services/api'
import ActivitySparkline from '../components/ActivitySparkline.vue'
import DownloadDetailsModal from '../components/DownloadDetailsModal.vue'
const sourcesStore = useSourcesStore() const sourcesStore = useSourcesStore()
const downloadsStore = useDownloadsStore() const downloadsStore = useDownloadsStore()
@@ -380,24 +524,36 @@ const credentialsStore = useCredentialsStore()
const notifications = useNotificationStore() const notifications = useNotificationStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
// Supported platforms for credential status
const supportedPlatforms = ['patreon', 'subscribestar', 'hentaifoundry', 'discord', 'pixiv', 'deviantart'] const supportedPlatforms = ['patreon', 'subscribestar', 'hentaifoundry', 'discord', 'pixiv', 'deviantart']
// State // State
const checkingAll = ref(false) const checkingAll = ref(false)
const retryingAll = ref(false) const retryingAll = ref(false)
const resettingOrphaned = ref(false) const resettingOrphaned = ref(false)
const manualRefreshing = ref(false)
const storageStats = ref(null) const storageStats = ref(null)
const activeDownloads = ref([]) const activeDownloads = ref([])
const loadingStats = ref(true) const detailsDialog = ref(false)
const loadingActivity = ref(true) const selectedDownload = ref(null)
const loadingStorage = ref(true) const credentialItems = ref([])
const refreshingStorage = ref(false) // 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 refreshInterval = null
let tickInterval = null
let visibilityHandler = null
// Computed // Computed
const stats = computed(() => downloadsStore.stats) const stats = computed(() => downloadsStore.stats)
const recentActivity = computed(() => downloadsStore.recentActivity) 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 recentActivityGrouped = computed(() => {
const singles = [] const singles = []
@@ -432,85 +588,179 @@ const hiddenRecentCount = computed(() =>
Math.max(0, recentActivityGrouped.value.length - DASHBOARD_LIST_LIMIT) Math.max(0, recentActivityGrouped.value.length - DASHBOARD_LIST_LIMIT)
) )
// Read threshold from settings, default 5 if unset or invalid
const failureThreshold = computed(() => { const failureThreshold = computed(() => {
const n = Number(settingsStore.settings['dashboard.failure_threshold']) const n = Number(settingsStore.settings['dashboard.failure_threshold'])
return Number.isFinite(n) && n >= 1 ? n : 5 return Number.isFinite(n) && n >= 1 ? n : 5
}) })
// Per-platform health for platforms with ≥1 enabled source const scheduleIntervalSeconds = computed(() => {
const n = Number(settingsStore.settings['download.schedule_interval'])
return Number.isFinite(n) && n > 0 ? n : 28800
})
// Annotate enabled sources with next_check derived from last_check + interval.
// Never-checked sources are treated as immediately due so they sort to the top.
const sourcesWithSchedule = computed(() => {
const nowTime = now.value.getTime()
const intervalMs = scheduleIntervalSeconds.value * 1000
return sourcesStore.sources
.filter((s) => s.enabled)
.map((s) => {
const lastCheckMs = s.last_check ? new Date(s.last_check).getTime() : null
const nextMs = lastCheckMs !== null ? lastCheckMs + intervalMs : nowTime
return { ...s, nextCheckMs: nextMs, nextCheckLabel: formatCountdown(nextMs - nowTime) }
})
.sort((a, b) => a.nextCheckMs - b.nextCheckMs)
})
const upcomingSources = computed(() => sourcesWithSchedule.value.slice(0, 4))
const nextCheckCountdown = computed(() => {
const next = sourcesWithSchedule.value[0]
if (!next) return null
const delta = next.nextCheckMs - now.value.getTime()
return formatCountdown(delta)
})
const platformHealth = computed(() => { const platformHealth = computed(() => {
const enabled = sourcesStore.sources.filter(s => s.enabled) const enabled = sourcesStore.sources.filter((s) => s.enabled)
return supportedPlatforms return supportedPlatforms
.map(platform => { .map((platform) => {
const sources = enabled.filter(s => s.platform === platform) const sources = enabled.filter((s) => s.platform === platform)
if (sources.length === 0) return null if (sources.length === 0) return null
const failing = sources.filter( const failing = sources.filter(
s => (s.error_count || 0) >= failureThreshold.value (s) => (s.error_count || 0) >= failureThreshold.value
).length ).length
return { platform, total: sources.length, failing } return { platform, total: sources.length, failing }
}) })
.filter(Boolean) .filter(Boolean)
}) })
// Sources currently in failing state, most-recent-check first const failingSources = computed(() =>
const failingSources = computed(() => { sourcesStore.sources
return sourcesStore.sources .filter((s) => s.enabled && (s.error_count || 0) >= failureThreshold.value)
.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)) .sort((a, b) => new Date(b.last_check || 0) - new Date(a.last_check || 0))
}) )
const totalFailingCount = computed(() => failingSources.value.length) const totalFailingCount = computed(() => failingSources.value.length)
const failedCount = computed(() => stats.value?.active_failed || 0) const failedCount = computed(() => stats.value?.active_failed || 0)
const activeDownloadsCount = computed(() => const activeDownloadsCount = computed(
(stats.value?.queued || 0) + (stats.value?.running || 0) () => (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) 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(() => { onMounted(() => {
// Load data in background - DON'T await, let UI render immediately
loadDashboardData() loadDashboardData()
// Start auto-refresh for running downloads
startAutoRefresh() startAutoRefresh()
startTick()
attachVisibilityHandler()
}) })
onUnmounted(() => { onUnmounted(() => {
stopAutoRefresh() stopAutoRefresh()
stopTick()
detachVisibilityHandler()
}) })
function loadDashboardData() { function loadDashboardData() {
// Fire off all requests in parallel, don't block UI sourcesStore.fetchSources().catch((e) => console.error('Failed to fetch sources:', e))
// Each section handles its own loading state credentialsStore.fetchCredentials().catch((e) => console.error('Failed to fetch credentials:', e))
sourcesStore.fetchSources().catch(e => console.error('Failed to fetch sources:', e)) settingsStore.fetchSettings().catch((e) => console.error('Failed to fetch settings:', 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() downloadsStore.fetchStats().catch((e) => console.error('Failed to fetch stats:', e))
.catch(e => console.error('Failed to fetch stats:', e)) downloadsStore
.finally(() => loadingStats.value = false) .fetchRecentActivity({ limit: 10 })
.catch((e) => console.error('Failed to fetch recent activity:', e))
downloadsStore.fetchRecentActivity({ limit: 10 }) downloadsStore
.catch(e => console.error('Failed to fetch recent activity:', e)) .fetchActivityTimeline({ days: 7 })
.finally(() => loadingActivity.value = false) .catch((e) => console.error('Failed to fetch activity timeline:', e))
fetchActiveDownloads() fetchActiveDownloads()
// Storage stats can be slow - load independently
fetchStorageStats() fetchStorageStats()
fetchCredentialItems()
lastRefreshedAt.value = new Date()
} }
async function fetchActiveDownloads() { async function fetchActiveDownloads() {
try { try {
// Fetch both queued and running downloads
const [queuedResponse, runningResponse] = await Promise.all([ const [queuedResponse, runningResponse] = await Promise.all([
downloadsStore.fetchDownloads({ status: 'queued', per_page: 20 }), downloadsStore.fetchDownloads({ status: 'queued', per_page: 20 }),
downloadsStore.fetchDownloads({ status: 'running', 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 running = runningResponse.items.filter(d => d.status === 'running') const queued = queuedResponse.items.filter((d) => d.status === 'queued')
const queued = queuedResponse.items.filter(d => d.status === 'queued')
activeDownloads.value = [...running, ...queued] activeDownloads.value = [...running, ...queued]
} catch (e) { } catch (e) {
console.error('Failed to fetch active downloads:', e) console.error('Failed to fetch active downloads:', e)
@@ -518,50 +768,104 @@ async function fetchActiveDownloads() {
} }
async function fetchStorageStats() { async function fetchStorageStats() {
loadingStorage.value = true
try { try {
const response = await settingsApi.get() const response = await settingsApi.get()
storageStats.value = response.data.storage_stats storageStats.value = response.data.storage_stats
} catch (e) { } catch (e) {
console.error('Failed to fetch storage stats:', e) console.error('Failed to fetch storage stats:', e)
} finally {
loadingStorage.value = false
} }
} }
async function refreshStorageStats() { async function fetchCredentialItems() {
refreshingStorage.value = true
try { try {
await settingsApi.refreshStorageStats() const response = await credentialsApi.list()
notifications.success('Storage stats refresh queued') credentialItems.value = response.data.items || []
// Wait a moment then fetch updated stats
setTimeout(() => fetchStorageStats(), 3000)
} catch (e) { } catch (e) {
console.error('Failed to refresh storage stats:', e) console.error('Failed to fetch credentials:', e)
notifications.error('Failed to refresh storage stats')
} finally {
refreshingStorage.value = false
} }
} }
// 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() { function startAutoRefresh() {
// Refresh active downloads every 5 seconds when there are active downloads scheduleNextRefresh()
refreshInterval = setInterval(async () => { }
if (activeDownloads.value.length > 0 || activeDownloadsCount.value > 0) {
await fetchActiveDownloads() function scheduleNextRefresh() {
await downloadsStore.fetchStats() stopAutoRefresh()
await downloadsStore.fetchRecentActivity({ limit: 10 }) const delay = activeDownloadsCount.value > 0 ? 5000 : 30000
refreshInterval = setTimeout(async () => {
if (!document.hidden) {
await refreshLive()
} }
}, 5000) scheduleNextRefresh()
}, delay)
} }
function stopAutoRefresh() { function stopAutoRefresh() {
if (refreshInterval) { if (refreshInterval) {
clearInterval(refreshInterval) clearTimeout(refreshInterval)
refreshInterval = null 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() { async function refreshActive() {
await fetchActiveDownloads() await fetchActiveDownloads()
} }
@@ -569,19 +873,18 @@ async function refreshActive() {
async function checkAllSources() { async function checkAllSources() {
checkingAll.value = true checkingAll.value = true
try { try {
const enabledSources = sourcesStore.sources.filter(s => s.enabled) const enabledSources = sourcesStore.sources.filter((s) => s.enabled)
const results = await Promise.allSettled( const results = await Promise.allSettled(
enabledSources.map(source => sourcesStore.triggerCheck(source.id)) enabledSources.map((source) => sourcesStore.triggerCheck(source.id))
) )
const queued = results.filter(r => r.status === 'fulfilled').length const queued = results.filter((r) => r.status === 'fulfilled').length
const failed = results.filter(r => r.status === 'rejected').length const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) { if (failed > 0) {
notifications.success(`Queued ${queued} sources (${failed} failed to queue)`) notifications.success(`Queued ${queued} sources (${failed} failed to queue)`)
} else { } else {
notifications.success(`Queued checks for ${queued} sources`) notifications.success(`Queued checks for ${queued} sources`)
} }
await fetchActiveDownloads() await refreshLive()
await downloadsStore.fetchStats()
} catch (error) { } catch (error) {
notifications.error(`Failed to check sources: ${error.message}`) notifications.error(`Failed to check sources: ${error.message}`)
} finally { } finally {
@@ -606,8 +909,7 @@ async function retryAllFailed() {
if (noSourceSkipped) parts.push(`${noSourceSkipped} orphaned, skipped`) if (noSourceSkipped) parts.push(`${noSourceSkipped} orphaned, skipped`)
notifications.success(parts.join(' · ')) notifications.success(parts.join(' · '))
await downloadsStore.fetchStats() await refreshLive()
await fetchActiveDownloads()
} catch (error) { } catch (error) {
notifications.error(`Failed to retry downloads: ${error.message}`) notifications.error(`Failed to retry downloads: ${error.message}`)
} finally { } finally {
@@ -618,10 +920,9 @@ async function retryAllFailed() {
async function resetOrphanedJobs() { async function resetOrphanedJobs() {
resettingOrphaned.value = true resettingOrphaned.value = true
try { try {
const response = await downloadsApi.resetOrphaned(0) // 0 = reset all running const response = await downloadsApi.resetOrphaned(0)
notifications.success(response.data.message || `Reset ${response.data.reset_count} stuck jobs`) notifications.success(response.data.message || `Reset ${response.data.reset_count} stuck jobs`)
await downloadsStore.fetchStats() await refreshLive()
await fetchActiveDownloads()
} catch (error) { } catch (error) {
notifications.error(`Failed to reset stuck jobs: ${error.message}`) notifications.error(`Failed to reset stuck jobs: ${error.message}`)
} finally { } finally {
@@ -629,6 +930,23 @@ async function resetOrphanedJobs() {
} }
} }
function showDetails(download) {
selectedDownload.value = download
detailsDialog.value = true
}
async function handleRetryFromModal(download) {
detailsDialog.value = false
try {
await downloadsStore.retryDownload(download.id)
notifications.success('Download queued for retry')
await refreshLive()
} catch (error) {
notifications.error(`Failed to retry: ${error.message}`)
}
}
// Formatting helpers
function getStatusIcon(status) { function getStatusIcon(status) {
const icons = { const icons = {
completed: 'mdi-check-circle', completed: 'mdi-check-circle',
@@ -685,19 +1003,47 @@ function formatDate(dateStr) {
function formatRelativeTime(dateStr) { function formatRelativeTime(dateStr) {
if (!dateStr) return 'Unknown' if (!dateStr) return 'Unknown'
const date = new Date(dateStr) const date = new Date(dateStr)
const now = new Date() const diffMs = now.value - date
const diffMs = now - date const past = diffMs >= 0
const diffMins = Math.floor(diffMs / 60000) const absMs = Math.abs(diffMs)
const diffHours = Math.floor(diffMs / 3600000) const diffMins = Math.floor(absMs / 60000)
const diffDays = Math.floor(diffMs / 86400000) const diffHours = Math.floor(absMs / 3600000)
const diffDays = Math.floor(absMs / 86400000)
if (diffMins < 1) return 'Just now' if (diffMins < 1) return past ? 'just now' : 'soon'
if (diffMins < 60) return `${diffMins} min${diffMins !== 1 ? 's' : ''} ago` const suffix = past ? 'ago' : 'from now'
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago` if (diffMins < 60) return `${diffMins} min${diffMins !== 1 ? 's' : ''} ${suffix}`
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago` if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ${suffix}`
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ${suffix}`
return date.toLocaleDateString() 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) { function truncate(str, length) {
if (!str) return '' if (!str) return ''
if (str.length <= length) return str if (str.length <= length) return str
@@ -708,7 +1054,6 @@ function getDownloadLabel(download) {
if (download.subscription_name && download.platform) { if (download.subscription_name && download.platform) {
return `${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) return truncate(download.url, 35)
} }
@@ -721,8 +1066,7 @@ async function retrySource(source) {
try { try {
await sourcesStore.triggerCheck(source.id) await sourcesStore.triggerCheck(source.id)
notifications.success(`Queued check for ${getSourceLabel(source)}`) notifications.success(`Queued check for ${getSourceLabel(source)}`)
// Refresh recent activity after retrying await refreshLive()
await downloadsStore.fetchRecentActivity({ limit: 10 })
} catch (error) { } catch (error) {
notifications.error(`Failed to queue check: ${error.message}`) notifications.error(`Failed to queue check: ${error.message}`)
} }
@@ -737,7 +1081,7 @@ async function retrySource(source) {
@keyframes pulse-border { @keyframes pulse-border {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.4; } 50% { opacity: 0.4; }
} }
.platform-label { .platform-label {
@@ -758,4 +1102,25 @@ async function retrySource(source) {
} }
.legend-swatch--ok { background-color: rgb(var(--v-theme-success)); } .legend-swatch--ok { background-color: rgb(var(--v-theme-success)); }
.legend-swatch--bad { background-color: rgb(var(--v-theme-error)); } .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> </style>
+18 -183
View File
@@ -217,7 +217,7 @@
<template v-slot:item.error_type="{ item }"> <template v-slot:item.error_type="{ item }">
<v-chip <v-chip
v-if="item.error_type" v-if="item.error_type"
color="error" :color="errorChipColor(item.error_type)"
size="small" size="small"
variant="tonal" variant="tonal"
> >
@@ -274,161 +274,11 @@
</v-data-table> </v-data-table>
</v-card> </v-card>
<!-- Details Dialog --> <DownloadDetailsModal
<v-dialog v-model="detailsDialog" max-width="700"> v-model="detailsDialog"
<v-card v-if="selectedDownload"> :download="selectedDownload"
<v-card-title> @retry="handleRetryFromModal"
Download Details />
<v-chip
:color="getStatusColor(selectedDownload.status)"
size="small"
class="ml-2"
>
{{ selectedDownload.status }}
</v-chip>
</v-card-title>
<v-card-text>
<v-table density="compact">
<tbody>
<tr>
<td class="font-weight-bold" width="150">Source</td>
<td>{{ getSourceLabel(selectedDownload) }}</td>
</tr>
<tr>
<td class="font-weight-bold">URL</td>
<td style="word-break: break-all">{{ selectedDownload.url }}</td>
</tr>
<tr>
<td class="font-weight-bold">Files Downloaded</td>
<td>{{ runStats?.downloaded_count ?? selectedDownload.file_count }}</td>
</tr>
<tr v-if="runStats">
<td class="font-weight-bold">Skipped</td>
<td>{{ runStats.skipped_count }} <span class="text-grey text-caption">(already in archive)</span></td>
</tr>
<tr v-if="runStats && runStats.per_item_failures > 0">
<td class="font-weight-bold">Per-item Failures</td>
<td>{{ runStats.per_item_failures }} <span class="text-grey text-caption">(single items gallery-dl skipped over)</span></td>
</tr>
<tr v-if="runStats && runStats.warning_count > 0">
<td class="font-weight-bold">Warnings</td>
<td>{{ runStats.warning_count }}</td>
</tr>
<tr>
<td class="font-weight-bold">Duration</td>
<td>{{ formatDuration(selectedDownload) }}</td>
</tr>
<tr v-if="runStats">
<td class="font-weight-bold">Exit Code</td>
<td>
<code :class="runStats.exit_code === 0 ? 'text-success' : 'text-warning'">{{ runStats.exit_code }}</code>
</td>
</tr>
<tr>
<td class="font-weight-bold">Created</td>
<td>{{ formatDate(selectedDownload.created_at) }}</td>
</tr>
<tr>
<td class="font-weight-bold">Started</td>
<td>{{ formatDate(selectedDownload.started_at) }}</td>
</tr>
<tr>
<td class="font-weight-bold">Completed</td>
<td>{{ formatDate(selectedDownload.completed_at) }}</td>
</tr>
<tr v-if="selectedDownload.error_type">
<td class="font-weight-bold">Error Type</td>
<td class="text-error">{{ formatErrorType(selectedDownload.error_type) }}</td>
</tr>
<tr v-if="selectedDownload.error_message">
<td class="font-weight-bold">Error Message</td>
<td class="text-error">{{ selectedDownload.error_message }}</td>
</tr>
</tbody>
</v-table>
<v-expansion-panels class="mt-4" v-model="openLogPanels" multiple v-if="selectedDownload.metadata">
<v-expansion-panel v-if="selectedDownload.metadata.stderr_errors_warnings">
<v-expansion-panel-title>
Errors &amp; Warnings
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
class="ml-2"
@click.stop="copyToClipboard(selectedDownload.metadata.stderr_errors_warnings, 'errors &amp; warnings')"
>
<v-icon size="small">mdi-content-copy</v-icon>
<v-tooltip activator="parent">Copy</v-tooltip>
</v-btn>
</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption text-error" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stderr_errors_warnings }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel v-if="selectedDownload.metadata.stdout">
<v-expansion-panel-title>
Output Log (stdout)
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
class="ml-2"
@click.stop="copyToClipboard(selectedDownload.metadata.stdout, 'stdout')"
>
<v-icon size="small">mdi-content-copy</v-icon>
<v-tooltip activator="parent">Copy</v-tooltip>
</v-btn>
</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stdout }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel v-if="selectedDownload.metadata.stderr">
<v-expansion-panel-title>
Verbose Log (stderr)
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
class="ml-2"
@click.stop="copyToClipboard(selectedDownload.metadata.stderr, 'verbose log')"
>
<v-icon size="small">mdi-content-copy</v-icon>
<v-tooltip activator="parent">Copy</v-tooltip>
</v-btn>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-text-field
v-model="verboseLogFilter"
density="compact"
variant="outlined"
hide-details
clearable
placeholder="Filter lines (substring match)"
prepend-inner-icon="mdi-magnify"
class="mb-2"
/>
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ filteredVerboseLog || '(no matching lines)' }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
v-if="selectedDownload.status === 'failed'"
color="primary"
@click="retryDownload(selectedDownload); detailsDialog = false"
>
Retry Download
</v-btn>
<v-btn variant="text" @click="detailsDialog = false">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div> </div>
</template> </template>
@@ -438,6 +288,7 @@ import { useRoute } from 'vue-router'
import { useDownloadsStore } from '../stores/downloads' import { useDownloadsStore } from '../stores/downloads'
import { useSourcesStore } from '../stores/sources' import { useSourcesStore } from '../stores/sources'
import { useNotificationStore } from '../stores/notifications' import { useNotificationStore } from '../stores/notifications'
import DownloadDetailsModal from '../components/DownloadDetailsModal.vue'
const route = useRoute() const route = useRoute()
const downloadsStore = useDownloadsStore() const downloadsStore = useDownloadsStore()
@@ -453,8 +304,6 @@ const filterExcludeSuperseded = ref(true)
const detailsDialog = ref(false) const detailsDialog = ref(false)
const selectedDownload = ref(null) const selectedDownload = ref(null)
const retryingIds = ref([]) const retryingIds = ref([])
const verboseLogFilter = ref('')
const openLogPanels = ref([])
let refreshInterval = null let refreshInterval = null
const statusOptions = [ const statusOptions = [
@@ -645,6 +494,12 @@ function formatErrorType(errorType) {
return errorType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) return errorType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
} }
function errorChipColor(errorType) {
if (errorType === 'tier_limited') return 'warning'
if (errorType === 'no_new_content') return 'info'
return 'error'
}
function truncateUrl(url) { function truncateUrl(url) {
if (!url) return '' if (!url) return ''
if (url.length <= 40) return url if (url.length <= 40) return url
@@ -671,34 +526,9 @@ function getSourceLabel(item) {
return `Source #${sourceId}` return `Source #${sourceId}`
} }
const runStats = computed(() => selectedDownload.value?.metadata?.run_stats || null)
const filteredVerboseLog = computed(() => {
const stderr = selectedDownload.value?.metadata?.stderr || ''
const query = verboseLogFilter.value.trim().toLowerCase()
if (!query) return stderr
return stderr
.split('\n')
.filter(line => line.toLowerCase().includes(query))
.join('\n')
})
function showDetails(download) { function showDetails(download) {
selectedDownload.value = download selectedDownload.value = download
detailsDialog.value = true detailsDialog.value = true
verboseLogFilter.value = ''
// Open the errors/warnings panel by default when there's anything worth seeing.
openLogPanels.value = download?.metadata?.stderr_errors_warnings ? [0] : []
}
async function copyToClipboard(text, label = 'content') {
if (!text) return
try {
await navigator.clipboard.writeText(text)
notifications.success(`Copied ${label} to clipboard`)
} catch (err) {
notifications.error(`Copy failed: ${err.message}`)
}
} }
async function retryDownload(download) { async function retryDownload(download) {
@@ -713,6 +543,11 @@ async function retryDownload(download) {
retryingIds.value = retryingIds.value.filter(id => id !== download.id) retryingIds.value = retryingIds.value.filter(id => id !== download.id)
} }
} }
async function handleRetryFromModal(download) {
detailsDialog.value = false
await retryDownload(download)
}
</script> </script>
<style scoped> <style scoped>