Merge remote-tracking branch 'origin/main' into claude/cool-albattani-5zalq5

# Conflicts:
#	server/src/qlik/browser.rs
This commit is contained in:
Claude committed 2026-10-14 16:57:08 +00:00
commit 6cba754e06
6 files changed
+79 -44

No files matched your search

+9
View File
@@ -260,6 +260,10 @@ async fn traiter_qlik(code: &str, nom: &Option<String>) {
e.fournisseur_courant = Some(libelle(code, nom)); e.fournisseur_courant = Some(libelle(code, nom));
e.etape = Some("qlik"); 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<Option<usize>> = async { let res: anyhow::Result<Option<usize>> = async {
let articles = crate::ff::pg::pg_get_articles_by_fournisseur(code).await?; let articles = crate::ff::pg::pg_get_articles_by_fournisseur(code).await?;
let mut vus = HashSet::new(); let mut vus = HashSet::new();
@@ -276,6 +280,11 @@ async fn traiter_qlik(code: &str, nom: &Option<String>) {
Ok(Some(n)) Ok(Some(n))
} }
.await; .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 ms = t0.elapsed().as_millis() as i32;
let Ok(pool) = crate::db::pool() else { return }; let Ok(pool) = crate::db::pool() else { return };
match res { match res {
+27 -33
View File
@@ -67,10 +67,9 @@ async fn lancer() -> anyhow::Result<Navigateur> {
.arg("ignore-certificate-errors") .arg("ignore-certificate-errors")
.user_data_dir(&profil) .user_data_dir(&profil)
.launch_timeout(Duration::from_secs(60)) .launch_timeout(Duration::from_secs(60))
// Délai des commandes passées par le gestionnaire du navigateur. Il ne // Délai des commandes internes du gestionnaire CDP. Il ne couvre PAS
// couvre PAS les commandes de page, coupées à 30 s par chromiumoxide : // `page.execute`, borné à 30 s par chromiumoxide : les évaluations
// les scripts longs passent donc par `PageQlik::evaluer`, qui n'attend // longues passent par un relevé périodique (cf. `PageQlik::evaluer`).
// jamais une commande plus de quelques instants.
.request_timeout(Duration::from_secs(24 * 3600)); .request_timeout(Duration::from_secs(24 * 3600));
if let Some(chemin) = chemin_chromium() { if let Some(chemin) = chemin_chromium() {
builder = builder.chrome_executable(chemin); builder = builder.chrome_executable(chemin);
@@ -329,54 +328,49 @@ impl PageQlik {
/// (éventuellement async), appelée avec `args` ; la promesse est attendue et /// (é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). /// 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 /// ⚠️ chromiumoxide abandonne toute commande CDP au bout de 30 s
/// (`REQUEST_TIMEOUT` de `CommandFuture`, insensible à `request_timeout`). /// (« Request timed out. »), quel que soit `request_timeout` : la constante
/// Une extraction Qlik dure plusieurs minutes : l'attendre dans un seul /// est codée en dur dans `CommandFuture`. Une extraction Qlik dure plusieurs
/// `Runtime.evaluate` la faisait échouer sur « Request timed out ». Le /// minutes : attendre la promesse dans un seul `Runtime.evaluate` la faisait
/// script est donc lancé sans être attendu, et son résultat relevé par des /// échouer à coup sûr. Le script est donc lancé sans être attendu, son
/// évaluations courtes jusqu'à ce qu'il soit prêt. /// résultat rangé dans `window`, et relevé par des évaluations courtes.
pub async fn evaluer(&self, source: &str, args: &Value) -> anyhow::Result<Value> { pub async fn evaluer(&self, source: &str, args: &Value) -> anyhow::Result<Value> {
static TRAVAUX: AtomicUsize = AtomicUsize::new(0); static EVALUATIONS: AtomicUsize = AtomicUsize::new(0);
let cle = serde_json::to_string(&format!("__cf_eval_{}", TRAVAUX.fetch_add(1, Ordering::SeqCst)))?; let cle = serde_json::to_string(&format!("__cf_eval_{}", EVALUATIONS.fetch_add(1, Ordering::SeqCst)))?;
let lancement = format!( let lancement = format!(
"(() => {{ const __e = window[{cle}] = {{ fini: false }}; \ "(() => {{ const __k = {cle}; window[__k] = {{ fini: false }}; \
Promise.resolve().then(() => (\n{source}\n)({})).then( \ (async () => {{ const __f = (\n{source}\n); const __r = await __f({args}); return JSON.stringify(__r === undefined ? null : __r); }})()\
(r) => {{ __e.json = JSON.stringify(r === undefined ? null : r); __e.fini = true; }}, \ .then((json) => {{ window[__k] = {{ fini: true, json }}; }}, \
(e) => {{ __e.erreur = String((e && e.stack) || e); __e.fini = true; }}); \ (e) => {{ window[__k] = {{ fini: true, erreur: (e && e.stack) ? String(e.stack) : String(e) }}; }}); \
return true; }})()", return true; }})()",
serde_json::to_string(args)? args = serde_json::to_string(args)?
); );
self.evaluer_court(lancement).await?; self.evaluer_court(lancement).await?;
let releve = format!( let releve = format!(
"(() => {{ const __e = window[{cle}]; if (!__e) return {{ absent: true }}; if (!__e.fini) return null; \ "(() => {{ const __k = {cle}; const e = window[__k]; \
delete window[{cle}]; return __e.erreur !== undefined ? {{ erreur: __e.erreur }} : {{ json: __e.json }}; }})()" 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 { loop {
tokio::time::sleep(pause).await; tokio::time::sleep(Duration::from_millis(500)).await;
pause = (pause * 2).min(Duration::from_secs(1)); let Some(Value::String(etat)) = self.evaluer_court(releve.clone()).await? else { continue };
let etat = self.evaluer_court(releve.clone()).await?; let etat: Value = serde_json::from_str(&etat)?;
if etat.is_null() { if etat.get("perdu").is_some() {
continue;
}
if etat.get("absent").is_some() {
// La page a été rechargée : le script et son état ont disparu.
return Err(anyhow!("Execution context was destroyed (page rechargée pendant l'évaluation)")); 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()) { if let Some(erreur) = etat.get("erreur").and_then(|e| e.as_str()) {
return Err(anyhow!("{erreur}")); return Err(anyhow!("{erreur}"));
} }
return match etat.get("json").and_then(|j| j.as_str()) { 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), None => Ok(Value::Null),
}; };
} }
} }
/// `Runtime.evaluate` d'une expression synchrone, résultat par valeur. /// Un `Runtime.evaluate` qui rend la main aussitôt (valeur JSON brute).
/// Doit rester bien en deçà des 30 s de chromiumoxide (cf. [`Self::evaluer`]). async fn evaluer_court(&self, expression: String) -> anyhow::Result<Option<Value>> {
async fn evaluer_court(&self, expression: String) -> anyhow::Result<Value> {
let params = EvaluateParams::builder() let params = EvaluateParams::builder()
.expression(expression) .expression(expression)
.return_by_value(true) .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()); let message = ex.exception.as_ref().and_then(|e| e.description.clone()).unwrap_or_else(|| ex.text.clone());
return Err(anyhow!("{message}")); 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()`). /// Ferme la page puis le contexte (`ctx.close()`).
+14
View File
@@ -95,6 +95,20 @@ async fn plomberie_cdp() {
let err = page.evaluer("() => { throw new Error('boum'); }", &json!(null)).await.unwrap_err().to_string(); let err = page.evaluer("() => { throw new Error('boum'); }", &json!(null)).await.unwrap_err().to_string();
assert!(err.contains("boum"), "{err}"); 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 // Script embarqué de la recherche, avec ses vrais arguments JSON : le
// websocket Engine (wss) échoue ici faute de Qlik, et le script le dit. // websocket Engine (wss) échoue ici faute de Qlik, et le script le dit.
let args = json!({ let args = json!({
@@ -50,9 +50,11 @@ interface FournisseurRow {
dernierSqlStatut: string | null; dernierSqlStatut: string | null;
dernierSqlLignes: number | null; dernierSqlLignes: number | null;
dernierSqlMs: number | null; dernierSqlMs: number | null;
dernierSqlErreur: string | null;
dernierQlikAt: string | null; dernierQlikAt: string | null;
dernierQlikStatut: string | null; dernierQlikStatut: string | null;
dernierQlikCodes: number | null; dernierQlikCodes: number | null;
dernierQlikErreur: string | null;
} }
interface Resume { interface Resume {
@@ -357,9 +359,23 @@ export function SynchronisationClient() {
{ {
id: "remarque", id: "remarque",
header: "Remarque", header: "Remarque",
cell: (r) => (r.desactiveMotif // Le badge « Échec » seul ne dit pas quoi corriger : on affiche le
? <span className="text-[13px] text-[var(--text-secondary)]">{r.desactiveMotif}</span> // message enregistré par la dernière tentative.
: null), 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 (
<div className="max-w-[480px] space-y-1 text-[13px]">
{r.desactiveMotif && <div className="text-[var(--text-secondary)]">{r.desactiveMotif}</div>}
{erreurs.map((e) => (
<div key={e} className="break-words text-[var(--accent-error)]">{e}</div>
))}
</div>
);
},
}, },
]; ];
@@ -36,7 +36,7 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="text-xs leading-tight text-right whitespace-nowrap hidden xl:block" className={`text-xs leading-tight text-right ${status === "error" ? "max-w-[420px] line-clamp-2" : "whitespace-nowrap hidden xl:block"}`}
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }} style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message || undefined} title={message || undefined}
> >
+9 -7
View File
@@ -110,9 +110,10 @@ export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: Use
if (data.status === "success" || data.status === "error") { if (data.status === "success" || data.status === "error") {
trackingRef.current = false; trackingRef.current = false;
if (data.status === "success") onSuccessRef.current?.(); if (data.status === "success") onSuccessRef.current?.();
// Petit délai pour laisser l'utilisateur lire le message vert/rouge. // Le succès s'efface après un court délai ; une erreur reste
// affichée jusqu'à la prochaine tentative, sinon elle passe inaperçue.
setTimeout(() => { setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur)); setStatus((cur) => (cur === "success" ? "idle" : cur));
}, 4000); }, 4000);
return false; return false;
} }
@@ -159,10 +160,10 @@ export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: Use
applyJobToUi(data); applyJobToUi(data);
startPolling(); startPolling();
} else if (data.status === "success" || data.status === "error") { } else if (data.status === "success" || data.status === "error") {
// Job terminé avant le montage : on l'affiche brièvement. // Job terminé avant le montage : succès affiché brièvement, erreur conservée.
applyJobToUi(data); applyJobToUi(data);
setTimeout(() => { setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur)); setStatus((cur) => (cur === "success" ? "idle" : cur));
}, 4000); }, 4000);
} }
} catch { } catch {
@@ -208,12 +209,13 @@ export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: Use
} }
// Statut terminal direct (cas rare : 0 code → success immédiat). // Statut terminal direct (cas rare : 0 code → success immédiat).
applyJobToUi(data); applyJobToUi(data);
if (data.status === "success") onSuccessRef.current?.(); if (data.status === "success") {
setTimeout(() => setStatus("idle"), 4000); onSuccessRef.current?.();
setTimeout(() => setStatus("idle"), 4000);
}
} catch (e) { } catch (e) {
setStatus("error"); setStatus("error");
setMessage(e instanceof Error ? e.message : String(e)); setMessage(e instanceof Error ? e.message : String(e));
setTimeout(() => setStatus("idle"), 6000);
} }
}, [applyJobToUi, query, startPolling, status, target.mode]); }, [applyJobToUi, query, startPolling, status, target.mode]);