jobs logs
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user