diff --git a/kazeia_central/api/app.py b/kazeia_central/api/app.py index 9cf0e70..486b06e 100644 --- a/kazeia_central/api/app.py +++ b/kazeia_central/api/app.py @@ -22,6 +22,7 @@ from pydantic import BaseModel from starlette.responses import Response from ..adb import Adb, AdbError +from ..fleet import device_overview, fan_out from ..provider import ProviderClient from ..store import BadPassword, Store, StoreLocked @@ -176,6 +177,27 @@ def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI: def sessions(serial: str, profile_id: str | None = None): return [s.model_dump() for s in _guard(lambda: client(serial).sessions(profile_id))] + # ---- flotte (fan-out parc, rapport par device §9) --------------------- + @app.get("/api/fleet/overview") + def fleet_overview(): + report = fan_out(adb, lambda s: device_overview(adb, s)) + labels = store.device_labels() if store.is_unlocked else {} + for r in report: + r["label"] = labels.get(r["serial"]) + return report + + @app.post("/api/fleet/update-check") + def fleet_update_check(): + return fan_out(adb, lambda s: client(s).update_check()) + + @app.post("/api/fleet/update-install") + def fleet_update_install(): + report = fan_out(adb, lambda s: client(s).update_install()) + store.audit("fleet_update_install", actor=_OPERATOR, + detail={"devices": [r["serial"] for r in report if r["ok"]]}, + now=_now_ms()) + return report + # UI web locale servie à la racine — APRÈS les routes /api pour ne pas les # masquer. `html=True` → sert index.html sur "/". if _WEB_DIR.is_dir(): diff --git a/kazeia_central/fleet.py b/kazeia_central/fleet.py new file mode 100644 index 0000000..451278e --- /dev/null +++ b/kazeia_central/fleet.py @@ -0,0 +1,68 @@ +"""Opérations de flotte (fan-out) pour Kazeia-central. + +§9 du contrat : toute action parc se fait par **fan-out séquentiel par device**, et +les commandes adb **échouent silencieusement par tablette** → on capture le résultat +OU l'erreur de CHAQUE device, jamais un échec global qui masquerait les autres. Le +rapport renvoyé est une liste `{serial, ok, result|error}` exploitable telle quelle. + +Séquentiel (pas parallèle) volontairement : conforme au contrat, et un `adb` partagé +sature vite ; pour un parc de quelques tablettes le coût est négligeable. +""" + +from __future__ import annotations + +from typing import Any, Callable + +from .adb import Adb +from .provider import ProviderClient + + +def fan_out(adb: Adb, fn: Callable[[str], Any], *, + serials: list[str] | None = None) -> list[dict[str, Any]]: + """Exécute `fn(serial)` sur chaque tablette prête (ou la sous-liste `serials`), + en isolant les échecs. Renvoie un rapport par device.""" + ready = adb.ready_devices() + if serials is not None: + wanted = set(serials) + ready = [d for d in ready if d.serial in wanted] + report: list[dict[str, Any]] = [] + for d in ready: + try: + report.append({"serial": d.serial, "ok": True, "result": fn(d.serial)}) + except Exception as e: # adb/provider peut échouer pour CE device seulement + report.append({"serial": d.serial, "ok": False, "error": str(e)}) + return report + + +def device_overview(adb: Adb, serial: str) -> dict[str, Any]: + """Résumé santé d'une tablette pour la vue parc. Chaque sous-requête est isolée : + une section indisponible ne fait pas tomber tout le résumé (renvoi partiel).""" + c = ProviderClient(adb, serial) + + def safe(f, default=None): + try: + return f() + except Exception: + return default + + state = safe(lambda: c.state().model_dump()) + updates = safe(lambda: c.updates().model_dump()) + rag = safe(lambda: c.rag_status().model_dump()) + profiles = safe(lambda: c.profiles(), []) + sessions = safe(lambda: c.sessions(), []) + crashes = safe(lambda: c.crashes(), []) + + g = lambda d, k: (d or {}).get(k) + return { + "serial": serial, + "online": state is not None, + "uptime_s": g(state, "uptime_seconds"), + "mem_available_mb": g(state, "mem_available_mb"), + "update_phase": g(updates, "phase"), + "app_update_available": g(updates, "app_update_available"), + "rag_ready": g(rag, "ready"), + "rag_docs": g(rag, "doc_count"), + "profiles": len(profiles), + "sessions": len(sessions), + "crashes": len(crashes), + } diff --git a/kazeia_central/web/index.html b/kazeia_central/web/index.html index c138cdd..95718d1 100644 --- a/kazeia_central/web/index.html +++ b/kazeia_central/web/index.html @@ -72,6 +72,7 @@ console de flotte
+
@@ -173,6 +174,51 @@ async function loadDevices() { } } +// ---- vue parc (fan-out) -------------------------------------------------- +async function fleetView() { + selected = null; loadDevices(); + const d = $("detail"); + d.innerHTML = `
interrogation du parc…
`; + try { + const rows = await api("/fleet/overview"); + const yn = (v) => v == null ? "—" : (v ? "oui" : "non"); + const body = rows.map(r => { + const o = r.result || {}; + const who = r.label ? `👤 ${r.label}` : `${r.serial}`; + if (!r.ok) return `${who}${r.error}`; + return ` + ${who} + ${dot(o.online)}${o.online?"en ligne":"hors-ligne"} + ${o.uptime_s ?? "—"} + ${o.mem_available_mb ?? "—"} + ${o.update_phase ?? "—"}${o.app_update_available?" ⬆":""} + ${o.rag_ready?dot(true):dot(false)}${o.rag_docs ?? 0} + ${o.profiles ?? 0} + ${o.sessions ?? 0} + ${o.crashes ? `${o.crashes}` : 0}`; + }).join(""); + d.innerHTML = + `
+ + +
+ + + + ${body || ``} +
tabletteétatuptime(s)RAM(Mo)MAJRAGprofilssessionscrashes
aucune tablette prête
`; + } catch (e) { d.innerHTML = `
vue parc : ${e.message}
`; } +} + +async function fleetUpdateCheck() { + $("fleetmsg").textContent = "vérification en cours…"; + try { + const rep = await api("/fleet/update-check", {method:"POST"}); + const ok = rep.filter(r => r.ok).length; + $("fleetmsg").textContent = `${ok}/${rep.length} tablette(s) ont accepté la vérification`; + } catch (e) { $("fleetmsg").textContent = e.message; } +} + function card(title, body, cls="") { return `

${title}

${body}
`; } function kv(k, v) { return `
${k}${v ?? "—"}
`; } function dot(on) { return ``; } diff --git a/tests/test_fleet.py b/tests/test_fleet.py new file mode 100644 index 0000000..418a4f9 --- /dev/null +++ b/tests/test_fleet.py @@ -0,0 +1,88 @@ +"""Tests du fan-out flotte : isolation des échecs par device (§9) + overview.""" + +import nacl.pwhash +from fastapi.testclient import TestClient + +from kazeia_central.adb import Adb, AdbError +from kazeia_central.adb.client import AdbDevice +from kazeia_central.api.app import create_app +from kazeia_central.fleet import device_overview, fan_out +from kazeia_central.store import Store + + +class _ReadyAdb(Adb): + """adb factice : liste de tablettes prêtes, content_query cannée.""" + def __init__(self, serials, canned=None, fail_paths=()): + super().__init__() + self._serials = serials + self._canned = canned or {} + self._fail = set(fail_paths) + + def ready_devices(self): + return [AdbDevice(s, "device", {"model": "Pad3"}) for s in self._serials] + + def content_query(self, path, *, serial=None, where=None, expect_rows=False, _retry=True): + if path in self._fail: + raise AdbError(f"{path} indispo") + return self._canned.get(path, []) + + +_CANNED = { + "state": [{"pid": 1, "uptime_seconds": 100, "pss_mb": 2, "ion_mb": 3, "mem_available_mb": 500}], + "updates": [{"phase": "UPTODATE", "app_update_available": 0}], + "rag_status": [{"enabled": 1, "ready": 1, "doc_count": 18}], + "profiles": [{"id": "p1"}, {"id": "p2"}], + "conversations": [{"profile_id": "p1", "session_id": "s1"}], + "crashes": [], +} + + +def test_fan_out_isolates_failures(): + adb = _ReadyAdb(["D1", "D2", "D3"]) + + def fn(serial): + if serial == "D2": + raise RuntimeError("boom sur D2") + return f"ok-{serial}" + + rep = fan_out(adb, fn) + assert [r["serial"] for r in rep] == ["D1", "D2", "D3"] + assert rep[0] == {"serial": "D1", "ok": True, "result": "ok-D1"} + assert rep[1]["ok"] is False and "boom sur D2" in rep[1]["error"] + assert rep[2]["ok"] is True # D3 non affecté par l'échec de D2 + + +def test_fan_out_respects_serials_subset(): + adb = _ReadyAdb(["D1", "D2", "D3"]) + rep = fan_out(adb, lambda s: s, serials=["D1", "D3"]) + assert [r["serial"] for r in rep] == ["D1", "D3"] + + +def test_fan_out_no_ready_devices(): + assert fan_out(_ReadyAdb([]), lambda s: s) == [] + + +def test_device_overview_full(): + o = device_overview(_ReadyAdb(["D1"], _CANNED), "D1") + assert o["online"] and o["uptime_s"] == 100 and o["mem_available_mb"] == 500 + assert o["rag_docs"] == 18 and o["profiles"] == 2 and o["sessions"] == 1 and o["crashes"] == 0 + assert o["update_phase"] == "UPTODATE" + + +def test_device_overview_partial_when_state_down(): + # /state KO → online False, mais le reste reste compté (renvoi partiel) + o = device_overview(_ReadyAdb(["D1"], _CANNED, fail_paths=["state"]), "D1") + assert o["online"] is False and o["uptime_s"] is None + assert o["profiles"] == 2 and o["rag_docs"] == 18 + + +def test_fleet_overview_endpoint_merges_labels(tmp_path): + store = Store(tmp_path / "c.db", + kdf_ops=nacl.pwhash.argon2id.OPSLIMIT_MIN, + kdf_mem=nacl.pwhash.argon2id.MEMLIMIT_MIN) + c = TestClient(create_app(adb=_ReadyAdb(["D1"], _CANNED), store=store)) + c.post("/api/unlock", json={"password": "pw"}) + c.put("/api/devices/D1/label", json={"label": "Mme D."}) + rep = c.get("/api/fleet/overview").json() + assert rep[0]["ok"] and rep[0]["label"] == "Mme D." + assert rep[0]["result"]["profiles"] == 2