[IMP] script todo: stats d'install (durée/version) + moyennes par arch/distro

L'historique d'installation est désormais enregistré dans un fichier DÉDIÉ
.venv.erplibre/qemu_install_stats.json (repli ~/.erplibre) : chaque run garde
distro + version + architecture + durée + horodatage (500 derniers).

- record_duration(distro, version, arch, secs) enregistre le run (appelé par
  le dashboard à la complétion d'une VM).
- Menus de sélection enrichis : chaque architecture et chaque distribution
  affiche la DURÉE MOYENNE d'install historique « · ~5m moy (3) » quand la
  donnée existe. La DERNIÈRE install (distro version [arch] — durée) est
  rappelée en tête du déploiement.
- eta_reference lit désormais les runs (médiane par arch, repli global).
  Nouveaux helpers : avg_by_arch, avg_by_distro, last_run.

Validé : enregistrement + moyennes (amd64 ~5m (2), ubuntu ~13m (3)),
dernière install affichée, distros sans données masquées.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-29 10:46:21 +00:00
parent 4c04145c31
commit b2a2db686e
3 changed files with 111 additions and 23 deletions

View file

@ -192,8 +192,14 @@ def _read_new(path: str, offset: int) -> tuple[str, int]:
# Télémétrie, historique de durées (ETA), navigateur CLI
# --------------------------------------------------------------------------- #
def _stats_path() -> Path:
"""Fichier d'historique persistant (durées d'installation par arch)."""
return session_dir() / "stats.json"
"""Fichier d'historique persistant des installations. DÉDIÉ dans
.venv.erplibre du dépôt (repli sur ~/.erplibre si le venv est absent)."""
try:
venv = Path(__file__).resolve().parents[2] / ".venv.erplibre"
venv.mkdir(parents=True, exist_ok=True)
return venv / "qemu_install_stats.json"
except OSError:
return session_dir() / "stats.json"
def load_stats() -> dict:
@ -203,34 +209,68 @@ def load_stats() -> dict:
return {}
def record_duration(arch: str, secs: float) -> None:
"""Mémorise la durée d'une install (par architecture) pour estimer l'ETA
des prochaines. On garde les 20 dernières valeurs par arch."""
def record_duration(distro, version, arch, secs) -> None:
"""Enregistre une install (distro + version + archi + durée + horodatage)
dans l'historique, pour l'ETA et les moyennes par archi/distro. Garde les
500 derniers runs."""
data = load_stats()
durations = data.setdefault("durations", {})
key = arch or "unknown"
lst = durations.setdefault(key, [])
lst.append(int(secs))
durations[key] = lst[-20:]
runs = data.setdefault("runs", [])
runs.append(
{
"distro": distro or "?",
"version": version or "?",
"arch": arch or "?",
"seconds": int(secs),
"ts": int(time.time()),
}
)
data["runs"] = runs[-500:]
try:
_stats_path().write_text(json.dumps(data))
_stats_path().write_text(json.dumps(data, ensure_ascii=False))
except OSError:
pass
def eta_reference(stats: dict, arch: str) -> int | None:
"""Durée d'install de RÉFÉRENCE (médiane) pour cette arch, d'après
l'historique ; repli sur toutes archis confondues. None si aucun historique."""
durations = stats.get("durations", {}) or {}
lst = durations.get(arch or "unknown") or []
if not lst:
lst = [x for v in durations.values() for x in v]
if not lst:
def _runs(stats=None):
return (stats or load_stats()).get("runs", []) or []
def eta_reference(stats, arch):
"""Durée d'install de RÉFÉRENCE (médiane) pour cette archi ; repli toutes
archis confondues. None si aucun historique."""
runs = _runs(stats)
secs = [r["seconds"] for r in runs if r.get("arch") == arch]
if not secs:
secs = [r["seconds"] for r in runs]
if not secs:
return None
s = sorted(lst)
s = sorted(secs)
return s[len(s) // 2]
def _avg(field, value, stats=None):
secs = [r["seconds"] for r in _runs(stats) if r.get(field) == value]
return (sum(secs) / len(secs)) if secs else None
def avg_by_arch(arch, stats=None):
"""(moyenne_secondes, nb_runs) pour cette archi, ou (None, 0)."""
secs = [r["seconds"] for r in _runs(stats) if r.get("arch") == arch]
return (sum(secs) / len(secs), len(secs)) if secs else (None, 0)
def avg_by_distro(distro, stats=None):
"""(moyenne_secondes, nb_runs) pour cette distro, ou (None, 0)."""
secs = [r["seconds"] for r in _runs(stats) if r.get("distro") == distro]
return (sum(secs) / len(secs), len(secs)) if secs else (None, 0)
def last_run(stats=None):
"""Dernier run enregistré (dict) ou None."""
runs = _runs(stats)
return runs[-1] if runs else None
def _fmt_size(nbytes) -> str:
"""Octets -> « 1.2G » / « 345M » / « 12K »."""
if nbytes is None:
@ -540,7 +580,12 @@ def run_monitor(manifest_path: str, run_app: bool = True):
elapsed = now - started
self._final[name] = (state, code, elapsed)
if state == "done":
record_duration(vm.get("arch"), elapsed)
record_duration(
vm.get("distro"),
vm.get("version"),
vm.get("arch"),
elapsed,
)
self._stats = load_stats()
lbl = "" if state == "done" else f"❌ ({code})"
self._set_cell(table, name, "state", lbl)

View file

@ -947,6 +947,37 @@ class TODO:
return self._QEMU_ARM64_DISTROS
return None
def _qemu_last_run_line(self):
"""Ligne « dernière install » (distro version [arch] en durée), depuis
l'historique (.venv.erplibre) ; '' si aucune donnée."""
try:
from script.todo import qemu_install_monitor as mon
r = mon.last_run()
if r:
return (
f" {t('Last install:')} {r.get('distro')} "
f"{r.get('version')} [{r.get('arch')}] — "
f"{mon._fmt_secs(r.get('seconds', 0))}"
)
except Exception:
pass
return ""
def _qemu_stat_avg(self, field, value):
"""Suffixe « · ~5m moy (3) » : durée d'install MOYENNE historique pour
cette archi/distro (fichier .venv.erplibre), ou '' si aucune donnée."""
try:
from script.todo import qemu_install_monitor as mon
fn = mon.avg_by_arch if field == "arch" else mon.avg_by_distro
secs, n = fn(value)
if secs:
return f" · ~{mon._fmt_secs(secs)} {t('avg')} ({n})"
except Exception:
pass
return ""
def _qemu_ask_arch(self, opts, native, allow_all=False):
"""Affiche les architectures `opts` (natif marqué d'un *) et renvoie le
choix. Si `allow_all`, propose aussi [all] = toutes les archis (renvoie
@ -963,7 +994,7 @@ class TODO:
label += f" ({t('ARM 64-bit — emulated, slow')})"
else:
label += f" ({t('emulated, slow')})"
print(f" [{i}] {label}")
print(f" [{i}] {label}{self._qemu_stat_avg('arch', a)}")
if allow_all:
print(f" [all] {t('All supported architectures')}")
sel = (
@ -2408,6 +2439,10 @@ class TODO:
print(f"{t('Cannot load QEMU catalog: ')}{exc}")
return
distros = list(mod.DISTROS)
# Rappel de la dernière installation enregistrée (si historique).
last = self._qemu_last_run_line()
if last:
print(last)
# 0) Architecture du parc (défaut : native ; [all] = TOUTES les archis
# supportées). Pour une arch précise non-amd64, on restreint le
@ -2451,7 +2486,7 @@ class TODO:
vers = ", ".join(
(v + " *" if v == default_v else v) for v in mod.DISTROS[d][0]
)
print(f" [{i}] {d} ({vers})")
print(f" [{i}] {d} ({vers}){self._qemu_stat_avg('distro', d)}")
print(f" [all] {t('Whole catalog (every version)')}")
print(
f" [principal] {t('The main version of each distro (marked *)')}"

View file

@ -1322,6 +1322,14 @@ TRANSLATIONS = {
"fr": "vue",
"en": "view",
},
"avg": {
"fr": "moy",
"en": "avg",
},
"Last install:": {
"fr": "Dernière install :",
"en": "Last install:",
},
"No command found.": {
"fr": "Aucune commande trouvée.",
"en": "No command found.",