Compare commits

...

14 Commits

Author SHA1 Message Date
gru
ac5113055d Merge pull request 'logs' (#1) from logs into master
Reviewed-on: #1
2026-05-21 09:33:34 +02:00
Mateusz Gruszczyński
d7ac0f18e9 table wrapper 2026-05-20 22:15:32 +02:00
Mateusz Gruszczyński
af20e55539 resolve ip 2026-05-20 21:59:25 +02:00
Mateusz Gruszczyński
f4d8611240 smart queue logs 2026-05-20 21:55:29 +02:00
Mateusz Gruszczyński
6ab330f583 new fonts 2026-05-20 13:47:07 +02:00
Mateusz Gruszczyński
07c23a8d25 update openapi 2026-05-20 11:15:33 +02:00
Mateusz Gruszczyński
559ccd77f3 fix in ux 2026-05-20 10:41:44 +02:00
Mateusz Gruszczyński
88ea802192 config for logs and filters 2026-05-20 10:36:29 +02:00
Mateusz Gruszczyński
366b9906bb config for logs and filters 2026-05-20 10:33:18 +02:00
Mateusz Gruszczyński
4cff530b0e config for logs and filters 2026-05-20 10:31:07 +02:00
Mateusz Gruszczyński
0a82211e4c logs_commit4 2026-05-20 08:55:20 +02:00
Mateusz Gruszczyński
44ebb6afb0 logs_commit3 2026-05-20 08:33:18 +02:00
Mateusz Gruszczyński
9cf3be8fc6 logs_commit2 2026-05-20 08:25:08 +02:00
Mateusz Gruszczyński
94f81911a1 logs_commit1 2026-05-20 08:21:58 +02:00
29 changed files with 2161 additions and 3968 deletions

View File

@@ -1 +0,0 @@
scripts/INSTALL.md

198
INSTALL.md Normal file
View File

@@ -0,0 +1,198 @@
# pyTorrent stack installer
This document describes the one-command installer for installing **rTorrent + pyTorrent** from a clean server.
The installer is split into two layers:
- `scripts/install_stack.sh` - public bootstrap script intended to be downloaded directly from Git.
- `scripts/stack_installers/` - OS-specific installers and helper scripts used by the bootstrap script.
## Quick install
Run as root or through `sudo`:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh | sudo bash
```
The bootstrap script downloads the current pyTorrent repository, detects the operating system family, and runs the matching installer:
- Debian / Ubuntu: `scripts/stack_installers/install_stack_debian_ubuntu.sh`
- RHEL-compatible systems: `scripts/stack_installers/install_stack_rhel.sh`
Supported RHEL-compatible systems include RHEL, Rocky Linux, AlmaLinux, CentOS Stream, and Fedora-like systems where `dnf` or `yum` is available.
## What gets installed
Default installation includes:
- rTorrent `v0.16.11`
- libtorrent `v0.16.11`
- minimal rTorrent build without c-ares/custom curl
- rTorrent system user: `rtorrent`
- rTorrent SCGI endpoint: `scgi://127.0.0.1:5000`
- rTorrent incoming BitTorrent port: `51300`
- pyTorrent application directory: `/opt/pytorrent`
- pyTorrent HTTP port: `8090`
- pyTorrent profile configured through the HTTP API
The installer creates or updates a pyTorrent rTorrent profile through API after both services are installed.
## Recommended usage with overrides
Environment variables must be passed to the `sudo bash` process.
Example:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_PORT=8091 RTORRENT_SCGI_PORT=5001 bash
```
Another example with a custom profile name:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_PROFILE_NAME="Local rTorrent" PYTORRENT_PORT=8090 bash
```
## Bootstrap parameters
These variables are used by `scripts/install_stack.sh`.
| Variable | Default | Description |
| --- | --- | --- |
| `PYTORRENT_REPO_URL` | `https://git.linuxiarz.pl/gru/pyTorrent` | Git repository base URL. |
| `PYTORRENT_REPO_BRANCH` | `master` | Branch used to download the repository archive. |
| `PYTORRENT_ARCHIVE_URL` | derived from repo URL and branch | Custom repository archive URL. |
| `PYTORRENT_BOOTSTRAP_DIR` | `/tmp/pytorrent-stack-installer` | Temporary directory used by the bootstrap script. |
| `PYTORRENT_KEEP_BOOTSTRAP_DIR` | `0` | Set to `1` to keep the temporary directory after installation. |
Example using a different branch:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_REPO_BRANCH=develop bash
```
## rTorrent parameters
These variables are used by both stack installers.
| Variable | Default | Description |
| --- | --- | --- |
| `RTORRENT_USER` | `rtorrent` | System user used to run rTorrent. |
| `RTORRENT_HOME` | `/home/${RTORRENT_USER}` | Home directory for the rTorrent user. |
| `RTORRENT_BASE_DIR` | `/opt/rtorrent_build` | Build and install directory for xmlrpc-c, libtorrent and rTorrent. |
| `RTORRENT_SCGI_PORT` | `5000` | Local SCGI port for rTorrent XMLRPC/SCGI. |
| `RTORRENT_TORRENT_PORT` | `51300` | Incoming BitTorrent listen port. |
| `RTORRENT_REF` | `v0.16.11` | rTorrent Git tag, branch, or commit. |
| `LIBTORRENT_REF` | `v0.16.11` | libtorrent Git tag, branch, or commit. |
Example:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo RTORRENT_USER=rtorrent RTORRENT_SCGI_PORT=5001 RTORRENT_TORRENT_PORT=51400 bash
```
## pyTorrent parameters
| Variable | Default | Description |
| --- | --- | --- |
| `PYTORRENT_APP_DIR` | `/opt/pytorrent` | pyTorrent installation directory. |
| `PYTORRENT_PORT` | `8090` | HTTP port used by the pyTorrent service. |
| `PYTORRENT_BASE_URL` | `http://127.0.0.1:${PYTORRENT_PORT}` | Base URL used by the API configurator. |
| `PYTORRENT_PROFILE_NAME` | `Local rTorrent` | Name of the rTorrent profile created in pyTorrent. |
| `PYTORRENT_API_TOKEN` | empty | Bearer token used when pyTorrent API authentication is enabled. |
| `PYTORRENT_SERVICE_NAME` | `pytorrent` | systemd service name for pyTorrent. |
| `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:${RTORRENT_SCGI_PORT}` | SCGI URL saved in the pyTorrent rTorrent profile. |
Example with API token:
```bash
curl -fsSL https://git.linuxiarz.pl/gru/pyTorrent/raw/branch/master/scripts/install_stack.sh \
| sudo PYTORRENT_API_TOKEN="pt_xxx" bash
```
## API configurator parameters
The API configurator can be run manually:
```bash
/opt/pytorrent/venv/bin/python /opt/pytorrent/scripts/stack_installers/configure_pytorrent_api.py \
--base-url http://127.0.0.1:8090 \
--profile-name "Local rTorrent" \
--scgi-url scgi://127.0.0.1:5000
```
CLI options:
| Option | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `--base-url` | `PYTORRENT_BASE_URL` | `http://127.0.0.1:8090` | pyTorrent API base URL. |
| `--api-token` | `PYTORRENT_API_TOKEN` | empty | Bearer token for authenticated API calls. |
| `--profile-name` | `PYTORRENT_RTORRENT_PROFILE_NAME` | `Local rTorrent` | Profile name to create or update. |
| `--scgi-url` | `PYTORRENT_RTORRENT_SCGI_URL` | `scgi://127.0.0.1:5000` | rTorrent SCGI URL. |
| `--timeout` | `PYTORRENT_RTORRENT_TIMEOUT` | `10` | rTorrent request timeout in seconds. |
| `--wait` | `PYTORRENT_API_WAIT_SECONDS` | `90` | Time to wait for the pyTorrent API to become available. |
| `--remote` | `PYTORRENT_RTORRENT_REMOTE` | `0` | Mark profile as remote. Accepts `1`, `true`, `yes`, `on`. |
## Local installation without bootstrap
If the repository is already cloned:
Debian / Ubuntu:
```bash
sudo bash scripts/stack_installers/install_stack_debian_ubuntu.sh
```
RHEL-compatible systems:
```bash
sudo bash scripts/stack_installers/install_stack_rhel.sh
```
## Installed service hints
Check services:
```bash
systemctl status pytorrent
systemctl status rtorrent@rtorrent.service
```
Check logs:
```bash
tail -f /data/logs/app.log /data/logs/error.log
journalctl -u pytorrent -f
journalctl -u rtorrent@rtorrent.service -f
```
## Notes
- The default rTorrent build is intentionally minimal.
- c-ares and custom curl are not enabled by the stack installer defaults.
- The rTorrent installer overwrites the generated `.rtorrent.rc` because the stack installer passes `--force-config`.
- pyTorrent is configured through the HTTP API after the service starts.
- If API authentication is enabled before profile configuration, pass `PYTORRENT_API_TOKEN`.
## Build logs and troubleshooting
The stack installer writes quiet build output to `/var/log/pytorrent-installer` by default.
Override it with:
```bash
PYTORRENT_STACK_LOG_DIR=/tmp/pytorrent-build-logs
```
For full command output during rTorrent/libtorrent compilation, run with:
```bash
PYTORRENT_DEBUG_INSTALL=1
```
On RHEL-compatible systems the installer also tries to enable CRB/PowerTools and installs `libcurl-devel`, `redhat-rpm-config`, `patch`, `diffutils`, `findutils`, `file`, and `libstdc++-devel`, because minimal Alma/Rocky images often do not include enough build tooling.

View File

@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS user_preferences (
footer_items_json TEXT,
title_speed_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
reverse_dns_enabled INTEGER DEFAULT 0,
automation_toasts_enabled INTEGER DEFAULT 1,
smart_queue_toasts_enabled INTEGER DEFAULT 1,
disk_monitor_paths_json TEXT,
@@ -457,6 +458,35 @@ CREATE TABLE IF NOT EXISTS tracker_summary_cache (
);
CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch);
CREATE TABLE IF NOT EXISTS operation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER,
event_type TEXT NOT NULL,
severity TEXT DEFAULT 'info',
source TEXT DEFAULT 'system',
torrent_hash TEXT,
torrent_name TEXT,
action TEXT,
message TEXT NOT NULL,
details_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at);
CREATE TABLE IF NOT EXISTS operation_log_settings (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL DEFAULT 0,
retention_mode TEXT DEFAULT 'days',
retention_days INTEGER DEFAULT 30,
retention_lines INTEGER DEFAULT 5000,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id)
);
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
domain TEXT PRIMARY KEY,
source_url TEXT,
@@ -481,6 +511,7 @@ MIGRATIONS = [
"ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN reverse_dns_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100",
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
"ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT",
@@ -579,6 +610,11 @@ MIGRATIONS = [
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
"CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)",
"CREATE TABLE IF NOT EXISTS operation_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, event_type TEXT NOT NULL, severity TEXT DEFAULT 'info', source TEXT DEFAULT 'system', torrent_hash TEXT, torrent_name TEXT, action TEXT, message TEXT NOT NULL, details_json TEXT, created_at TEXT NOT NULL)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)",
"CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
]
POST_MIGRATION_INDEXES = [
@@ -589,6 +625,8 @@ POST_MIGRATION_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
]
def utcnow() -> str:

File diff suppressed because it is too large Load Diff

View File

@@ -19,10 +19,10 @@ import threading
from pathlib import Path
from urllib.parse import quote
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
from ..db import connect, utcnow
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs
from ..services.torrent_cache import torrent_cache
from ..services.torrent_summary import cached_summary
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
@@ -281,19 +281,33 @@ def _active_profile_cache_summary(profile_id: int | None = None) -> dict:
def cleanup_summary() -> dict:
active_profile = preferences.active_profile()
profile_id = int((active_profile or {}).get("id") or 0)
operation_logs_total = _table_count(
"operation_logs",
"WHERE profile_id=? OR profile_id IS NULL",
(profile_id,),
) if profile_id else _table_count("operation_logs")
operation_log_retention = operation_logs.get_settings(profile_id) if profile_id else operation_logs.get_settings(0)
return {
"jobs_total": _table_count("jobs"),
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
"smart_queue_history_total": _table_count("smart_queue_history"),
"operation_logs_total": operation_logs_total,
"automation_history_total": _table_count("automation_history"),
"planner_history_total": download_planner.history_count(int((preferences.active_profile() or {}).get("id") or 0)) if preferences.active_profile() else 0,
"cache": _active_profile_cache_summary(),
"planner_history_total": download_planner.history_count(profile_id) if profile_id else 0,
"cache": _active_profile_cache_summary(profile_id if profile_id else None),
"retention_days": {
"jobs": JOBS_RETENTION_DAYS,
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
"operation_logs": operation_log_retention.get("retention_days", LOG_RETENTION_DAYS),
"automation_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
"planner_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
},
"operation_log_retention": operation_log_retention,
"retention_labels": {
"operation_logs": operation_logs.retention_label(operation_log_retention),
},
"database": _db_size(),
}

View File

@@ -10,5 +10,6 @@ from . import automations as _automations_routes
from . import smart_queue as _smart_queue_routes
from . import system as _system_routes
from . import backup as _backup_routes
from . import operation_logs as _operation_logs_routes
__all__ = ["bp"]

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from ._shared import *
from ..services import operation_logs
def _active_profile_or_400():
profile = preferences.active_profile()
if not profile:
return None
return profile
@bp.get("/operation-logs")
def operation_logs_list():
profile = _active_profile_or_400()
if not profile:
return ok({"logs": [], "total": 0, "stats": {}, "settings": operation_logs.get_settings(0), "error": "No profile"})
operation_logs.apply_retention(int(profile["id"]))
data = operation_logs.list_logs(
int(profile["id"]),
limit=int(request.args.get("limit") or 200),
offset=int(request.args.get("offset") or 0),
event_type=str(request.args.get("type") or "").strip(),
q=str(request.args.get("q") or "").strip(),
hide_jobs=str(request.args.get("hide_jobs") or "").lower() in {"1", "true", "yes", "on"},
)
data["stats"] = operation_logs.stats(int(profile["id"]))
data["settings"] = data["stats"].get("settings")
return ok(data)
@bp.post("/operation-logs/settings")
def operation_logs_settings_save():
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {})
result = operation_logs.apply_retention(int(profile["id"]))
return ok({"settings": settings, "retention": result})
@bp.post("/operation-logs/clear")
def operation_logs_clear():
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
event_type = str((request.get_json(silent=True) or {}).get("event_type") or "").strip()
return ok({"deleted": operation_logs.clear(int(profile["id"]), event_type=event_type)})
@bp.post("/operation-logs/apply-retention")
def operation_logs_apply_retention():
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok(operation_logs.apply_retention(int(profile["id"])))

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from ._shared import *
from ..services import operation_logs
@bp.get("/system/disk")
def system_disk():
@@ -208,6 +209,17 @@ def cleanup_smart_queue():
@bp.post("/cleanup/operation-logs")
def cleanup_operation_logs():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
# Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact.
deleted = operation_logs.clear(int(profile["id"]))
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@bp.post("/cleanup/planner")
def cleanup_planner():
profile = preferences.active_profile()
@@ -236,7 +248,9 @@ def cleanup_automations():
def cleanup_all():
deleted_jobs = clear_jobs()
active_profile = preferences.active_profile()
deleted_planner = download_planner.clear_history(int(active_profile["id"])) if active_profile else 0
active_profile_id = int(active_profile["id"]) if active_profile else 0
deleted_logs = operation_logs.clear(active_profile_id) if active_profile_id else 0
deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0
with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
if not exists:
@@ -250,7 +264,7 @@ def cleanup_all():
else:
cur = conn.execute("DELETE FROM automation_history")
deleted_auto = int(cur.rowcount or 0)
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from ._shared import *
from ..services import torrent_creator
from ..services.reverse_dns import attach_reverse_dns
@bp.get("/torrents")
def torrents():
@@ -386,6 +387,10 @@ def torrent_peers(torrent_hash: str):
peers = rtorrent.torrent_peers(profile, torrent_hash)
for peer in peers:
peer.update(lookup_ip(peer.get("ip", "")))
prefs = preferences.get_preferences(profile_id=profile.get("id"))
if int(prefs.get("reverse_dns_enabled") or 0):
# Note: PTR hostnames are attached only when the user enables the lightweight cached resolver.
attach_reverse_dns(peers)
return ok({"peers": peers})

View File

@@ -13,6 +13,33 @@ FLAG_ICONS_VERSION = "7.2.3"
SWAGGER_UI_VERSION = "5"
SOCKET_IO_VERSION = "4.7.5"
GOOGLE_FONT_FAMILIES = (
"DM Sans",
"Figtree",
"Geist",
"IBM Plex Sans",
"Inter",
"JetBrains Mono",
"Lato",
"Manrope",
"Montserrat",
"Nunito Sans",
"Open Sans",
"Poppins",
"Roboto",
"Source Sans 3",
)
GOOGLE_FONT_WEIGHTS = "400;500;600;700;800"
def google_fonts_css_url() -> str:
families = "&".join(
f"family={name.replace(' ', '+')}:wght@{GOOGLE_FONT_WEIGHTS}"
for name in GOOGLE_FONT_FAMILIES
)
return f"https://fonts.googleapis.com/css2?{families}&display=swap"
BOOTSTRAP_THEMES = (
"default",
"flatly",
@@ -39,6 +66,10 @@ STATIC_ASSETS = {
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
},
"font_css": {
"local": f"{LIBS_STATIC_DIR}/fonts/google-fonts.css",
"cdn": google_fonts_css_url(),
},
"socket_io_js": {
"local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
"cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
@@ -87,6 +118,7 @@ def missing_offline_paths() -> list[Path]:
LIBS_DIR / f"fontawesome/{FONTAWESOME_VERSION}/webfonts",
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/4x3",
LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/1x1",
LIBS_DIR / "fonts/files",
]
for directory in required_dirs:
if not directory.is_dir() or not any(directory.iterdir()):

View File

@@ -0,0 +1,200 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from ..db import connect, utcnow, default_user_id
from . import auth, rtorrent
DEFAULT_SETTINGS = {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000}
VALID_RETENTION_MODES = {"days", "lines", "both", "manual"}
def _user_id(user_id: int | None = None) -> int:
return int(user_id or auth.current_user_id() or default_user_id())
def _details(value: dict | None = None) -> str:
try:
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True)
except Exception:
return "{}"
def _row_to_public(row: dict) -> dict:
item = dict(row)
try:
item["details"] = json.loads(item.get("details_json") or "{}")
except Exception:
item["details"] = {}
item["details_h"] = ", ".join(f"{k}: {v}" for k, v in item["details"].items() if v not in (None, ""))
return item
def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
user_id = _user_id(user_id)
profile_id = int(profile_id or 0)
with connect() as conn:
row = conn.execute(
"SELECT * FROM operation_log_settings WHERE user_id=? AND profile_id=?",
(user_id, profile_id),
).fetchone()
if not row:
return {"user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
data = {**DEFAULT_SETTINGS, **dict(row)}
data["retention_mode"] = data.get("retention_mode") if data.get("retention_mode") in VALID_RETENTION_MODES else "days"
data["retention_days"] = max(1, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"]))
data["retention_lines"] = max(100, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"]))
return data
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
user_id = _user_id(user_id)
profile_id = int(profile_id or 0)
mode = str(data.get("retention_mode") or "days").lower()
if mode not in VALID_RETENTION_MODES:
mode = "days"
days = max(1, min(3650, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"])))
lines = max(100, min(1_000_000, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"])))
now = utcnow()
with connect() as conn:
conn.execute(
"""
INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, created_at, updated_at)
VALUES(?,?,?,?,?,?,?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET
retention_mode=excluded.retention_mode,
retention_days=excluded.retention_days,
retention_lines=excluded.retention_lines,
updated_at=excluded.updated_at
""",
(user_id, profile_id, mode, days, lines, now, now),
)
return get_settings(profile_id, user_id)
def record(profile_id: int | None, event_type: str, message: str, *, severity: str = "info", source: str = "system", torrent_hash: str | None = None, torrent_name: str | None = None, action: str | None = None, details: dict | None = None, user_id: int | None = None) -> int:
now = utcnow()
user_id = _user_id(user_id)
with connect() as conn:
cur = conn.execute(
"""
INSERT INTO operation_logs(user_id, profile_id, event_type, severity, source, torrent_hash, torrent_name, action, message, details_json, created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
""",
(user_id, int(profile_id or 0) or None, str(event_type), str(severity or "info"), str(source or "system"), torrent_hash, torrent_name, action, str(message), _details(details), now),
)
return int(cur.lastrowid)
def record_job_event(profile_id: int, action: str, status: str, payload: dict | None, result: dict | None = None, error: str = "", job_id: str | None = None, user_id: int | None = None) -> None:
payload = payload or {}
result = result or {}
hashes = payload.get("hashes") or []
ctx = payload.get("job_context") or {}
items = ctx.get("items") or []
by_hash = {str(item.get("hash")): item for item in items if item}
event_type = "job_done" if status == "done" else "job_failed" if status == "failed" else "job_started"
severity = "danger" if status == "failed" else "info"
if action in {"add_magnet", "add_torrent_raw"}:
name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300]
msg = f"{action} {status}: {name}"
record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id)
return
if not hashes:
record(profile_id, event_type, f"{action} {status}", severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "error": error, "result": result}, user_id=user_id)
return
for h in hashes:
item = by_hash.get(str(h)) or {}
name = str(item.get("name") or h)
record(profile_id, "torrent_removed" if action == "remove" and status == "done" else event_type, f"{action} {status}: {name}", severity=severity, source="job", torrent_hash=str(h), torrent_name=name, action=action, details={"job_id": job_id, "status": status, "error": error, "result": result, "target_path": ctx.get("target_path"), "remove_data": ctx.get("remove_data")}, user_id=user_id)
def record_cache_diff(profile_id: int, added: list[dict], removed: list[str], updated: list[dict], old_rows: dict[str, dict]) -> None:
for row in added or []:
record(profile_id, "torrent_added", f"Torrent added: {row.get('name') or row.get('hash')}", source="poller", torrent_hash=row.get("hash"), torrent_name=row.get("name"), details={"size": row.get("size"), "path": row.get("path"), "label": row.get("label")})
for h in removed or []:
old = old_rows.get(str(h)) or {}
record(profile_id, "torrent_removed", f"Torrent removed: {old.get('name') or h}", source="poller", torrent_hash=str(h), torrent_name=old.get("name"), details={"path": old.get("path"), "label": old.get("label")})
for patch in updated or []:
h = str(patch.get("hash") or "")
old = old_rows.get(h) or {}
was_complete = bool(old.get("complete")) or float(old.get("progress") or 0) >= 100
is_complete = bool(patch.get("complete", old.get("complete"))) or float(patch.get("progress", old.get("progress") or 0) or 0) >= 100
if h and not was_complete and is_complete:
record(profile_id, "torrent_completed", f"Torrent completed: {old.get('name') or h}", source="poller", torrent_hash=h, torrent_name=old.get("name"), details={"ratio": patch.get("ratio", old.get("ratio")), "size": old.get("size"), "path": old.get("path")})
def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type: str = "", q: str = "", hide_jobs: bool = False) -> dict:
limit = max(1, min(int(limit or 200), 1000))
offset = max(0, int(offset or 0))
where = ["(profile_id=? OR profile_id IS NULL)"]
params: list[Any] = [int(profile_id or 0)]
if event_type:
where.append("event_type=?")
params.append(event_type)
if hide_jobs:
# Note: Job-originated rows include torrent_added/torrent_removed events, so source is the reliable filter.
where.append("COALESCE(source, '') <> 'job'")
if q:
where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ?)")
like = f"%{q}%"
params.extend([like, like, like, like])
sql_where = " WHERE " + " AND ".join(where)
with connect() as conn:
rows = conn.execute(f"SELECT * FROM operation_logs{sql_where} ORDER BY id DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
total = conn.execute(f"SELECT COUNT(*) AS n FROM operation_logs{sql_where}", tuple(params)).fetchone()["n"]
return {"logs": [_row_to_public(r) for r in rows], "total": int(total or 0), "limit": limit, "offset": offset}
def stats(profile_id: int) -> dict:
profile_id = int(profile_id or 0)
with connect() as conn:
total = conn.execute("SELECT COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL", (profile_id,)).fetchone()["n"]
by_type = conn.execute("SELECT event_type, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY event_type ORDER BY n DESC LIMIT 12", (profile_id,)).fetchall()
by_day = conn.execute("SELECT substr(created_at,1,10) AS bucket, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY bucket ORDER BY bucket DESC LIMIT 14", (profile_id,)).fetchall()
by_month = conn.execute("SELECT substr(created_at,1,7) AS bucket, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY bucket ORDER BY bucket DESC LIMIT 12", (profile_id,)).fetchall()
top_actions = conn.execute("SELECT COALESCE(action, event_type) AS action, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY COALESCE(action, event_type) ORDER BY n DESC LIMIT 12", (profile_id,)).fetchall()
return {"total": int(total or 0), "by_type": by_type, "by_day": by_day, "by_month": by_month, "top_actions": top_actions, "settings": get_settings(profile_id)}
def retention_label(settings: dict) -> str:
mode = settings.get("retention_mode") or "days"
if mode == "manual":
return "manual cleanup only"
if mode == "lines":
return f"retention {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
if mode == "both":
return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days and {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days"
def clear(profile_id: int, *, event_type: str = "") -> int:
where = ["(profile_id=? OR profile_id IS NULL)"]
params: list[Any] = [int(profile_id or 0)]
if event_type:
where.append("event_type=?")
params.append(event_type)
with connect() as conn:
cur = conn.execute("DELETE FROM operation_logs WHERE " + " AND ".join(where), tuple(params))
return int(cur.rowcount or 0)
def apply_retention(profile_id: int, user_id: int | None = None) -> dict:
settings = get_settings(profile_id, user_id)
mode = settings.get("retention_mode") or "manual"
deleted_days = 0
deleted_lines = 0
with connect() as conn:
if mode in {"days", "both"}:
cutoff = (datetime.now(timezone.utc) - timedelta(days=int(settings["retention_days"]))).isoformat(timespec="seconds")
cur = conn.execute("DELETE FROM operation_logs WHERE (profile_id=? OR profile_id IS NULL) AND created_at<?", (int(profile_id or 0), cutoff))
deleted_days = int(cur.rowcount or 0)
if mode in {"lines", "both"}:
keep = int(settings["retention_lines"])
ids = conn.execute("SELECT id FROM operation_logs WHERE profile_id=? OR profile_id IS NULL ORDER BY id DESC LIMIT -1 OFFSET ?", (int(profile_id or 0), keep)).fetchall()
if ids:
placeholders = ",".join("?" for _ in ids)
cur = conn.execute(f"DELETE FROM operation_logs WHERE id IN ({placeholders})", tuple(r["id"] for r in ids))
deleted_lines = int(cur.rowcount or 0)
return {"deleted_days": deleted_days, "deleted_lines": deleted_lines, "deleted": deleted_days + deleted_lines, "settings": settings}

View File

@@ -20,11 +20,22 @@ BOOTSTRAP_THEMES = {
FONT_FAMILIES = {
"default": "Theme default",
"adwaita-mono": "Adwaita Mono",
"system-ui": "System UI / Apple-like",
"figtree": "Figtree",
"inter": "Inter",
"system-ui": "System UI",
"geist": "Geist",
"manrope": "Manrope",
"dm-sans": "DM Sans",
"source-sans-3": "Source Sans 3",
"open-sans": "Open Sans",
"roboto": "Roboto",
"lato": "Lato",
"nunito-sans": "Nunito Sans",
"poppins": "Poppins",
"montserrat": "Montserrat",
"ibm-plex-sans": "IBM Plex Sans",
"jetbrains-mono": "JetBrains Mono",
"adwaita-mono": "Adwaita Mono",
}
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
@@ -38,6 +49,9 @@ RECOMMENDED_TABLE_COLUMNS = {
"created": False, "priority": False, "state": False, "active": False, "complete": False,
"hashing": False, "message": False, "hash": False,
},
"mobileSortFilters": {
"seeds:-1": True, "up_rate:-1": True, "down_rate:-1": True, "progress:-1": True,
},
"mobileSmartFiltersEnabled": False,
"widths": {
"select": 44, "name": 389, "status": 83, "size": 75, "progress": 177,
@@ -333,6 +347,7 @@ def save_preferences(data: dict, user_id: int | None = None):
footer_items_json = data.get("footer_items_json")
title_speed_enabled = data.get("title_speed_enabled")
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
reverse_dns_enabled = data.get("reverse_dns_enabled")
automation_toasts_enabled = data.get("automation_toasts_enabled")
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
disk_monitor_paths_json = data.get("disk_monitor_paths_json")
@@ -373,6 +388,9 @@ def save_preferences(data: dict, user_id: int | None = None):
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
if tracker_favicons_enabled is not None:
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
if reverse_dns_enabled is not None:
# Note: Reverse DNS is optional because peer PTR lookups can add latency on busy swarms.
conn.execute("UPDATE user_preferences SET reverse_dns_enabled=?, updated_at=? WHERE user_id=?", (1 if reverse_dns_enabled else 0, now, user_id))
if automation_toasts_enabled is not None:
# Note: Lets users silence automation-created toast noise without hiding job/history data.
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import ipaddress
import socket
import time
from concurrent.futures import ThreadPoolExecutor, wait
from threading import Lock
from typing import Any
_CACHE_TTL_SECONDS = 24 * 60 * 60
_NEGATIVE_TTL_SECONDS = 60 * 60
_CACHE_LIMIT = 2048
_LOOKUP_LIMIT_PER_REQUEST = 24
_LOOKUP_TIMEOUT_SECONDS = 0.8
_cache: dict[str, tuple[str, float]] = {}
_pending: dict[str, Any] = {}
_lock = Lock()
_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="reverse-dns")
def _is_resolvable_ip(value: str) -> bool:
try:
ipaddress.ip_address(str(value or "").strip())
return True
except ValueError:
return False
def _lookup_host(ip: str) -> str:
try:
host = socket.gethostbyaddr(ip)[0]
return str(host or "").rstrip(".")
except Exception:
return ""
def _trim_cache(now: float) -> None:
expired = [ip for ip, (_, expires_at) in _cache.items() if expires_at <= now]
for ip in expired:
_cache.pop(ip, None)
if len(_cache) <= _CACHE_LIMIT:
return
for ip, _ in sorted(_cache.items(), key=lambda item: item[1][1])[: len(_cache) - _CACHE_LIMIT]:
_cache.pop(ip, None)
def _store(ip: str, host: str, now: float | None = None) -> None:
now = now or time.monotonic()
ttl = _CACHE_TTL_SECONDS if host else _NEGATIVE_TTL_SECONDS
_cache[ip] = (host, now + ttl)
def attach_reverse_dns(peers: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Attach cached or newly resolved PTR hostnames to peer rows with a small request budget."""
now = time.monotonic()
missing: list[str] = []
with _lock:
_trim_cache(now)
for peer in peers:
ip = str(peer.get("ip") or "").strip()
if not ip or not _is_resolvable_ip(ip):
peer["host"] = ""
continue
cached = _cache.get(ip)
if cached and cached[1] > now:
peer["host"] = cached[0]
continue
peer["host"] = ""
if ip not in _pending and ip not in missing and len(missing) < _LOOKUP_LIMIT_PER_REQUEST:
missing.append(ip)
for ip in missing:
_pending[ip] = _executor.submit(_lookup_host, ip)
futures = list(_pending.items())
if futures:
wait([future for _, future in futures], timeout=_LOOKUP_TIMEOUT_SECONDS)
done_hosts: dict[str, str] = {}
with _lock:
now = time.monotonic()
for ip, future in list(_pending.items()):
if not future.done():
continue
try:
host = str(future.result() or "")
except Exception:
host = ""
_store(ip, host, now)
done_hosts[ip] = host
_pending.pop(ip, None)
for peer in peers:
ip = str(peer.get("ip") or "").strip()
if ip in done_hosts:
peer["host"] = done_hosts[ip]
elif not peer.get("host") and ip in _pending:
peer["host_pending"] = True
return peers

View File

@@ -311,6 +311,33 @@ def count_history(profile_id: int, user_id: int | None = None) -> int:
).fetchone()
return int((row or {}).get('count') or 0)
def _latest_history_event(profile_id: int, user_id: int | None = None) -> str:
"""Return the newest Smart Queue history event for duplicate suppression."""
# Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream.
user_id = user_id or default_user_id()
with connect() as conn:
row = conn.execute(
'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1',
(user_id, profile_id),
).fetchone()
return str((row or {}).get('event') or '')
def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[str, Any] | None = None) -> bool:
"""Record one disabled-state history row until Smart Queue runs or changes state again."""
# Note: This keeps the UI audit trail useful without creating repeated disabled logs on every poll.
if _latest_history_event(profile_id, user_id) in {'disabled_waiting_start', 'auto_stopped_idle'}:
return False
payload = {
'decision': 'Smart Queue disabled, waiting for start',
'enabled': False,
**(details or {}),
}
add_history(profile_id, 'disabled_waiting_start', [], [], 0, payload, user_id)
return True
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]:
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)}
@@ -1105,8 +1132,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), True)
except Exception:
restored = []
add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False, 'labels_restored': restored}, user_id)
return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'}
# Note: Disabled checks are frequent poller passes; record only the first waiting-state row.
disabled_log_recorded = _record_disabled_waiting_once(profile_id, user_id, {'labels_restored': restored})
return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'disabled_log_recorded': disabled_log_recorded, 'message': 'Smart Queue disabled, waiting for start'}
torrents = rtorrent.list_torrents(profile)
# Note: Stalled labels block automatic starting only; a manually started Stalled item still counts as a running slot.

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from threading import RLock
from time import time
from . import rtorrent
from . import rtorrent, operation_logs
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
@@ -58,6 +58,8 @@ class TorrentCache:
self._data[profile_id] = fresh
self._errors[profile_id] = ""
self._updated_at[profile_id] = time()
if old:
operation_logs.record_cache_diff(profile_id, added, removed, updated, old)
return {"ok": True, "profile_id": profile_id, "added": added, "updated": updated, "removed": removed, "post_check_changes": post_check_changes}
except Exception as exc:
with self._lock:

View File

@@ -5,7 +5,7 @@ import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from . import rtorrent, auth, disk_guard
from . import rtorrent, auth, disk_guard, operation_logs
from .preferences import get_profile
from ..config import WORKERS
from ..db import connect, utcnow, default_user_id
@@ -300,6 +300,7 @@ def _run(job_id: str):
if not _mark_running(job_id, attempts):
return
event_meta = _job_event_meta(payload)
operation_logs.record_job_event(profile["id"], job["action"], "started", payload, job_id=job_id, user_id=int(job.get("user_id") or 0))
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
result = _execute(profile, job["action"], payload)
@@ -308,6 +309,7 @@ def _run(job_id: str):
if fresh and fresh["status"] != "running":
return
_set_job(job_id, "done", result=result, finished=True)
operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0))
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
except Exception as exc:
@@ -319,6 +321,8 @@ def _run(job_id: str):
return
status = "pending" if attempts < max_attempts else "failed"
_set_job(job_id, status, str(exc), finished=(status == "failed"))
if status == "failed":
operation_logs.record_job_event(int(job.get("profile_id") or 0), job.get("action"), "failed", payload, error=str(exc), job_id=job_id, user_id=int(job.get("user_id") or 0))
_emit("operation_failed", {"job_id": job_id, "action": job.get("action"), "profile_id": job.get("profile_id"), "hashes": payload.get("hashes") or [], "error": str(exc), **_job_event_meta(payload)})
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts})
if status == "pending":

View File

@@ -14,6 +14,7 @@ import { pollerSource } from './poller.js';
import { profilesSource } from './profiles.js';
import { dashboardSource } from './dashboard.js';
import { chartsSource } from './charts.js';
import { operationLogsSource } from './operationLogs.js';
import { bootstrapSource } from './bootstrap.js';
export const moduleSources = [
@@ -30,6 +31,7 @@ export const moduleSources = [
smartQueueSource,
plannerSource,
dashboardSource,
operationLogsSource,
pollerSource,
profilesSource,
chartsSource,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -33,13 +33,66 @@ html[data-app-font="inter"] {
}
html[data-app-font="system-ui"] {
--app-font-family:
system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
Roboto, Arial, sans-serif;
}
html[data-app-font="figtree"] {
--app-font-family:
Figtree, Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
sans-serif;
}
html[data-app-font="geist"] {
--app-font-family:
Geist, Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
sans-serif;
}
html[data-app-font="manrope"] {
--app-font-family:
Manrope, Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
sans-serif;
}
html[data-app-font="dm-sans"] {
--app-font-family:
"DM Sans", Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
sans-serif;
}
html[data-app-font="source-sans-3"] {
--app-font-family:
"Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI,
Roboto, Arial, sans-serif;
}
html[data-app-font="open-sans"] {
--app-font-family:
"Open Sans", system-ui, -apple-system, "Segoe UI", Roboto, Arial,
sans-serif;
}
html[data-app-font="roboto"] {
--app-font-family:
Roboto, system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
}
html[data-app-font="lato"] {
--app-font-family:
Lato, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}
html[data-app-font="nunito-sans"] {
--app-font-family:
"Nunito Sans", system-ui, -apple-system, "Segoe UI", Roboto, Arial,
sans-serif;
}
html[data-app-font="poppins"] {
--app-font-family:
Poppins, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}
html[data-app-font="montserrat"] {
--app-font-family:
Montserrat, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
sans-serif;
}
html[data-app-font="ibm-plex-sans"] {
--app-font-family:
"IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, Arial,
sans-serif;
}
html[data-app-font="jetbrains-mono"] {
--app-font-family:
"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
@@ -466,6 +519,18 @@ body.resizing-details {
.muted-pane {
color: var(--bs-secondary-color);
}
.torrent-log-message {
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.65rem;
font-size: 1rem;
line-height: 1.6;
margin: 0;
min-height: 4.25rem;
padding: 1rem 1.1rem;
white-space: pre-wrap;
}
.detail-table {
white-space: nowrap;
}
@@ -994,6 +1059,7 @@ body.mobile-mode .main-grid {
.mobile-sort-row .btn {
width: 100%;
justify-content: center;
pointer-events: auto;
}
.view-preferences-note {
align-items: center;
@@ -3337,9 +3403,21 @@ body.mobile-mode .mobile-filter-bar {
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 0.55rem;
}
.mobile-sort-row .btn {
pointer-events: auto;
.column-config-section {
display: grid;
gap: 0.5rem;
margin-bottom: 1rem;
}
.column-config-section:last-child {
margin-bottom: 0;
}
.column-config-section h6 {
margin: 0;
}
.mobile-progress:empty {
display: none;
}
@@ -4164,3 +4242,150 @@ body,
overflow-wrap: anywhere;
white-space: normal;
}
/* Operation logs */
.operation-log-toolbar,
.operation-log-toolbar-main,
.operation-log-settings-grid,
.operation-log-view-settings {
display: flex;
flex-wrap: wrap;
gap: .5rem;
align-items: end;
}
.operation-log-toolbar {
justify-content: space-between;
}
.operation-log-toolbar-main {
flex: 1 1 auto;
}
.operation-log-toolbar-toggle {
flex: 0 0 auto;
margin-left: auto;
}
.operation-log-view-settings {
align-items: center;
border-top: 1px solid var(--bs-border-color);
margin-top: 1rem;
padding-top: 1rem;
}
.operation-log-view-settings > div:first-child {
flex: 1 1 220px;
}
.operation-log-view-settings small {
color: var(--bs-secondary-color);
display: block;
}
.operation-log-type-filter {
max-width: 180px;
}
.operation-log-search {
max-width: 260px;
}
.operation-log-hide-jobs {
align-items: center;
min-height: 31px;
}
.operation-log-settings-actions {
display: flex;
flex-wrap: wrap;
gap: .5rem;
}
.operation-log-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: .75rem;
}
.operation-log-stat,
.operation-log-panels section {
border: 1px solid var(--bs-border-color);
border-radius: .75rem;
padding: .75rem;
background: var(--bs-body-bg);
}
.operation-log-stat span {
display: block;
font-size: 1.25rem;
font-weight: 700;
}
.operation-log-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: .75rem;
margin-top: .75rem;
}
.operation-log-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: .25rem 0;
border-bottom: 1px solid var(--bs-border-color-translucent);
}
.operation-log-row:last-child {
border-bottom: 0;
}
.operation-log-table td {
vertical-align: top;
}
@media (max-width: 760px) {
.operation-log-type-filter,
.operation-log-search {
max-width: none;
width: 100%;
}
.operation-log-toolbar,
.operation-log-toolbar-main,
.operation-log-view-settings {
align-items: stretch;
flex-direction: column;
}
.operation-log-toolbar-toggle {
margin-left: 0;
}
.operation-log-toolbar-main > .btn,
.operation-log-settings-actions > .btn,
.operation-log-view-settings > .btn {
width: 100%;
}
}
.peers-table {
table-layout: auto;
width: 100%;
}
.peers-table .peer-progress-wide {
min-width: 108px;
width: clamp(108px, 12vw, 126px);
}
.peer-host {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@
<link rel="shortcut icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
<link href="{{ bootstrap_theme_url('default') }}" rel="stylesheet">
<link href="{{ frontend_asset_url('fontawesome_css') }}" rel="stylesheet">
<link href="{{ frontend_asset_url('font_css') }}" rel="stylesheet">
<link href="{{ static_url('styles.css') }}" rel="stylesheet">
</head>
<body class="auth-page">

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import re
from pathlib import Path
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parents[1]
@@ -14,6 +14,33 @@ FONTAWESOME_VERSION = "6.5.2"
FLAG_ICONS_VERSION = "7.2.3"
SWAGGER_UI_VERSION = "5"
SOCKET_IO_VERSION = "4.7.5"
GOOGLE_FONT_FAMILIES = (
"DM Sans",
"Figtree",
"Geist",
"IBM Plex Sans",
"Inter",
"JetBrains Mono",
"Lato",
"Manrope",
"Montserrat",
"Nunito Sans",
"Open Sans",
"Poppins",
"Roboto",
"Source Sans 3",
)
GOOGLE_FONT_WEIGHTS = "400;500;600;700;800"
def google_fonts_css_url() -> str:
families = "&".join(
f"family={name.replace(' ', '+')}:wght@{GOOGLE_FONT_WEIGHTS}"
for name in GOOGLE_FONT_FAMILIES
)
return f"https://fonts.googleapis.com/css2?{families}&display=swap"
BOOTSTRAP_THEMES = (
"default",
"flatly",
@@ -43,6 +70,10 @@ STATIC_ASSETS = {
"local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
},
"font_css": {
"local": f"{LIBS_STATIC_DIR}/fonts/google-fonts.css",
"cdn": google_fonts_css_url(),
},
"swagger_css": {
"local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui.css",
"cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css",
@@ -53,6 +84,7 @@ STATIC_ASSETS = {
},
}
URL_RE = re.compile(r"url\((['\"]?)(?!data:)(?!https?:)([^)'\"]+)\1\)")
ANY_URL_RE = re.compile(r"url\((['\"]?)(?!data:)([^)'\"]+)\1\)")
def bootstrap_css_asset(theme: str) -> dict[str, str]:
@@ -97,13 +129,49 @@ def download_css_with_assets(url: str, dest: Path) -> None:
download(asset_url, asset_dest)
def download_google_fonts_css(url: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
req = Request(
url,
headers={
"User-Agent": "Mozilla/5.0 pyTorrent installer",
"Accept": "text/css,*/*;q=0.1",
},
)
with urlopen(req, timeout=60) as response:
css = response.read().decode("utf-8", errors="ignore")
if not css.strip():
raise RuntimeError(f"Empty response for {url}")
def replace_url(match: re.Match[str]) -> str:
quote = match.group(1) or ""
asset_url = match.group(2)
parsed = urlparse(asset_url)
if parsed.scheme not in {"http", "https"}:
return match.group(0)
filename = Path(parsed.path).name
if not filename:
return match.group(0)
asset_dest = dest.parent / "files" / filename
if not asset_dest.exists():
download(asset_url, asset_dest)
return f"url({quote}files/{filename}{quote})"
rewritten = ANY_URL_RE.sub(replace_url, css)
tmp = dest.with_suffix(dest.suffix + ".tmp")
tmp.write_text(rewritten, encoding="utf-8")
tmp.replace(dest)
print(f"OK {dest.relative_to(ROOT)}")
def main() -> None:
items = list(STATIC_ASSETS.values())
items.extend(bootstrap_css_asset(theme) for theme in BOOTSTRAP_THEMES)
for item in items:
url = item["cdn"]
dest = ROOT / "pytorrent" / "static" / item["local"]
if dest.suffix == ".css":
if item.get("local") == STATIC_ASSETS["font_css"]["local"]:
download_google_fonts_css(url, dest)
elif dest.suffix == ".css":
download_css_with_assets(url, dest)
else:
download(url, dest)