From 851df294f2748a8a8a8ee4bb92a037dbd7a9333b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Apr 2026 19:32:59 -0400 Subject: [PATCH] feat(dashboard): revamp with at-a-glance strip, sparkline, modal, disk bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/downloads.py | 64 ++ backend/app/api/settings.py | 49 +- backend/app/tasks/maintenance.py | 25 + frontend/src/components/ActivitySparkline.vue | 120 +++ .../src/components/DownloadDetailsModal.vue | 269 +++++++ frontend/src/services/api.js | 2 + frontend/src/stores/downloads.js | 9 + frontend/src/views/Dashboard.vue | 703 +++++++++++++----- frontend/src/views/Downloads.vue | 201 +---- 9 files changed, 1085 insertions(+), 357 deletions(-) create mode 100644 frontend/src/components/ActivitySparkline.vue create mode 100644 frontend/src/components/DownloadDetailsModal.vue diff --git a/backend/app/api/downloads.py b/backend/app/api/downloads.py index 06d96f6..0655370 100644 --- a/backend/app/api/downloads.py +++ b/backend/app/api/downloads.py @@ -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"]) async def reset_orphaned_downloads(): """Reset orphaned running downloads back to queued. diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index ffbebec..a13e4a6 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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.config import get_settings +from app.services.gallery_dl import GalleryDLService bp = Blueprint("settings", __name__) @@ -23,15 +24,24 @@ async def get_cached_storage_stats(session: AsyncSession) -> dict: ) setting = result.scalar_one_or_none() - if setting and setting.value: - return setting.value + from app.tasks.maintenance import _disk_usage_stats + 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 { "total_size": 0, "total_size_formatted": "Calculating...", "file_count": 0, "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" 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: with open(config_path) as 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: 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"]) async def update_gallery_dl_config(): """Update gallery-dl configuration.""" diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index ca50073..66d3b7a 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -3,6 +3,7 @@ import asyncio import logging import os +import shutil from datetime import datetime, timedelta from pathlib import Path @@ -38,6 +39,29 @@ def format_size(size_bytes: int) -> str: 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: """Calculate storage statistics for the downloads directory. @@ -80,6 +104,7 @@ def calculate_storage_stats() -> dict: "total_size_formatted": format_size(total_size), "file_count": file_count, "calculated_at": datetime.utcnow().isoformat(), + **_disk_usage_stats(downloads_path), } diff --git a/frontend/src/components/ActivitySparkline.vue b/frontend/src/components/ActivitySparkline.vue new file mode 100644 index 0000000..295c4d4 --- /dev/null +++ b/frontend/src/components/ActivitySparkline.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/components/DownloadDetailsModal.vue b/frontend/src/components/DownloadDetailsModal.vue new file mode 100644 index 0000000..985e89e --- /dev/null +++ b/frontend/src/components/DownloadDetailsModal.vue @@ -0,0 +1,269 @@ + + + diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 75c4c31..af81987 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -38,6 +38,7 @@ export const downloadsApi = { retryFailedBulk: (recentWindowHours = 24) => api.post('/downloads/retry-failed-bulk', { recent_window_hours: recentWindowHours }), stats: (params = {}) => api.get('/downloads/stats', { 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 }), requeueStale: (thresholdMinutes = 0) => api.post('/downloads/requeue-stale', { threshold_minutes: thresholdMinutes }), } @@ -56,6 +57,7 @@ export const settingsApi = { update: (data) => api.patch('/settings', data), getGalleryDL: () => api.get('/settings/gallery-dl'), updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }), + resetGalleryDL: () => api.post('/settings/gallery-dl/reset'), getApiKey: () => api.get('/settings/api-key'), regenerateApiKey: () => api.post('/settings/api-key/regenerate'), getLogs: (params = {}) => api.get('/settings/logs', { params }), diff --git a/frontend/src/stores/downloads.js b/frontend/src/stores/downloads.js index 0309a22..51445a3 100644 --- a/frontend/src/stores/downloads.js +++ b/frontend/src/stores/downloads.js @@ -39,6 +39,13 @@ export const useDownloadsStore = defineStore('downloads', () => { 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) { return await downloadsApi.retry(id) } @@ -59,6 +66,7 @@ export const useDownloadsStore = defineStore('downloads', () => { downloads, recentActivity, stats, + activityTimeline, loading, total, currentPage, @@ -66,6 +74,7 @@ export const useDownloadsStore = defineStore('downloads', () => { fetchDownloads, fetchRecentActivity, fetchStats, + fetchActivityTimeline, retryDownload, retryFailedBulk, resetOrphaned, diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 00d3f4e..af2ce62 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,52 +1,212 @@