jobs logs

This commit is contained in:
Mateusz Gruszczyński
2026-06-13 10:28:16 +02:00
parent f1129fd3c4
commit 630521778d
4 changed files with 103 additions and 13 deletions
File diff suppressed because one or more lines are too long
+25
View File
@@ -3069,6 +3069,31 @@ body.mobile-mode .mobile-filter-bar {
outline: 0; outline: 0;
} }
.jobs-toolbar,
.jobs-toolbar-actions,
.jobs-toolbar-toggle {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.jobs-toolbar {
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.jobs-toolbar-actions,
.jobs-toolbar-toggle {
align-items: center;
}
.jobs-show-details {
align-items: center;
margin-bottom: 0;
}
.jobs-table { .jobs-table {
min-width: 1080px; min-width: 1080px;
white-space: normal; white-space: normal;
+9 -1
View File
@@ -190,12 +190,20 @@
<button class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="jobs-toolbar d-flex gap-2 mb-2 flex-wrap align-items-center"> <div class="jobs-toolbar">
<div class="jobs-toolbar-actions">
<button id="refreshJobsBtn" class="btn btn-sm btn-outline-primary" type="button"><i class="fa-solid fa-rotate"></i> Refresh</button> <button id="refreshJobsBtn" class="btn btn-sm btn-outline-primary" type="button"><i class="fa-solid fa-rotate"></i> Refresh</button>
<button id="clearJobsBtn" class="btn btn-sm btn-outline-danger" type="button"><i class="fa-solid fa-trash"></i> Clear finished</button> <button id="clearJobsBtn" class="btn btn-sm btn-outline-danger" type="button"><i class="fa-solid fa-trash"></i> Clear finished</button>
<button id="emergencyClearJobsBtn" class="btn btn-sm btn-danger" type="button"><i class="fa-solid fa-triangle-exclamation"></i> Emergency clean all</button> <button id="emergencyClearJobsBtn" class="btn btn-sm btn-danger" type="button"><i class="fa-solid fa-triangle-exclamation"></i> Emergency clean all</button>
<span class="text-muted small">Pending, running, done, failed, retry and cancel history.</span> <span class="text-muted small">Pending, running, done, failed, retry and cancel history.</span>
</div> </div>
<div class="jobs-toolbar-toggle">
<label class="form-check form-switch jobs-show-details">
<input id="jobsShowDetails" class="form-check-input" type="checkbox">
<span class="form-check-label">Show details</span>
</label>
</div>
</div>
<div id="jobsTable" class="table-responsive"><span class="spinner-border spinner-border-sm"></span> Loading jobs...</div> <div id="jobsTable" class="table-responsive"><span class="spinner-border spinner-border-sm"></span> Loading jobs...</div>
<div id="jobsPager" class="pager-row mt-2"></div> <div id="jobsPager" class="pager-row mt-2"></div>
</div> </div>
+64 -7
View File
@@ -27,6 +27,8 @@ TRACKERS = [
"https://tracker.example.dev/announce", "https://tracker.example.dev/announce",
] ]
CLIENTS = ["qBittorrent/4.6", "Transmission/4.0", "libtorrent/2.0", "Deluge/2.1", "rtorrent/0.9"] CLIENTS = ["qBittorrent/4.6", "Transmission/4.0", "libtorrent/2.0", "Deluge/2.1", "rtorrent/0.9"]
LARGE_TORRENT_DETAIL_THRESHOLD = 50_000
LARGE_TORRENT_TICK_BATCH = 5_000
def xmlrpc_safe(value: Any) -> Any: def xmlrpc_safe(value: Any) -> Any:
@@ -54,6 +56,10 @@ class MockRtorrentState:
def __init__(self, count: int, seed: int, state_file: Path | None = None, persist: bool = False, disk_total_gb: int = 4096, disk_used_percent: float = 68.0): def __init__(self, count: int, seed: int, state_file: Path | None = None, persist: bool = False, disk_total_gb: int = 4096, disk_used_percent: float = 68.0):
self.lock = threading.RLock() self.lock = threading.RLock()
self.started_at = time.time() self.started_at = time.time()
self.seed = seed
self.large_mode = count >= LARGE_TORRENT_DETAIL_THRESHOLD
self.last_tick_at = 0.0
self.tick_cursor = 0
self.state_file = state_file self.state_file = state_file
self.persist = persist self.persist = persist
self.disk_total_bytes = max(1, int(disk_total_gb)) * 1024 * 1024 * 1024 self.disk_total_bytes = max(1, int(disk_total_gb)) * 1024 * 1024 * 1024
@@ -96,6 +102,7 @@ class MockRtorrentState:
down_rate = 0 if complete or not active else rng.randint(50_000, 8_000_000) down_rate = 0 if complete or not active else rng.randint(50_000, 8_000_000)
up_rate = 0 if not active else rng.randint(5_000, 2_000_000) up_rate = 0 if not active else rng.randint(5_000, 2_000_000)
torrent = { torrent = {
"mock_index": index,
"hash": torrent_hash, "hash": torrent_hash,
"name": f"Mock Torrent {index + 1:05d} - {label}", "name": f"Mock Torrent {index + 1:05d} - {label}",
"state": state, "state": state,
@@ -122,8 +129,8 @@ class MockRtorrentState:
"last_activity": now - rng.randint(0, 7 * 86400), "last_activity": now - rng.randint(0, 7 * 86400),
"completed_at": now - rng.randint(0, 180 * 86400) if complete else 0, "completed_at": now - rng.randint(0, 180 * 86400) if complete else 0,
"trackers": rng.sample(TRACKERS, k=rng.randint(1, len(TRACKERS))), "trackers": rng.sample(TRACKERS, k=rng.randint(1, len(TRACKERS))),
"files": self.make_files(index, size, completed, rng), "files": [] if self.large_mode else self.make_files(index, size, completed, rng),
"peers_list": self.make_peers(rng), "peers_list": [] if self.large_mode else self.make_peers(rng),
} }
self.torrents.append(torrent) self.torrents.append(torrent)
self.reindex() self.reindex()
@@ -171,6 +178,34 @@ class MockRtorrentState:
]) ])
return rows return rows
def detail_rng(self, torrent: dict[str, Any] | None, kind: str) -> random.Random:
"""Create deterministic detail RNGs without storing large nested lists."""
index = int((torrent or {}).get("mock_index") or 0)
return random.Random(f"{self.seed}:{kind}:{index}")
def file_rows(self, torrent: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Return stored files or lazily generated files for high-volume mocks."""
if not torrent:
return []
files = torrent.get("files") or []
if files:
return files
if not self.large_mode:
return []
return self.make_files(int(torrent.get("mock_index") or 0), int(torrent.get("size") or 1), int(torrent.get("completed") or 0), self.detail_rng(torrent, "files"))
def peer_rows(self, torrent: dict[str, Any] | None) -> list[list[Any]]:
"""Return stored peers or lazily generated peers for high-volume mocks."""
if not torrent:
return []
peers = torrent.get("peers_list") or []
if peers:
return peers
if not self.large_mode:
return []
return self.make_peers(self.detail_rng(torrent, "peers"))
def reindex(self) -> None: def reindex(self) -> None:
self.by_hash = {str(t["hash"]): t for t in self.torrents} self.by_hash = {str(t["hash"]): t for t in self.torrents}
@@ -178,7 +213,14 @@ class MockRtorrentState:
"""Load optional persisted mock state for repeatable development sessions.""" """Load optional persisted mock state for repeatable development sessions."""
data = json.loads(self.state_file.read_text(encoding="utf-8")) data = json.loads(self.state_file.read_text(encoding="utf-8"))
self.config.update(data.get("config") or {}) self.config.update(data.get("config") or {})
self.seed = int(data.get("seed") or self.seed)
self.torrents = list(data.get("torrents") or []) self.torrents = list(data.get("torrents") or [])
self.large_mode = len(self.torrents) >= LARGE_TORRENT_DETAIL_THRESHOLD
for index, torrent in enumerate(self.torrents):
torrent.setdefault("mock_index", index)
if self.large_mode:
torrent.setdefault("files", [])
torrent.setdefault("peers_list", [])
self.reindex() self.reindex()
def save(self) -> None: def save(self) -> None:
@@ -187,13 +229,27 @@ class MockRtorrentState:
return return
self.state_file.parent.mkdir(parents=True, exist_ok=True) self.state_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.state_file.with_suffix(".tmp") tmp = self.state_file.with_suffix(".tmp")
tmp.write_text(json.dumps({"updated_at": human_now(), "config": self.config, "torrents": self.torrents}), encoding="utf-8") tmp.write_text(json.dumps({"updated_at": human_now(), "seed": self.seed, "config": self.config, "torrents": self.torrents}), encoding="utf-8")
tmp.replace(self.state_file) tmp.replace(self.state_file)
def tick(self) -> None: def tick(self) -> None:
"""Advance speeds, totals and progress on each RPC request.""" """Advance a bounded number of torrents so large mock sets stay responsive."""
now = int(time.time()) now = int(time.time())
for index, torrent in enumerate(self.torrents): if now == int(self.last_tick_at):
return
self.last_tick_at = float(now)
total = len(self.torrents)
if not total:
return
if total <= LARGE_TORRENT_DETAIL_THRESHOLD:
indices = range(total)
else:
batch = min(LARGE_TORRENT_TICK_BATCH, total)
start = self.tick_cursor % total
self.tick_cursor = (start + batch) % total
indices = [(start + offset) % total for offset in range(batch)]
for index in indices:
torrent = self.torrents[index]
if not torrent.get("state") or not torrent.get("is_active"): if not torrent.get("state") or not torrent.get("is_active"):
torrent["down_rate"] = 0 torrent["down_rate"] = 0
torrent["up_rate"] = 0 torrent["up_rate"] = 0
@@ -247,11 +303,11 @@ class MockRtorrentState:
return [[self.torrent_row_value(t, f) for f in fields] for t in self.torrents] return [[self.torrent_row_value(t, f) for f in fields] for t in self.torrents]
if method == "p.multicall": if method == "p.multicall":
torrent = self.by_hash.get(str(args[0])) torrent = self.by_hash.get(str(args[0]))
return torrent.get("peers_list", []) if torrent else [] return self.peer_rows(torrent) if torrent else []
if method == "f.multicall": if method == "f.multicall":
torrent = self.by_hash.get(str(args[0])) torrent = self.by_hash.get(str(args[0]))
fields = args[2:] fields = args[2:]
return [self.file_row(file, fields) for file in (torrent or {}).get("files", [])] return [self.file_row(file, fields) for file in self.file_rows(torrent)]
if method == "t.multicall": if method == "t.multicall":
torrent = self.by_hash.get(str(args[0]) or str(args[1] if len(args) > 1 else "")) torrent = self.by_hash.get(str(args[0]) or str(args[1] if len(args) > 1 else ""))
return [[tracker, 1, 120 + i, 30 + i, 5000 + i] for i, tracker in enumerate((torrent or {}).get("trackers", []))] return [[tracker, 1, 120 + i, 30 + i, 5000 + i] for i, tracker in enumerate((torrent or {}).get("trackers", []))]
@@ -458,6 +514,7 @@ class ScgiXmlRpcHandler(socketserver.BaseRequestHandler):
class ThreadingScgiServer(socketserver.ThreadingTCPServer): class ThreadingScgiServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True allow_reuse_address = True
daemon_threads = True daemon_threads = True
request_queue_size = 128
def fallback_db_path() -> Path: def fallback_db_path() -> Path: