diff --git a/server/src/admin/sync_scheduler.rs b/server/src/admin/sync_scheduler.rs index 7e002f4..6560cd7 100644 --- a/server/src/admin/sync_scheduler.rs +++ b/server/src/admin/sync_scheduler.rs @@ -260,6 +260,10 @@ async fn traiter_qlik(code: &str, nom: &Option) { e.fournisseur_courant = Some(libelle(code, nom)); e.etape = Some("qlik"); } + // Journal téléchargeable depuis Paramètres, comme la synchro manuelle : la + // sortie du conteneur ne garde pas les milliers de lignes d'une extraction. + let journal = format!("sync-nuit_qlik_{}_{}", code, Local::now().format("%Y%m%d-%H%M%S")); + crate::logcapture::start_capture(&journal, Some(&format!("synchro de nuit Qlik fournisseur={}", libelle(code, nom)))); let res: anyhow::Result> = async { let articles = crate::ff::pg::pg_get_articles_by_fournisseur(code).await?; let mut vus = HashSet::new(); @@ -276,6 +280,11 @@ async fn traiter_qlik(code: &str, nom: &Option) { Ok(Some(n)) } .await; + let pied = match &res { + Ok(_) => "statut final=success".to_string(), + Err(e) => format!("statut final=error — {e:#}"), + }; + crate::logcapture::stop_capture(&journal, Some(&pied)); let ms = t0.elapsed().as_millis() as i32; let Ok(pool) = crate::db::pool() else { return }; match res { diff --git a/server/src/qlik/browser.rs b/server/src/qlik/browser.rs index c9db8e1..8afcb96 100644 --- a/server/src/qlik/browser.rs +++ b/server/src/qlik/browser.rs @@ -67,10 +67,9 @@ async fn lancer() -> anyhow::Result { .arg("ignore-certificate-errors") .user_data_dir(&profil) .launch_timeout(Duration::from_secs(60)) - // Délai des commandes passées par le gestionnaire du navigateur. Il ne - // couvre PAS les commandes de page, coupées à 30 s par chromiumoxide : - // les scripts longs passent donc par `PageQlik::evaluer`, qui n'attend - // jamais une commande plus de quelques instants. + // Délai des commandes internes du gestionnaire CDP. Il ne couvre PAS + // `page.execute`, borné à 30 s par chromiumoxide : les évaluations + // longues passent par un relevé périodique (cf. `PageQlik::evaluer`). .request_timeout(Duration::from_secs(24 * 3600)); if let Some(chemin) = chemin_chromium() { builder = builder.chrome_executable(chemin); @@ -329,54 +328,49 @@ impl PageQlik { /// (éventuellement async), appelée avec `args` ; la promesse est attendue et /// le résultat rendu en JSON (`JSON.stringify` côté page, donc NaN → null). /// - /// ⚠️ chromiumoxide coupe toute commande de page au bout de 30 s, en dur - /// (`REQUEST_TIMEOUT` de `CommandFuture`, insensible à `request_timeout`). - /// Une extraction Qlik dure plusieurs minutes : l'attendre dans un seul - /// `Runtime.evaluate` la faisait échouer sur « Request timed out ». Le - /// script est donc lancé sans être attendu, et son résultat relevé par des - /// évaluations courtes jusqu'à ce qu'il soit prêt. + /// ⚠️ chromiumoxide abandonne toute commande CDP au bout de 30 s + /// (« Request timed out. »), quel que soit `request_timeout` : la constante + /// est codée en dur dans `CommandFuture`. Une extraction Qlik dure plusieurs + /// minutes : attendre la promesse dans un seul `Runtime.evaluate` la faisait + /// échouer à coup sûr. Le script est donc lancé sans être attendu, son + /// résultat rangé dans `window`, et relevé par des évaluations courtes. pub async fn evaluer(&self, source: &str, args: &Value) -> anyhow::Result { - static TRAVAUX: AtomicUsize = AtomicUsize::new(0); - let cle = serde_json::to_string(&format!("__cf_eval_{}", TRAVAUX.fetch_add(1, Ordering::SeqCst)))?; + static EVALUATIONS: AtomicUsize = AtomicUsize::new(0); + let cle = serde_json::to_string(&format!("__cf_eval_{}", EVALUATIONS.fetch_add(1, Ordering::SeqCst)))?; let lancement = format!( - "(() => {{ const __e = window[{cle}] = {{ fini: false }}; \ - Promise.resolve().then(() => (\n{source}\n)({})).then( \ - (r) => {{ __e.json = JSON.stringify(r === undefined ? null : r); __e.fini = true; }}, \ - (e) => {{ __e.erreur = String((e && e.stack) || e); __e.fini = true; }}); \ + "(() => {{ const __k = {cle}; window[__k] = {{ fini: false }}; \ + (async () => {{ const __f = (\n{source}\n); const __r = await __f({args}); return JSON.stringify(__r === undefined ? null : __r); }})()\ + .then((json) => {{ window[__k] = {{ fini: true, json }}; }}, \ + (e) => {{ window[__k] = {{ fini: true, erreur: (e && e.stack) ? String(e.stack) : String(e) }}; }}); \ return true; }})()", - serde_json::to_string(args)? + args = serde_json::to_string(args)? ); self.evaluer_court(lancement).await?; let releve = format!( - "(() => {{ const __e = window[{cle}]; if (!__e) return {{ absent: true }}; if (!__e.fini) return null; \ - delete window[{cle}]; return __e.erreur !== undefined ? {{ erreur: __e.erreur }} : {{ json: __e.json }}; }})()" + "(() => {{ const __k = {cle}; const e = window[__k]; \ + if (e === undefined) return JSON.stringify({{ perdu: true }}); \ + if (!e.fini) return null; delete window[__k]; return JSON.stringify(e); }})()" ); - let mut pause = Duration::from_millis(20); loop { - tokio::time::sleep(pause).await; - pause = (pause * 2).min(Duration::from_secs(1)); - let etat = self.evaluer_court(releve.clone()).await?; - if etat.is_null() { - continue; - } - if etat.get("absent").is_some() { - // La page a été rechargée : le script et son état ont disparu. + tokio::time::sleep(Duration::from_millis(500)).await; + let Some(Value::String(etat)) = self.evaluer_court(releve.clone()).await? else { continue }; + let etat: Value = serde_json::from_str(&etat)?; + if etat.get("perdu").is_some() { return Err(anyhow!("Execution context was destroyed (page rechargée pendant l'évaluation)")); } if let Some(erreur) = etat.get("erreur").and_then(|e| e.as_str()) { return Err(anyhow!("{erreur}")); } return match etat.get("json").and_then(|j| j.as_str()) { - Some(s) => Ok(serde_json::from_str(s)?), + Some(json) => Ok(serde_json::from_str(json)?), None => Ok(Value::Null), }; } } - /// `Runtime.evaluate` d'une expression synchrone, résultat par valeur. - /// Doit rester bien en deçà des 30 s de chromiumoxide (cf. [`Self::evaluer`]). - async fn evaluer_court(&self, expression: String) -> anyhow::Result { + /// Un `Runtime.evaluate` qui rend la main aussitôt (valeur JSON brute). + async fn evaluer_court(&self, expression: String) -> anyhow::Result> { let params = EvaluateParams::builder() .expression(expression) .return_by_value(true) @@ -387,7 +381,7 @@ impl PageQlik { let message = ex.exception.as_ref().and_then(|e| e.description.clone()).unwrap_or_else(|| ex.text.clone()); return Err(anyhow!("{message}")); } - Ok(res.result.result.value.clone().unwrap_or(Value::Null)) + Ok(res.result.result.value.clone()) } /// Ferme la page puis le contexte (`ctx.close()`). diff --git a/server/src/qlik/tests_navigateur.rs b/server/src/qlik/tests_navigateur.rs index 8606f6d..32fa9d9 100644 --- a/server/src/qlik/tests_navigateur.rs +++ b/server/src/qlik/tests_navigateur.rs @@ -95,6 +95,20 @@ async fn plomberie_cdp() { let err = page.evaluer("() => { throw new Error('boum'); }", &json!(null)).await.unwrap_err().to_string(); assert!(err.contains("boum"), "{err}"); + // Une extraction dure plusieurs minutes : l'évaluation doit dépasser les + // 30 s après lesquelles chromiumoxide abandonne une commande CDP. + let r = page + .evaluer("async ({ ms }) => { await new Promise((r) => setTimeout(r, ms)); return { ok: true, ms }; }", &json!({ "ms": 35_000 })) + .await + .expect("évaluation de 35 s"); + assert_eq!(r, json!({ "ok": true, "ms": 35000 })); + let err = page + .evaluer("async () => { await new Promise((r) => setTimeout(r, 50)); throw new Error('boum asynchrone'); }", &json!(null)) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("boum asynchrone"), "{err}"); + // Script embarqué de la recherche, avec ses vrais arguments JSON : le // websocket Engine (wss) échoue ici faute de Qlik, et le script le dit. let args = json!({ diff --git a/src/app/(dashboard)/admin/synchronisation/client.tsx b/src/app/(dashboard)/admin/synchronisation/client.tsx index 4f0dd8a..deb66b5 100644 --- a/src/app/(dashboard)/admin/synchronisation/client.tsx +++ b/src/app/(dashboard)/admin/synchronisation/client.tsx @@ -50,9 +50,11 @@ interface FournisseurRow { dernierSqlStatut: string | null; dernierSqlLignes: number | null; dernierSqlMs: number | null; + dernierSqlErreur: string | null; dernierQlikAt: string | null; dernierQlikStatut: string | null; dernierQlikCodes: number | null; + dernierQlikErreur: string | null; } interface Resume { @@ -357,9 +359,23 @@ export function SynchronisationClient() { { id: "remarque", header: "Remarque", - cell: (r) => (r.desactiveMotif - ? {r.desactiveMotif} - : null), + // Le badge « Échec » seul ne dit pas quoi corriger : on affiche le + // message enregistré par la dernière tentative. + cell: (r) => { + const erreurs = [ + r.dernierSqlStatut === "echec" && r.dernierSqlErreur ? `${VENTES} : ${r.dernierSqlErreur}` : null, + r.dernierQlikStatut === "echec" && r.dernierQlikErreur ? `${RESEAU} : ${r.dernierQlikErreur}` : null, + ].filter((e): e is string => e !== null); + if (!r.desactiveMotif && erreurs.length === 0) return null; + return ( +
+ {r.desactiveMotif &&
{r.desactiveMotif}
} + {erreurs.map((e) => ( +
{e}
+ ))} +
+ ); + }, }, ]; diff --git a/src/features/grid/components/sync-qlik-button.tsx b/src/features/grid/components/sync-qlik-button.tsx index 4ba5991..af812b7 100644 --- a/src/features/grid/components/sync-qlik-button.tsx +++ b/src/features/grid/components/sync-qlik-button.tsx @@ -36,7 +36,7 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr return (