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
+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.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."""