tuning ui and adding adaptive themeing to extension
This commit is contained in:
+7
-2
@@ -16,6 +16,7 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
@@ -28,14 +29,18 @@ COPY backend/ .
|
||||
# Copy built frontend from Stage 1
|
||||
COPY --from=frontend-build /frontend/dist /app/static
|
||||
|
||||
# Copy and set up entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /data/downloads /data/config /data/cookies
|
||||
|
||||
# Run as non-root user
|
||||
# Create non-root user (entrypoint will switch to this user after fixing permissions)
|
||||
RUN useradd -m -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app /data
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["hypercorn", "app.main:app", "--bind", "0.0.0.0:8080"]
|
||||
|
||||
@@ -24,8 +24,12 @@ async def list_credentials():
|
||||
result = await session.execute(select(Credential))
|
||||
credentials = result.scalars().all()
|
||||
|
||||
# Also return a simple map of platform -> has_credentials for UI convenience
|
||||
platforms_with_creds = {c.platform: True for c in credentials}
|
||||
|
||||
return jsonify({
|
||||
"items": [c.to_dict(include_data=False) for c in credentials]
|
||||
"items": [c.to_dict(include_data=False) for c in credentials],
|
||||
"platforms": platforms_with_creds,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -128,6 +128,46 @@ async def retry_download(download_id: int):
|
||||
}), 202
|
||||
|
||||
|
||||
@bp.route("/recent-activity", methods=["GET"])
|
||||
async def recent_activity():
|
||||
"""Get recent noteworthy downloads: failures and completions with new files.
|
||||
|
||||
These are downloads that warrant user attention, not routine "no new content" runs.
|
||||
Results are limited to the last 7 days by default.
|
||||
"""
|
||||
limit = min(int(request.args.get("limit", 10)), 50)
|
||||
days = int(request.args.get("days", 7))
|
||||
|
||||
from datetime import timedelta
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Get failed downloads OR completed with files (noteworthy activity)
|
||||
from sqlalchemy import or_
|
||||
query = select(Download).where(
|
||||
and_(
|
||||
Download.created_at >= cutoff,
|
||||
or_(
|
||||
Download.status == DownloadStatus.FAILED,
|
||||
and_(
|
||||
Download.status == DownloadStatus.COMPLETED,
|
||||
Download.file_count > 0
|
||||
)
|
||||
)
|
||||
)
|
||||
).order_by(Download.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
downloads = result.scalars().all()
|
||||
|
||||
return jsonify({
|
||||
"items": [d.to_dict() for d in downloads],
|
||||
"count": len(downloads),
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/stats", methods=["GET"])
|
||||
async def get_stats():
|
||||
"""Get download statistics."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Settings API endpoints."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from quart import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import select
|
||||
@@ -12,6 +13,45 @@ from app.config import get_settings
|
||||
bp = Blueprint("settings", __name__)
|
||||
|
||||
|
||||
def get_storage_stats():
|
||||
"""Calculate storage statistics for the downloads directory."""
|
||||
config = get_settings()
|
||||
downloads_path = Path(config.downloads_path)
|
||||
|
||||
if not downloads_path.exists():
|
||||
return {"total_size": 0, "total_size_formatted": "0 B", "file_count": 0}
|
||||
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(downloads_path):
|
||||
for f in files:
|
||||
fp = os.path.join(root, f)
|
||||
try:
|
||||
total_size += os.path.getsize(fp)
|
||||
file_count += 1
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error calculating storage stats: {e}")
|
||||
return {"total_size": 0, "total_size_formatted": "Error", "file_count": 0}
|
||||
|
||||
# Format size
|
||||
def format_size(size):
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if size < 1024:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} PB"
|
||||
|
||||
return {
|
||||
"total_size": total_size,
|
||||
"total_size_formatted": format_size(total_size),
|
||||
"file_count": file_count,
|
||||
}
|
||||
|
||||
|
||||
async def get_or_create_extension_api_key(session: AsyncSession) -> str:
|
||||
"""Get the extension API key, creating one if it doesn't exist."""
|
||||
result = await session.execute(
|
||||
@@ -72,7 +112,10 @@ async def get_all_settings():
|
||||
api_key = await get_or_create_extension_api_key(session)
|
||||
settings_dict[EXTENSION_API_KEY_SETTING] = api_key
|
||||
|
||||
return jsonify({"settings": settings_dict})
|
||||
# Include storage statistics
|
||||
storage_stats = get_storage_stats()
|
||||
|
||||
return jsonify({"settings": settings_dict, "storage_stats": storage_stats})
|
||||
|
||||
|
||||
@bp.route("", methods=["PATCH"])
|
||||
@@ -186,3 +229,39 @@ async def update_gallery_dl_config():
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to write gallery-dl config: {e}")
|
||||
return jsonify({"error": f"Failed to write config: {e}"}), 500
|
||||
|
||||
|
||||
@bp.route("/logs", methods=["GET"])
|
||||
async def get_logs():
|
||||
"""Get recent download logs from download metadata.
|
||||
|
||||
Returns the stdout/stderr from recent downloads for debugging.
|
||||
"""
|
||||
from app.models.download import Download
|
||||
from sqlalchemy import desc
|
||||
|
||||
limit = min(int(request.args.get("limit", 50)), 200)
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Get recent downloads with output logs
|
||||
query = select(Download).order_by(desc(Download.created_at)).limit(limit)
|
||||
result = await session.execute(query)
|
||||
downloads = result.scalars().all()
|
||||
|
||||
logs = []
|
||||
for dl in downloads:
|
||||
metadata = dl.metadata_ or {}
|
||||
if metadata.get("stdout") or metadata.get("stderr") or dl.error_message:
|
||||
logs.append({
|
||||
"id": dl.id,
|
||||
"url": dl.url,
|
||||
"status": dl.status,
|
||||
"created_at": dl.created_at.isoformat() + "Z" if dl.created_at else None,
|
||||
"stdout": metadata.get("stdout", ""),
|
||||
"stderr": metadata.get("stderr", ""),
|
||||
"error_message": dl.error_message,
|
||||
})
|
||||
|
||||
return jsonify({"logs": logs, "count": len(logs)})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""SQLAlchemy base model."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
@@ -10,8 +10,29 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for created_at and updated_at timestamps."""
|
||||
def utcnow():
|
||||
"""Return current UTC time as timezone-aware datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
def format_datetime(dt):
|
||||
"""Format datetime as ISO string with UTC marker for API responses.
|
||||
|
||||
If the datetime is naive (no timezone), assumes UTC and appends Z.
|
||||
This ensures JavaScript Date() correctly interprets the timezone.
|
||||
"""
|
||||
if not dt:
|
||||
return None
|
||||
# If naive (no timezone), assume UTC and append Z
|
||||
if dt.tzinfo is None:
|
||||
return dt.isoformat() + "Z"
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for created_at and updated_at timestamps.
|
||||
|
||||
Uses UTC timestamps for consistency. Frontend should convert to local time.
|
||||
"""
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import String, Integer, Text, ForeignKey, BigInteger
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.base import Base, TimestampMixin, format_datetime
|
||||
|
||||
|
||||
class DownloadStatus:
|
||||
@@ -79,10 +79,10 @@ class Download(Base, TimestampMixin):
|
||||
"error_message": self.error_message,
|
||||
"file_count": self.file_count,
|
||||
"total_size": self.total_size,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"started_at": format_datetime(self.started_at),
|
||||
"completed_at": format_datetime(self.completed_at),
|
||||
"metadata": self.metadata_,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"created_at": format_datetime(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import String, Boolean, Integer, Text, ForeignKey, UniqueConstra
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.base import Base, TimestampMixin, format_datetime
|
||||
|
||||
|
||||
class Source(Base, TimestampMixin):
|
||||
@@ -55,12 +55,12 @@ class Source(Base, TimestampMixin):
|
||||
"url": self.url,
|
||||
"enabled": self.enabled,
|
||||
"check_interval": self.check_interval,
|
||||
"last_check": self.last_check.isoformat() if self.last_check else None,
|
||||
"last_success": self.last_success.isoformat() if self.last_success else None,
|
||||
"last_check": format_datetime(self.last_check),
|
||||
"last_success": format_datetime(self.last_success),
|
||||
"error_count": self.error_count,
|
||||
"metadata": self.metadata_,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": format_datetime(self.created_at),
|
||||
"updated_at": format_datetime(self.updated_at),
|
||||
}
|
||||
if include_subscription and self.subscription:
|
||||
result["subscription_name"] = self.subscription.name
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import String, Boolean, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.base import Base, TimestampMixin, format_datetime
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.source import Source
|
||||
@@ -64,8 +64,8 @@ class Subscription(Base, TimestampMixin):
|
||||
"metadata": self.metadata_,
|
||||
"platform_count": self.platform_count,
|
||||
"enabled_source_count": self.enabled_source_count,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": format_datetime(self.created_at),
|
||||
"updated_at": format_datetime(self.updated_at),
|
||||
}
|
||||
if include_sources and self.sources:
|
||||
result["sources"] = [s.to_dict(include_subscription=False) for s in self.sources]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Fix ownership of data directories (needed for bind mounts)
|
||||
# Only fix top-level dirs to avoid slow recursive chown on large downloads
|
||||
echo "Fixing data directory permissions..."
|
||||
chown appuser:appuser /data /data/downloads /data/config /data/cookies 2>/dev/null || true
|
||||
|
||||
# Wait for database to be ready
|
||||
echo "Waiting for database..."
|
||||
until gosu appuser python -c "
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from app.config import get_settings
|
||||
|
||||
async def check():
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(settings.async_database_url)
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(__import__('sqlalchemy').text('SELECT 1'))
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(check())
|
||||
" 2>/dev/null; do
|
||||
echo "Database not ready, waiting..."
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready!"
|
||||
|
||||
# Only run migrations from the app container (not celery worker)
|
||||
# This prevents race conditions when multiple containers start simultaneously
|
||||
if [[ "$1" == "hypercorn" ]]; then
|
||||
echo "Running database migrations..."
|
||||
gosu appuser alembic upgrade head
|
||||
echo "Migrations complete!"
|
||||
else
|
||||
echo "Skipping migrations (will be run by app container)"
|
||||
fi
|
||||
|
||||
# Start the application as non-root user
|
||||
exec gosu appuser "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="#1976D2" d="M20,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6A2,2 0 0,0 20,4M4,18V6H20V18H4M6,10H8V8H6V10M6,14H8V12H6V14M10,10H18V8H10V10M10,14H18V12H10V14Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
+3
-10
@@ -19,12 +19,7 @@
|
||||
|
||||
"browser_action": {
|
||||
"default_popup": "popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"default_icon": "icons/icon.svg",
|
||||
"default_title": "GallerySubscriber"
|
||||
},
|
||||
|
||||
@@ -44,9 +39,7 @@
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
"48": "icons/icon.svg",
|
||||
"96": "icons/icon.svg"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>GallerySubscriber Settings</title>
|
||||
<style>
|
||||
/* Light theme (default) */
|
||||
:root {
|
||||
--primary-color: #1976d2;
|
||||
--primary-hover: #1565c0;
|
||||
--success-color: #4caf50;
|
||||
--error-color: #f44336;
|
||||
--bg-color: #f5f5f5;
|
||||
@@ -13,6 +15,64 @@
|
||||
--text-color: #212121;
|
||||
--text-secondary: #757575;
|
||||
--border-color: #e0e0e0;
|
||||
--input-bg: #ffffff;
|
||||
--btn-secondary-bg: #f5f5f5;
|
||||
--btn-secondary-hover: #eeeeee;
|
||||
--instructions-bg: #e3f2fd;
|
||||
--instructions-title: #1565c0;
|
||||
--code-bg: rgba(0,0,0,0.08);
|
||||
--result-success-bg: #e8f5e9;
|
||||
--result-success-text: #2e7d32;
|
||||
--result-error-bg: #ffebee;
|
||||
--result-error-text: #c62828;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme="dark"] {
|
||||
--primary-color: #64b5f6;
|
||||
--primary-hover: #42a5f5;
|
||||
--success-color: #81c784;
|
||||
--error-color: #e57373;
|
||||
--bg-color: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text-color: #e0e0e0;
|
||||
--text-secondary: #9e9e9e;
|
||||
--border-color: #424242;
|
||||
--input-bg: #2d2d2d;
|
||||
--btn-secondary-bg: #2d2d2d;
|
||||
--btn-secondary-hover: #3d3d3d;
|
||||
--instructions-bg: #1a2a3a;
|
||||
--instructions-title: #64b5f6;
|
||||
--code-bg: rgba(255,255,255,0.1);
|
||||
--result-success-bg: #1b3d1b;
|
||||
--result-success-text: #81c784;
|
||||
--result-error-bg: #3d1b1b;
|
||||
--result-error-text: #e57373;
|
||||
}
|
||||
|
||||
/* Auto-detect system theme preference */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--primary-color: #64b5f6;
|
||||
--primary-hover: #42a5f5;
|
||||
--success-color: #81c784;
|
||||
--error-color: #e57373;
|
||||
--bg-color: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text-color: #e0e0e0;
|
||||
--text-secondary: #9e9e9e;
|
||||
--border-color: #424242;
|
||||
--input-bg: #2d2d2d;
|
||||
--btn-secondary-bg: #2d2d2d;
|
||||
--btn-secondary-hover: #3d3d3d;
|
||||
--instructions-bg: #1a2a3a;
|
||||
--instructions-title: #64b5f6;
|
||||
--code-bg: rgba(255,255,255,0.1);
|
||||
--result-success-bg: #1b3d1b;
|
||||
--result-success-text: #81c784;
|
||||
--result-error-bg: #3d1b1b;
|
||||
--result-error-text: #e57373;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -80,6 +140,8 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
@@ -122,17 +184,17 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1565c0;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
background: var(--btn-secondary-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #eeeeee;
|
||||
background: var(--btn-secondary-hover);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -159,13 +221,13 @@
|
||||
}
|
||||
|
||||
.result.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
background: var(--result-success-bg);
|
||||
color: var(--result-success-text);
|
||||
}
|
||||
|
||||
.result.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
background: var(--result-error-bg);
|
||||
color: var(--result-error-text);
|
||||
}
|
||||
|
||||
.connection-test {
|
||||
@@ -179,7 +241,7 @@
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #9e9e9e;
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
@@ -191,7 +253,7 @@
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: #e3f2fd;
|
||||
background: var(--instructions-bg);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
@@ -201,7 +263,7 @@
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #1565c0;
|
||||
color: var(--instructions-title);
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
@@ -215,7 +277,7 @@
|
||||
}
|
||||
|
||||
.instructions code {
|
||||
background: rgba(0,0,0,0.08);
|
||||
background: var(--code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
|
||||
+81
-18
@@ -1,12 +1,75 @@
|
||||
/* Light theme (default) */
|
||||
:root {
|
||||
--primary-color: #1976d2;
|
||||
--primary-hover: #1565c0;
|
||||
--success-color: #4caf50;
|
||||
--warning-color: #ff9800;
|
||||
--error-color: #f44336;
|
||||
--bg-color: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--bg-hover: #f5f9ff;
|
||||
--text-color: #212121;
|
||||
--text-secondary: #757575;
|
||||
--border-color: #e0e0e0;
|
||||
--alert-warning-bg: #fff8e1;
|
||||
--alert-warning-border: #ffcc02;
|
||||
--alert-warning-text: #795548;
|
||||
--status-success-bg: #e8f5e9;
|
||||
--status-success-text: #2e7d32;
|
||||
--status-success-border: #a5d6a7;
|
||||
--status-error-bg: #ffebee;
|
||||
--status-error-text: #c62828;
|
||||
--status-error-border: #ef9a9a;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme="dark"] {
|
||||
--primary-color: #64b5f6;
|
||||
--primary-hover: #42a5f5;
|
||||
--success-color: #81c784;
|
||||
--warning-color: #ffb74d;
|
||||
--error-color: #e57373;
|
||||
--bg-color: #1e1e1e;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-hover: #2a3a4a;
|
||||
--text-color: #e0e0e0;
|
||||
--text-secondary: #9e9e9e;
|
||||
--border-color: #424242;
|
||||
--alert-warning-bg: #3d3224;
|
||||
--alert-warning-border: #8b6914;
|
||||
--alert-warning-text: #ffcc02;
|
||||
--status-success-bg: #1b3d1b;
|
||||
--status-success-text: #81c784;
|
||||
--status-success-border: #2e7d32;
|
||||
--status-error-bg: #3d1b1b;
|
||||
--status-error-text: #e57373;
|
||||
--status-error-border: #c62828;
|
||||
}
|
||||
|
||||
/* Auto-detect system theme preference */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--primary-color: #64b5f6;
|
||||
--primary-hover: #42a5f5;
|
||||
--success-color: #81c784;
|
||||
--warning-color: #ffb74d;
|
||||
--error-color: #e57373;
|
||||
--bg-color: #1e1e1e;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-hover: #2a3a4a;
|
||||
--text-color: #e0e0e0;
|
||||
--text-secondary: #9e9e9e;
|
||||
--border-color: #424242;
|
||||
--alert-warning-bg: #3d3224;
|
||||
--alert-warning-border: #8b6914;
|
||||
--alert-warning-text: #ffcc02;
|
||||
--status-success-bg: #1b3d1b;
|
||||
--status-success-text: #81c784;
|
||||
--status-success-border: #2e7d32;
|
||||
--status-error-bg: #3d1b1b;
|
||||
--status-error-text: #e57373;
|
||||
--status-error-border: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -91,7 +154,7 @@ body {
|
||||
|
||||
.platform-card:hover:not(.disabled) {
|
||||
border-color: var(--primary-color);
|
||||
background: #f5f9ff;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.platform-card.disabled {
|
||||
@@ -176,17 +239,17 @@ body {
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1565c0;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #eeeeee;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -205,7 +268,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #f5f5f5;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -234,9 +297,9 @@ body {
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff8e1;
|
||||
border: 1px solid #ffcc02;
|
||||
color: #795548;
|
||||
background: var(--alert-warning-bg);
|
||||
border: 1px solid var(--alert-warning-border);
|
||||
color: var(--alert-warning-text);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
@@ -247,21 +310,21 @@ body {
|
||||
}
|
||||
|
||||
.status-message.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #a5d6a7;
|
||||
background: var(--status-success-bg);
|
||||
color: var(--status-success-text);
|
||||
border: 1px solid var(--status-success-border);
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #ef9a9a;
|
||||
background: var(--status-error-bg);
|
||||
color: var(--status-error-text);
|
||||
border: 1px solid var(--status-error-border);
|
||||
}
|
||||
|
||||
.status-message.warning {
|
||||
background: #fff8e1;
|
||||
color: #795548;
|
||||
border: 1px solid #ffcc02;
|
||||
background: var(--alert-warning-bg);
|
||||
color: var(--alert-warning-text);
|
||||
border: 1px solid var(--alert-warning-border);
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -274,7 +337,7 @@ body {
|
||||
|
||||
.version {
|
||||
font-size: 11px;
|
||||
color: #9e9e9e;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<body>
|
||||
<div class="popup-container">
|
||||
<header class="header">
|
||||
<img src="../icons/icon-32.png" alt="GallerySubscriber" class="logo">
|
||||
<img src="../icons/icon.svg" alt="GallerySubscriber" class="logo">
|
||||
<h1>GallerySubscriber</h1>
|
||||
<span id="connection-status" class="status-indicator" title="Disconnected"></span>
|
||||
</header>
|
||||
|
||||
+10
-2
@@ -58,9 +58,14 @@ const route = useRoute()
|
||||
const notificationStore = useNotificationStore()
|
||||
const wsStore = useWebSocketStore()
|
||||
|
||||
// Initialize WebSocket listeners
|
||||
// Initialize WebSocket listeners and restore theme
|
||||
onMounted(() => {
|
||||
wsStore.init()
|
||||
// Restore theme preference from localStorage
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme) {
|
||||
theme.global.name.value = savedTheme
|
||||
}
|
||||
})
|
||||
|
||||
const drawer = ref(true)
|
||||
@@ -70,6 +75,7 @@ const navItems = [
|
||||
{ title: 'Subscriptions', icon: 'mdi-account-multiple', path: '/subscriptions' },
|
||||
{ title: 'Downloads', icon: 'mdi-download', path: '/downloads' },
|
||||
{ title: 'Credentials', icon: 'mdi-key', path: '/credentials' },
|
||||
{ title: 'Logs', icon: 'mdi-text-box-outline', path: '/logs' },
|
||||
{ title: 'Settings', icon: 'mdi-cog', path: '/settings' },
|
||||
]
|
||||
|
||||
@@ -81,7 +87,9 @@ const currentPageTitle = computed(() => {
|
||||
const isDark = computed(() => theme.global.current.value.dark)
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.global.name.value = isDark.value ? 'light' : 'dark'
|
||||
const newTheme = isDark.value ? 'light' : 'dark'
|
||||
theme.global.name.value = newTheme
|
||||
localStorage.setItem('theme', newTheme)
|
||||
}
|
||||
|
||||
const notification = computed(() => notificationStore)
|
||||
|
||||
@@ -31,6 +31,11 @@ const routes = [
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'Logs',
|
||||
component: () => import('../views/Logs.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -36,6 +36,7 @@ export const downloadsApi = {
|
||||
get: (id) => api.get(`/downloads/${id}`),
|
||||
retry: (id) => api.post(`/downloads/${id}/retry`),
|
||||
stats: (params = {}) => api.get('/downloads/stats', { params }),
|
||||
recentActivity: (params = {}) => api.get('/downloads/recent-activity', { params }),
|
||||
}
|
||||
|
||||
// Credentials API
|
||||
@@ -54,6 +55,7 @@ export const settingsApi = {
|
||||
updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }),
|
||||
getApiKey: () => api.get('/settings/api-key'),
|
||||
regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
|
||||
getLogs: (params = {}) => api.get('/settings/logs', { params }),
|
||||
}
|
||||
|
||||
// Platforms API
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { credentialsApi } from '../services/api'
|
||||
|
||||
export const useCredentialsStore = defineStore('credentials', () => {
|
||||
const credentials = ref([])
|
||||
const platforms = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchCredentials() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await credentialsApi.list()
|
||||
credentials.value = response.data.items
|
||||
platforms.value = response.data.platforms || {}
|
||||
return response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasPlatformCredentials(platform) {
|
||||
return !!platforms.value[platform]
|
||||
}
|
||||
|
||||
return {
|
||||
credentials,
|
||||
platforms,
|
||||
loading,
|
||||
fetchCredentials,
|
||||
hasPlatformCredentials,
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { downloadsApi } from '../services/api'
|
||||
|
||||
export const useDownloadsStore = defineStore('downloads', () => {
|
||||
const downloads = ref([])
|
||||
const recentActivity = ref([])
|
||||
const stats = ref(null)
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
@@ -13,9 +14,9 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
async function fetchDownloads(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
// Use provided per_page or default to 1000 for client-side pagination
|
||||
const response = await downloadsApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
per_page: params.per_page || 1000,
|
||||
...params,
|
||||
})
|
||||
downloads.value = response.data.items
|
||||
@@ -26,6 +27,12 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecentActivity(params = {}) {
|
||||
const response = await downloadsApi.recentActivity(params)
|
||||
recentActivity.value = response.data.items
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function fetchStats(params = {}) {
|
||||
const response = await downloadsApi.stats(params)
|
||||
stats.value = response.data
|
||||
@@ -38,12 +45,14 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
|
||||
return {
|
||||
downloads,
|
||||
recentActivity,
|
||||
stats,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
perPage,
|
||||
fetchDownloads,
|
||||
fetchRecentActivity,
|
||||
fetchStats,
|
||||
retryDownload,
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ export const useSubscriptionsStore = defineStore('subscriptions', () => {
|
||||
async function fetchSubscriptions(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
// Fetch all items for client-side pagination in v-data-table
|
||||
const response = await subscriptionsApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
per_page: 1000,
|
||||
...params,
|
||||
})
|
||||
subscriptions.value = response.data.items
|
||||
|
||||
@@ -59,45 +59,101 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<v-row class="mt-4">
|
||||
<!-- Downloads by Platform -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-title>Downloads by Platform</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="stats?.by_platform">
|
||||
<v-list-item
|
||||
v-for="(count, platform) in stats.by_platform"
|
||||
:key="platform"
|
||||
<v-card-text class="d-flex align-center ga-4 flex-wrap">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="checkingAll"
|
||||
@click="checkAllSources"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="getPlatformColor(platform)">
|
||||
{{ getPlatformIcon(platform) }}
|
||||
Check All Sources
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
prepend-icon="mdi-replay"
|
||||
:loading="retryingAll"
|
||||
:disabled="!failedCount"
|
||||
@click="retryAllFailed"
|
||||
>
|
||||
Retry All Failed ({{ failedCount }})
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<div class="text-body-2 text-grey">
|
||||
<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>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<!-- Running Downloads (auto-refresh) -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2" :class="{ 'mdi-spin': runningDownloads.length }">
|
||||
{{ runningDownloads.length ? 'mdi-loading' : 'mdi-download' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-capitalize">
|
||||
{{ platform }}
|
||||
Running
|
||||
<v-chip v-if="runningDownloads.length" size="small" color="info" class="ml-2">
|
||||
{{ runningDownloads.length }}
|
||||
</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="runningDownloads.length"
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="refreshRunning"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="runningDownloads.length" density="compact">
|
||||
<v-list-item v-for="dl in runningDownloads" :key="dl.id">
|
||||
<v-list-item-title class="text-body-2">
|
||||
{{ truncate(dl.url, 30) }}
|
||||
</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-chip size="small">{{ count }}</v-chip>
|
||||
</template>
|
||||
<v-list-item-subtitle>
|
||||
<v-progress-linear
|
||||
indeterminate
|
||||
color="info"
|
||||
height="4"
|
||||
class="mt-1"
|
||||
/>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
No download data yet
|
||||
<v-icon size="32" class="mb-2">mdi-check-circle-outline</v-icon>
|
||||
<div>No active downloads</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-col cols="12" md="8">
|
||||
<v-card>
|
||||
<v-card-title>Recent Downloads</v-card-title>
|
||||
<v-card-title>
|
||||
Recent Activity
|
||||
<v-chip v-if="recentActivity.length" size="small" class="ml-2" variant="tonal">
|
||||
Last 7 days
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="recentDownloads.length">
|
||||
<v-list v-if="recentActivity.length">
|
||||
<v-list-item
|
||||
v-for="download in recentDownloads"
|
||||
v-for="download in recentActivity"
|
||||
:key="download.id"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
@@ -109,7 +165,10 @@
|
||||
{{ truncate(download.url, 40) }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ formatDate(download.created_at) }}
|
||||
{{ formatRelativeTime(download.created_at) }}
|
||||
<span v-if="download.file_count > 0" class="text-success ml-2">
|
||||
{{ download.file_count }} new file{{ download.file_count !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<v-chip
|
||||
@@ -117,13 +176,13 @@
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ download.status }}
|
||||
{{ download.status === 'completed' ? 'new content' : download.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
No recent downloads
|
||||
No recent activity (failures or new downloads)
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
@@ -196,47 +255,135 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { settingsApi } from '../services/api'
|
||||
|
||||
const sourcesStore = useSourcesStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
// State
|
||||
const checkingAll = ref(false)
|
||||
const retryingAll = ref(false)
|
||||
const storageStats = ref(null)
|
||||
const runningDownloads = ref([])
|
||||
let refreshInterval = null
|
||||
|
||||
// Computed
|
||||
const stats = computed(() => downloadsStore.stats)
|
||||
const recentDownloads = computed(() => downloadsStore.downloads.slice(0, 5))
|
||||
const recentActivity = computed(() => downloadsStore.recentActivity)
|
||||
const sourcesWithErrors = computed(() =>
|
||||
sourcesStore.sources.filter(s => s.error_count > 0).slice(0, 5)
|
||||
)
|
||||
const failedCount = computed(() => stats.value?.failed || 0)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboardData()
|
||||
// Start auto-refresh for running downloads
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
async function loadDashboardData() {
|
||||
await Promise.all([
|
||||
sourcesStore.fetchSources(),
|
||||
downloadsStore.fetchStats(),
|
||||
downloadsStore.fetchDownloads({ per_page: 5 }),
|
||||
downloadsStore.fetchRecentActivity({ limit: 10 }),
|
||||
fetchRunningDownloads(),
|
||||
fetchStorageStats(),
|
||||
])
|
||||
})
|
||||
|
||||
function getPlatformIcon(platform) {
|
||||
const icons = {
|
||||
patreon: 'mdi-patreon',
|
||||
subscribestar: 'mdi-star',
|
||||
hentaifoundry: 'mdi-palette',
|
||||
discord: 'mdi-discord',
|
||||
}
|
||||
return icons[platform] || 'mdi-web'
|
||||
}
|
||||
|
||||
function getPlatformColor(platform) {
|
||||
const colors = {
|
||||
patreon: '#FF424D',
|
||||
subscribestar: '#FFD700',
|
||||
hentaifoundry: '#9C27B0',
|
||||
discord: '#5865F2',
|
||||
async function fetchRunningDownloads() {
|
||||
try {
|
||||
const response = await downloadsStore.fetchDownloads({ status: 'running', per_page: 20 })
|
||||
runningDownloads.value = response.items.filter(d => d.status === 'running')
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch running downloads:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStorageStats() {
|
||||
try {
|
||||
const response = await settingsApi.get()
|
||||
storageStats.value = response.data.storage_stats
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch storage stats:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
// Refresh running downloads every 5 seconds when there are active downloads
|
||||
refreshInterval = setInterval(async () => {
|
||||
if (runningDownloads.value.length > 0 || stats.value?.pending > 0) {
|
||||
await fetchRunningDownloads()
|
||||
await downloadsStore.fetchStats()
|
||||
await downloadsStore.fetchRecentActivity({ limit: 10 })
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRunning() {
|
||||
await fetchRunningDownloads()
|
||||
}
|
||||
|
||||
async function checkAllSources() {
|
||||
checkingAll.value = true
|
||||
try {
|
||||
const enabledSources = sourcesStore.sources.filter(s => s.enabled)
|
||||
let queued = 0
|
||||
for (const source of enabledSources) {
|
||||
try {
|
||||
await sourcesStore.triggerCheck(source.id)
|
||||
queued++
|
||||
} catch (e) {
|
||||
console.error(`Failed to queue ${source.id}:`, e)
|
||||
}
|
||||
}
|
||||
notifications.success(`Queued checks for ${queued} sources`)
|
||||
await fetchRunningDownloads()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to check sources: ${error.message}`)
|
||||
} finally {
|
||||
checkingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryAllFailed() {
|
||||
retryingAll.value = true
|
||||
try {
|
||||
const response = await downloadsStore.fetchDownloads({ status: 'failed', per_page: 100 })
|
||||
const failed = response.items
|
||||
let retried = 0
|
||||
for (const dl of failed) {
|
||||
try {
|
||||
await downloadsStore.retryDownload(dl.id)
|
||||
retried++
|
||||
} catch (e) {
|
||||
console.error(`Failed to retry ${dl.id}:`, e)
|
||||
}
|
||||
}
|
||||
notifications.success(`Queued retry for ${retried} downloads`)
|
||||
await downloadsStore.fetchStats()
|
||||
await fetchRunningDownloads()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to retry downloads: ${error.message}`)
|
||||
} finally {
|
||||
retryingAll.value = false
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function getStatusIcon(status) {
|
||||
@@ -266,6 +413,22 @@ function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr) {
|
||||
if (!dateStr) return 'Unknown'
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins} min${diffMins !== 1 ? 's' : ''} ago`
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function truncate(str, length) {
|
||||
if (!str) return ''
|
||||
if (str.length <= length) return str
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row class="mb-4">
|
||||
<v-col>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="loadLogs"
|
||||
:loading="loading"
|
||||
>
|
||||
Refresh
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-select
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
style="min-width: 140px"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>Recent Download Logs</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate />
|
||||
</div>
|
||||
|
||||
<v-expansion-panels v-else-if="filteredLogs.length">
|
||||
<v-expansion-panel
|
||||
v-for="log in filteredLogs"
|
||||
:key="log.id"
|
||||
>
|
||||
<v-expansion-panel-title>
|
||||
<div class="d-flex align-center ga-2" style="width: 100%">
|
||||
<v-chip
|
||||
:color="getStatusColor(log.status)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ log.status }}
|
||||
</v-chip>
|
||||
<span class="text-truncate" style="max-width: 400px">
|
||||
{{ log.url }}
|
||||
</span>
|
||||
<v-spacer />
|
||||
<span class="text-caption text-grey">
|
||||
{{ formatDate(log.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<div v-if="log.error_message" class="mb-4">
|
||||
<div class="text-subtitle-2 text-error">Error</div>
|
||||
<v-code class="d-block pa-2 bg-error-lighten-5">{{ log.error_message }}</v-code>
|
||||
</div>
|
||||
|
||||
<div v-if="log.stdout" class="mb-4">
|
||||
<div class="text-subtitle-2">Output</div>
|
||||
<pre class="log-output bg-grey-lighten-4 pa-2 rounded">{{ log.stdout }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="log.stderr">
|
||||
<div class="text-subtitle-2 text-warning">Errors/Warnings</div>
|
||||
<pre class="log-output bg-warning-lighten-5 pa-2 rounded">{{ log.stderr }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="!log.stdout && !log.stderr && !log.error_message" class="text-grey">
|
||||
No log output available
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<div v-else class="text-center text-grey py-8">
|
||||
<v-icon size="48" class="mb-2">mdi-text-box-outline</v-icon>
|
||||
<div>No logs available</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi } from '../services/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref([])
|
||||
const filterStatus = ref(null)
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'Completed', value: 'completed' },
|
||||
{ title: 'Failed', value: 'failed' },
|
||||
{ title: 'Running', value: 'running' },
|
||||
]
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (!filterStatus.value) return logs.value
|
||||
return logs.value.filter(l => l.status === filterStatus.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await settingsApi.getLogs({ limit: 100 })
|
||||
logs.value = response.data.logs
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colors = {
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
running: 'info',
|
||||
pending: 'warning',
|
||||
}
|
||||
return colors[status] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-output {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,29 @@
|
||||
<v-btn color="primary" @click="showAddDialog = true" prepend-icon="mdi-plus">
|
||||
Add Subscription
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-download"
|
||||
@click="exportSubscriptions"
|
||||
>
|
||||
Export
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-upload"
|
||||
@click="triggerImport"
|
||||
>
|
||||
Import
|
||||
</v-btn>
|
||||
<input
|
||||
ref="importInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style="display: none"
|
||||
@change="handleImport"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-text-field
|
||||
@@ -30,14 +53,32 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<v-row v-if="selectedIds.length > 0" class="mb-2">
|
||||
<v-col>
|
||||
<v-card color="primary" variant="tonal" class="pa-2">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<span>{{ selectedIds.length }} selected</span>
|
||||
<v-btn size="small" variant="text" @click="bulkEnable(true)">Enable All</v-btn>
|
||||
<v-btn size="small" variant="text" @click="bulkEnable(false)">Disable All</v-btn>
|
||||
<v-btn size="small" variant="text" color="error" @click="bulkDelete">Delete All</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="text" @click="selectedIds = []">Clear Selection</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card>
|
||||
<v-data-table
|
||||
v-model="selectedIds"
|
||||
:headers="headers"
|
||||
:items="subscriptionsStore.subscriptions"
|
||||
:loading="subscriptionsStore.loading"
|
||||
:items-per-page="20"
|
||||
v-model:expanded="expandedIds"
|
||||
item-value="id"
|
||||
show-select
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.name="{ item }">
|
||||
@@ -135,6 +176,12 @@
|
||||
<v-icon start size="small">{{ getPlatformIcon(source.platform) }}</v-icon>
|
||||
{{ source.platform }}
|
||||
</v-chip>
|
||||
<v-tooltip v-if="!credentialsStore.hasPlatformCredentials(source.platform)" location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props" color="warning" size="small" class="ml-1">mdi-alert</v-icon>
|
||||
</template>
|
||||
No credentials for {{ source.platform }}. Add them in Settings.
|
||||
</v-tooltip>
|
||||
</td>
|
||||
<td class="text-truncate" style="max-width: 300px">
|
||||
<a :href="source.url" target="_blank" @click.stop>{{ source.url }}</a>
|
||||
@@ -148,7 +195,12 @@
|
||||
@update:model-value="toggleSourceEnabled(source)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ formatDate(source.last_check) }}</td>
|
||||
<td>
|
||||
<div>{{ formatDate(source.last_check) }}</div>
|
||||
<div v-if="source.enabled && source.last_check" class="text-caption text-grey">
|
||||
Next: {{ formatNextCheck(source) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<v-chip v-if="source.error_count > 0" color="error" size="x-small">
|
||||
{{ source.error_count }}
|
||||
@@ -273,15 +325,19 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useSubscriptionsStore } from '../stores/subscriptions'
|
||||
import { useCredentialsStore } from '../stores/credentials'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const subscriptionsStore = useSubscriptionsStore()
|
||||
const credentialsStore = useCredentialsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterEnabled = ref(null)
|
||||
const expandedIds = ref([])
|
||||
const checkingIds = ref([])
|
||||
const selectedIds = ref([])
|
||||
const importInput = ref(null)
|
||||
|
||||
// Subscription dialog
|
||||
const showAddDialog = ref(false)
|
||||
@@ -330,6 +386,7 @@ const headers = [
|
||||
|
||||
onMounted(() => {
|
||||
loadSubscriptions()
|
||||
credentialsStore.fetchCredentials()
|
||||
})
|
||||
|
||||
watch([searchQuery, filterEnabled], () => {
|
||||
@@ -377,6 +434,23 @@ function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function formatNextCheck(source) {
|
||||
if (!source.last_check || !source.check_interval) return 'Unknown'
|
||||
const lastCheck = new Date(source.last_check)
|
||||
const nextCheck = new Date(lastCheck.getTime() + source.check_interval * 1000)
|
||||
const now = new Date()
|
||||
|
||||
if (nextCheck <= now) return 'Due now'
|
||||
|
||||
const diffMs = nextCheck - now
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
|
||||
if (diffMins < 60) return `in ${diffMins}m`
|
||||
if (diffHours < 24) return `in ${diffHours}h`
|
||||
return nextCheck.toLocaleString()
|
||||
}
|
||||
|
||||
async function toggleEnabled(subscription) {
|
||||
try {
|
||||
await subscriptionsStore.updateSubscription(subscription.id, { enabled: !subscription.enabled })
|
||||
@@ -493,6 +567,116 @@ async function deleteSource() {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk actions
|
||||
async function bulkEnable(enabled) {
|
||||
try {
|
||||
for (const id of selectedIds.value) {
|
||||
await subscriptionsStore.updateSubscription(id, { enabled })
|
||||
}
|
||||
notifications.success(`${selectedIds.value.length} subscriptions ${enabled ? 'enabled' : 'disabled'}`)
|
||||
selectedIds.value = []
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to update: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
if (!confirm(`Delete ${selectedIds.value.length} subscriptions? This cannot be undone.`)) return
|
||||
try {
|
||||
for (const id of selectedIds.value) {
|
||||
await subscriptionsStore.deleteSubscription(id)
|
||||
}
|
||||
notifications.success(`${selectedIds.value.length} subscriptions deleted`)
|
||||
selectedIds.value = []
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Import/Export
|
||||
function exportSubscriptions() {
|
||||
const data = {
|
||||
version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
subscriptions: subscriptionsStore.subscriptions.map(sub => ({
|
||||
name: sub.name,
|
||||
description: sub.description,
|
||||
priority: sub.priority,
|
||||
enabled: sub.enabled,
|
||||
sources: sub.sources?.map(s => ({
|
||||
platform: s.platform,
|
||||
url: s.url,
|
||||
enabled: s.enabled,
|
||||
check_interval: s.check_interval,
|
||||
})) || [],
|
||||
})),
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `subscriptions-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
notifications.success(`Exported ${data.subscriptions.length} subscriptions`)
|
||||
}
|
||||
|
||||
function triggerImport() {
|
||||
importInput.value.click()
|
||||
}
|
||||
|
||||
async function handleImport(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
|
||||
if (!data.subscriptions || !Array.isArray(data.subscriptions)) {
|
||||
throw new Error('Invalid format: missing subscriptions array')
|
||||
}
|
||||
|
||||
let imported = 0
|
||||
for (const sub of data.subscriptions) {
|
||||
try {
|
||||
// Create subscription
|
||||
const created = await subscriptionsStore.createSubscription({
|
||||
name: sub.name,
|
||||
description: sub.description,
|
||||
priority: sub.priority || 0,
|
||||
enabled: sub.enabled !== false,
|
||||
})
|
||||
|
||||
// Add sources
|
||||
if (sub.sources && created.id) {
|
||||
for (const source of sub.sources) {
|
||||
await subscriptionsStore.addSource(created.id, {
|
||||
platform: source.platform,
|
||||
url: source.url,
|
||||
enabled: source.enabled !== false,
|
||||
check_interval: source.check_interval,
|
||||
})
|
||||
}
|
||||
}
|
||||
imported++
|
||||
} catch (e) {
|
||||
console.error(`Failed to import ${sub.name}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
notifications.success(`Imported ${imported} of ${data.subscriptions.length} subscriptions`)
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Import failed: ${error.message}`)
|
||||
}
|
||||
|
||||
// Reset input
|
||||
event.target.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user