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"])
async def reset_orphaned_downloads():
"""Reset orphaned running downloads back to queued.