feat(gui): client diagnostics non bloquant + spool best-effort (E3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 10:39:25 +02:00
parent 4b7a31b9df
commit 8eb8cf9999
2 changed files with 175 additions and 0 deletions

View File

@@ -77,3 +77,114 @@ def build_diagnostics_payload(
"failed_count": failed,
"items": clean_items,
}
class DiagnosticsClient:
"""Envoie un payload diagnostic au portail. Non bloquant : capture toute erreur."""
def __init__(
self,
base_url: str,
session: Any,
timeout: float = 4.0,
logger: Optional[Callable[[str], None]] = None,
) -> None:
self._url = base_url.rstrip("/") + REPORT_PATH
self._session = session
self._timeout = timeout
self._log = logger or (lambda _msg: None)
def report(self, payload: dict) -> bool:
try:
resp = self._session.post(self._url, json=payload, timeout=self._timeout)
status = getattr(resp, "status_code", 0)
ok = 200 <= int(status) < 300
if not ok:
self._log(f"diagnostics report refusé (HTTP {status})")
return ok
except Exception as exc: # réseau absent, timeout, etc.
self._log(f"diagnostics report échec (non bloquant) : {exc}")
return False
def report_run_diagnostics(
summary: Any,
*,
base_url: str,
license_ref: Optional[str],
machine_id: Optional[str],
session: Any,
app_name: str = "gui_v6",
app_version: Optional[str] = None,
duration_ms: Optional[int] = None,
run_id: Optional[str] = None,
spool_path: Any = None,
logger: Optional[Callable[[str], None]] = None,
) -> bool:
"""Construit le payload depuis un ``RunSummary`` et l'envoie (non bloquant).
N'envoie RIEN si ``license_ref`` est absent. En cas d'échec réseau, spoole
le payload (si ``spool_path``) pour un rejeu ultérieur. Ne lève jamais.
"""
log = logger or (lambda _msg: None)
if not license_ref:
log("diagnostics ignorés : aucune licence locale valide")
return False
payload = build_diagnostics_payload(
run_id=run_id or new_run_id(),
app_name=app_name,
app_version=app_version,
license_ref=license_ref,
machine_id=machine_id,
duration_ms=duration_ms,
items=items_from_summary(summary),
)
client = DiagnosticsClient(base_url, session=session, logger=log)
ok = client.report(payload)
if not ok and spool_path is not None:
spool_payload(spool_path, payload)
return ok
def spool_payload(path: Any, payload: dict) -> None:
"""Ajoute un payload à la file JSONL locale (ne lève pas)."""
try:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
except Exception:
pass
def flush_spool(path: Any, client: "DiagnosticsClient") -> int:
"""Tente d'envoyer chaque payload en file ; conserve ceux qui échouent.
Retourne le nombre de payloads envoyés. Ne lève jamais.
"""
p = Path(path)
if not p.exists():
return 0
try:
lines = [ln for ln in p.read_text(encoding="utf-8").splitlines() if ln.strip()]
except Exception:
return 0
remaining: list[str] = []
sent = 0
for line in lines:
try:
payload = json.loads(line)
except Exception:
continue
if client.report(payload):
sent += 1
else:
remaining.append(line)
try:
if remaining:
p.write_text("\n".join(remaining) + "\n", encoding="utf-8")
else:
p.unlink(missing_ok=True)
except Exception:
pass
return sent