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:
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user