fix: silent pipeline log and server indexing progress

Three issues caused the Logs view to appear blank during a real pipeline run:

1. `from run import step_fetch, step_link` was outside the worker's
   try/except/finally. An import failure silently killed the thread,
   leaving _pipeline_done uncalled and the Run button stuck disabled
   forever. Now wrapped in its own try/except that posts the error to
   the log and resets the UI.

2. `build_server_index` makes N sequential HTTP requests (one per mod
   folder's meta.cpp) with no output during the scan. Added an optional
   `progress_fn(current, total, name)` callback; step_fetch wires it to
   print progress every 25 folders so the log never goes silent.

3. No immediate feedback after clicking Start — the log was blank until
   the worker thread started printing. Now posts a "Pipeline started"
   banner from the main thread before the worker launches.
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-08 23:35:26 +07:00
parent e0c2dfb32a
commit 3276f4b63f
5 changed files with 38 additions and 5 deletions

View File

@@ -112,6 +112,10 @@ Pass 2 builds `ok_disk_names` — the set of disk names that already match the s
**GUI threading model:** Every network or long-running operation runs in a `threading.Thread(daemon=True)` so the Tkinter event loop is never blocked. The only safe way to update widgets from a background thread is `self.after(0, callback)` — never touch widgets directly from a worker thread. `_poll_log` drains the entire log queue in one `after(80, ...)` tick and does a single batched `CTkTextbox.insert()` call rather than one per log entry, keeping the UI smooth even when `tqdm` emits many rapid updates during downloads. The wizard's "Test Connection" button follows the same pattern: `requests.get` runs in a daemon thread; the result is posted back via `self.after(0, ...)` with widget references captured *before* the thread starts, so stale references cannot update the wrong widgets if the user navigates away mid-request.
**`run_pipeline` worker — import guard:** `from run import step_fetch, step_link` is performed inside its own `try/except` *before* stdout is redirected. If this import fails for any reason the exception is posted to the log via `self.after(0, ...)` and `_pipeline_done` is called so the UI resets cleanly. Previously an import failure would silently kill the worker thread and leave the pipeline button disabled forever.
**`build_server_index` progress callback:** Accepts an optional `progress_fn(current, total, name)` callback. `step_fetch` in `run.py` uses this to print `Indexing N/M: @FolderName` every 25 folders so the log never goes silent during the server scan phase. The library itself never calls `print` — the caller owns the I/O.
### GUI localization (`gui/locales.py`)
All user-facing strings are centralised in `gui/locales.py`. Two languages are supported: English (`"en"`) and Vietnamese (`"vi"`).

View File

@@ -81,7 +81,11 @@ def make_session(auth: tuple[str, str]) -> requests.Session:
return s
def build_server_index(base_url: str, auth: tuple[str, str]) -> dict:
def build_server_index(
base_url: str,
auth: tuple[str, str],
progress_fn: "Callable[[int, int, str], None] | None" = None,
) -> dict:
"""
Scan the root of the file server and build mod lookup maps.
@@ -90,6 +94,9 @@ def build_server_index(base_url: str, auth: tuple[str, str]) -> dict:
:param base_url: Root URL of the Caddy file server (trailing slash optional).
:param auth: ``(username, password)`` tuple for HTTP Basic Auth.
:param progress_fn: Optional callback called as ``progress_fn(current, total, name)``
after each folder is processed. Use it to report progress without
coupling the library to ``print`` or any specific I/O sink.
:returns: Dict with keys:
- ``by_steam_id`` — ``{steam_id: folder_url}``
@@ -100,11 +107,12 @@ def build_server_index(base_url: str, auth: tuple[str, str]) -> dict:
root = base_url.rstrip("/") + "/"
items = _list_dir(root, session)
folders = [it for it in items if it.get("is_dir")]
total = len(folders)
by_steam_id: dict[str, str] = {}
by_name: dict[str, str] = {}
for folder in folders:
for i, folder in enumerate(folders, 1):
name = folder["name"].strip("/")
url = _folder_url(root, name)
by_name[_normalize_name(name)] = url
@@ -118,6 +126,9 @@ def build_server_index(base_url: str, auth: tuple[str, str]) -> dict:
except requests.RequestException:
pass # meta.cpp missing or unreachable — name-based fallback still works
if progress_fn is not None:
progress_fn(i, total, name)
return {"by_steam_id": by_steam_id, "by_name": by_name, "folders": folders}

View File

@@ -143,11 +143,22 @@ class ArmaModManagerApp(ctk.CTk):
self._pipeline_running = True
self._get_dashboard().set_pipeline_ui(running=True)
self.navigate_to("Logs")
# Post an immediate banner so the log is never blank after clicking Start.
_sep = "=" * 50
self.post_log(f"\n{_sep}\n {t('pipeline.starting')}\n{_sep}\n\n")
def worker() -> None:
# run.py calls fix_console_encoding() at import time, which needs
# the real sys.stdout.buffer. Import it before we redirect stdout.
try:
from run import step_fetch, step_link
except Exception as _import_err:
self.after(0, lambda: self.post_log(
f"\n✗ Failed to load pipeline: {_import_err}\n"
))
self.after(0, self._pipeline_done)
return
self._redirect_output()
try:
from arma_modlist_tools.parser import parse_modlist_html

View File

@@ -26,6 +26,7 @@ _EN: dict[str, str] = {
"nav.settings": "Settings",
# ── Pipeline step headers (printed to log) ───────────────────────────────
"pipeline.starting": "Pipeline started",
"pipeline.step1_name": "Parse presets",
"pipeline.step2_name": "Compare presets",
"pipeline.step3_name": "Download mods",
@@ -266,6 +267,7 @@ _VI: dict[str, str] = {
"nav.settings": "Cài đặt",
# ── Pipeline step headers ────────────────────────────────────────────────
"pipeline.starting": "Pipeline đã bắt đầu",
"pipeline.step1_name": "Phân tích preset",
"pipeline.step2_name": "So sánh preset",
"pipeline.step3_name": "Tải mod",

9
run.py
View File

@@ -91,9 +91,14 @@ def step_fetch(cfg) -> None:
for mod in data["mods"]:
queue.append((mod, preset_name))
def _index_progress(current: int, total: int, name: str) -> None:
if current == 1 or current % 25 == 0 or current == total:
print(f" Indexing {current}/{total}: {name}")
print(f" Building server index...")
index = build_server_index(cfg.server_url, cfg.server_auth)
print(f" Indexed {len(index['by_steam_id'])} mods\n")
index = build_server_index(cfg.server_url, cfg.server_auth, progress_fn=_index_progress)
print(f" Indexed {len(index['by_steam_id'])} mods by steam_id, "
f"{len(index['by_name'])} by name\n")
session = make_session(cfg.server_auth)
resolved = []