pbordescnil commited on
Commit
7ae57ee
·
1 Parent(s): fd71ccc

Cancel searches when clients leave

Browse files
application_neo4j/README.md CHANGED
@@ -145,3 +145,11 @@ La création initiale des projections GDS reste sérialisée afin que deux
145
  requêtes arrivant au démarrage ne tentent pas de créer le même graphe. Une fois
146
  les projections disponibles, les BFS et la construction de leurs résultats
147
  s'exécutent indépendamment.
 
 
 
 
 
 
 
 
 
145
  requêtes arrivant au démarrage ne tentent pas de créer le même graphe. Une fois
146
  les projections disponibles, les BFS et la construction de leurs résultats
147
  s'exécutent indépendamment.
148
+
149
+ Lorsqu'un utilisateur quitte la page pendant une recherche, le navigateur
150
+ envoie une demande d'annulation. Un job encore en file est retiré
151
+ immédiatement. Pour un job actif, l'application termine la transaction Neo4j
152
+ identifiée par les métadonnées du job, puis vérifie aussi un drapeau
153
+ d'annulation entre les lots de construction du résultat. Le signal envoyé par
154
+ le navigateur est une garantie au mieux : une fermeture brutale du processus
155
+ ou une coupure réseau peut empêcher son émission.
application_neo4j/app.py CHANGED
@@ -1,7 +1,7 @@
1
  # --- Import et initialisation Flask ---
2
  import os
3
  from flask import Flask, request, render_template, jsonify, session, redirect, url_for
4
- from neo4j import GraphDatabase, basic_auth
5
  from graphdatascience import GraphDataScience
6
  import app_algorithms as algo
7
  from translations import t
@@ -51,6 +51,21 @@ search_jobs = {}
51
  search_jobs_lock = Lock()
52
  graph_projection_lock = Lock()
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  # --- Configuration des arguments du script ---
55
  parser = argparse.ArgumentParser(description="Script pour lancer la web application.")
56
 
@@ -212,6 +227,7 @@ def inject_i18n():
212
  "search.progress_stage_relationships", "search.progress_stage_formatting",
213
  "search.progress_stage_highlights",
214
  "search.progress_stage_completed", "search.progress_stage_failed",
 
215
  "search.progress_elapsed", "search.progress_items",
216
  "search.progress_server_percent",
217
  "search.progress_remaining_step", "search.progress_server_note",
@@ -343,6 +359,7 @@ def execute_search_job(job_id, name, depth, is_unlimited, filters, expert, lang)
343
  graph_data = result["graph_data"]
344
 
345
  try:
 
346
  update_search_job(job_id, status="running", started_at=time())
347
  def report_gds_stage(stage, gds_job_id):
348
  set_search_stage(job_id, stage, gds_job_id)
@@ -354,6 +371,7 @@ def execute_search_job(job_id, name, depth, is_unlimited, filters, expert, lang)
354
  search_job_id=job_id,
355
  progress_callback=report_gds_stage,
356
  )
 
357
 
358
  natural_graph_name = f"{GDS_GRAPH_NAME}_natural"
359
  reverse_graph_name = f"{GDS_GRAPH_NAME}_reverse"
@@ -367,6 +385,8 @@ def execute_search_job(job_id, name, depth, is_unlimited, filters, expert, lang)
367
  expert,
368
  job_id=job_id,
369
  progress_callback=report_gds_stage,
 
 
370
  )
371
 
372
  if not gds_result:
@@ -385,6 +405,8 @@ def execute_search_job(job_id, name, depth, is_unlimited, filters, expert, lang)
385
  process_gds_bfs_results(
386
  gds_result,
387
  graph_data,
 
 
388
  progress_callback=report_result_progress,
389
  )
390
 
@@ -392,6 +414,7 @@ def execute_search_job(job_id, name, depth, is_unlimited, filters, expert, lang)
392
  if not graph_data["nodes"] and not graph_data["edges"]:
393
  result["message"] = t("error.no_neighbors", lang, name=name)
394
  elif gds_result["source_label"] == "Model":
 
395
  set_search_stage(job_id, "building_highlights")
396
  result["highlights"] = algo.get_genealogy_highlights(
397
  gds, name, lang=lang
@@ -401,6 +424,7 @@ def execute_search_job(job_id, name, depth, is_unlimited, filters, expert, lang)
401
  elif not graph_data["nodes"] and not graph_data["edges"]:
402
  result["message"] = t("error.no_neighbors", lang, name=name)
403
 
 
404
  update_search_job(
405
  job_id,
406
  status="completed",
@@ -412,7 +436,32 @@ def execute_search_job(job_id, name, depth, is_unlimited, filters, expert, lang)
412
  completed_at=time(),
413
  result=result,
414
  )
 
 
 
 
 
 
 
 
 
 
 
 
415
  except Exception as error:
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  if "Failed to find a node" in str(error):
417
  message = t("error.node_not_found", lang, name=name)
418
  else:
@@ -460,6 +509,38 @@ def get_gds_progress(gds_job_id):
460
  return None
461
 
462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  @app.route("/api/search-jobs", methods=["POST"])
464
  def create_search_job():
465
  cleanup_search_jobs()
@@ -500,9 +581,11 @@ def create_search_job():
500
  "total_items": None,
501
  "lang": lang,
502
  "result": None,
 
 
503
  }
504
 
505
- search_executor.submit(
506
  execute_search_job,
507
  job_id,
508
  name,
@@ -512,11 +595,55 @@ def create_search_job():
512
  expert,
513
  lang,
514
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
 
516
  return jsonify({
517
  "job_id": job_id,
518
  "status_url": url_for("search_job_status", job_id=job_id),
519
  "result_url": url_for("search_job_result", job_id=job_id, lang=lang),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  }), 202
521
 
522
 
@@ -747,6 +874,8 @@ def findnode_expert():
747
  def process_gds_bfs_results(
748
  gds_result: Dict,
749
  graph_data: Dict,
 
 
750
  progress_callback=None,
751
  ):
752
  """
@@ -805,6 +934,8 @@ def process_gds_bfs_results(
805
 
806
  with driver.session() as session:
807
  for batch_start in range(0, total_nodes, RESULT_BATCH_SIZE):
 
 
808
  batch_ids = discovered_node_ids[
809
  batch_start:batch_start + RESULT_BATCH_SIZE
810
  ]
@@ -812,7 +943,7 @@ def process_gds_bfs_results(
812
  {"id": node_id, "distance": distance_by_node[node_id]}
813
  for node_id in batch_ids
814
  ]
815
- record = session.run(
816
  """
817
  UNWIND $nodes AS node_info
818
  MATCH (n) WHERE id(n) = node_info.id
@@ -848,6 +979,10 @@ def process_gds_bfs_results(
848
  distance: node_info.distance
849
  }) AS nodes_data
850
  """,
 
 
 
 
851
  {"nodes": batch_nodes},
852
  ).single()
853
  if record:
@@ -869,7 +1004,9 @@ def process_gds_bfs_results(
869
  if progress_callback:
870
  progress_callback("building_relationships", 0, 0)
871
 
872
- count_record = session.run(
 
 
873
  """
874
  UNWIND $all_ids AS source_id
875
  MATCH (source) WHERE id(source) = source_id
@@ -877,6 +1014,10 @@ def process_gds_bfs_results(
877
  WHERE id(target) IN $all_ids
878
  RETURN count(relationship) AS relationship_count
879
  """,
 
 
 
 
880
  {"all_ids": discovered_node_ids},
881
  ).single()
882
  total_relationships = (
@@ -889,10 +1030,12 @@ def process_gds_bfs_results(
889
  )
890
 
891
  for batch_start in range(0, total_nodes, RESULT_BATCH_SIZE):
 
 
892
  source_ids = discovered_node_ids[
893
  batch_start:batch_start + RESULT_BATCH_SIZE
894
  ]
895
- record = session.run(
896
  """
897
  UNWIND $source_ids AS source_id
898
  MATCH (source) WHERE id(source) = source_id
@@ -904,6 +1047,10 @@ def process_gds_bfs_results(
904
  targetName: endNode(relationship).name
905
  }) AS relationships
906
  """,
 
 
 
 
907
  {
908
  "source_ids": source_ids,
909
  "all_ids": discovered_node_ids,
@@ -930,6 +1077,8 @@ def process_gds_bfs_results(
930
  total_relationships,
931
  )
932
 
 
 
933
  if progress_callback:
934
  progress_callback("formatting_result", 0, 0)
935
 
 
1
  # --- Import et initialisation Flask ---
2
  import os
3
  from flask import Flask, request, render_template, jsonify, session, redirect, url_for
4
+ from neo4j import GraphDatabase, Query, basic_auth
5
  from graphdatascience import GraphDataScience
6
  import app_algorithms as algo
7
  from translations import t
 
51
  search_jobs_lock = Lock()
52
  graph_projection_lock = Lock()
53
 
54
+
55
+ class SearchCancelled(Exception):
56
+ """Raised by a worker when its browser no longer needs the result."""
57
+
58
+
59
+ def search_cancel_requested(job_id):
60
+ with search_jobs_lock:
61
+ job = search_jobs.get(job_id)
62
+ return bool(job and job.get("cancel_requested"))
63
+
64
+
65
+ def raise_if_search_cancelled(job_id):
66
+ if search_cancel_requested(job_id):
67
+ raise SearchCancelled()
68
+
69
  # --- Configuration des arguments du script ---
70
  parser = argparse.ArgumentParser(description="Script pour lancer la web application.")
71
 
 
227
  "search.progress_stage_relationships", "search.progress_stage_formatting",
228
  "search.progress_stage_highlights",
229
  "search.progress_stage_completed", "search.progress_stage_failed",
230
+ "search.progress_stage_cancelled",
231
  "search.progress_elapsed", "search.progress_items",
232
  "search.progress_server_percent",
233
  "search.progress_remaining_step", "search.progress_server_note",
 
359
  graph_data = result["graph_data"]
360
 
361
  try:
362
+ raise_if_search_cancelled(job_id)
363
  update_search_job(job_id, status="running", started_at=time())
364
  def report_gds_stage(stage, gds_job_id):
365
  set_search_stage(job_id, stage, gds_job_id)
 
371
  search_job_id=job_id,
372
  progress_callback=report_gds_stage,
373
  )
374
+ raise_if_search_cancelled(job_id)
375
 
376
  natural_graph_name = f"{GDS_GRAPH_NAME}_natural"
377
  reverse_graph_name = f"{GDS_GRAPH_NAME}_reverse"
 
385
  expert,
386
  job_id=job_id,
387
  progress_callback=report_gds_stage,
388
+ neo4j_driver=driver,
389
+ cancel_check=lambda: raise_if_search_cancelled(job_id),
390
  )
391
 
392
  if not gds_result:
 
405
  process_gds_bfs_results(
406
  gds_result,
407
  graph_data,
408
+ job_id=job_id,
409
+ cancel_check=lambda: raise_if_search_cancelled(job_id),
410
  progress_callback=report_result_progress,
411
  )
412
 
 
414
  if not graph_data["nodes"] and not graph_data["edges"]:
415
  result["message"] = t("error.no_neighbors", lang, name=name)
416
  elif gds_result["source_label"] == "Model":
417
+ raise_if_search_cancelled(job_id)
418
  set_search_stage(job_id, "building_highlights")
419
  result["highlights"] = algo.get_genealogy_highlights(
420
  gds, name, lang=lang
 
424
  elif not graph_data["nodes"] and not graph_data["edges"]:
425
  result["message"] = t("error.no_neighbors", lang, name=name)
426
 
427
+ raise_if_search_cancelled(job_id)
428
  update_search_job(
429
  job_id,
430
  status="completed",
 
436
  completed_at=time(),
437
  result=result,
438
  )
439
+ except SearchCancelled:
440
+ update_search_job(
441
+ job_id,
442
+ status="cancelled",
443
+ stage="cancelled",
444
+ gds_job_id=None,
445
+ application_progress_percent=None,
446
+ completed_items=None,
447
+ total_items=None,
448
+ completed_at=time(),
449
+ result=None,
450
+ )
451
  except Exception as error:
452
+ if search_cancel_requested(job_id):
453
+ update_search_job(
454
+ job_id,
455
+ status="cancelled",
456
+ stage="cancelled",
457
+ gds_job_id=None,
458
+ application_progress_percent=None,
459
+ completed_items=None,
460
+ total_items=None,
461
+ completed_at=time(),
462
+ result=None,
463
+ )
464
+ return
465
  if "Failed to find a node" in str(error):
466
  message = t("error.node_not_found", lang, name=name)
467
  else:
 
509
  return None
510
 
511
 
512
+ def terminate_search_transactions(job_id):
513
+ """Terminate any active Neo4j transaction tagged for this search."""
514
+ try:
515
+ with driver.session() as neo4j_session:
516
+ records = neo4j_session.run(
517
+ """
518
+ SHOW TRANSACTIONS
519
+ YIELD transactionId, metaData, status
520
+ WHERE metaData.search_job_id = $job_id
521
+ AND NOT status STARTS WITH 'Terminated'
522
+ RETURN transactionId
523
+ """,
524
+ {"job_id": job_id},
525
+ )
526
+ transaction_ids = [
527
+ record["transactionId"] for record in records
528
+ ]
529
+ if transaction_ids:
530
+ neo4j_session.run(
531
+ """
532
+ TERMINATE TRANSACTIONS $transaction_ids
533
+ YIELD transactionId, message
534
+ RETURN transactionId, message
535
+ """,
536
+ {"transaction_ids": transaction_ids},
537
+ ).consume()
538
+ except Exception as error:
539
+ # The cooperative cancellation flag still stops the worker between
540
+ # batches if transaction termination is unavailable.
541
+ print(f"Could not terminate transactions for {job_id}: {error}")
542
+
543
+
544
  @app.route("/api/search-jobs", methods=["POST"])
545
  def create_search_job():
546
  cleanup_search_jobs()
 
581
  "total_items": None,
582
  "lang": lang,
583
  "result": None,
584
+ "cancel_requested": False,
585
+ "future": None,
586
  }
587
 
588
+ future = search_executor.submit(
589
  execute_search_job,
590
  job_id,
591
  name,
 
595
  expert,
596
  lang,
597
  )
598
+ with search_jobs_lock:
599
+ job = search_jobs.get(job_id)
600
+ if job:
601
+ job["future"] = future
602
+ cancel_requested = job.get("cancel_requested")
603
+ else:
604
+ cancel_requested = True
605
+ if cancel_requested and future.cancel():
606
+ update_search_job(
607
+ job_id,
608
+ status="cancelled",
609
+ stage="cancelled",
610
+ completed_at=time(),
611
+ )
612
 
613
  return jsonify({
614
  "job_id": job_id,
615
  "status_url": url_for("search_job_status", job_id=job_id),
616
  "result_url": url_for("search_job_result", job_id=job_id, lang=lang),
617
+ "cancel_url": url_for("cancel_search_job", job_id=job_id),
618
+ }), 202
619
+
620
+
621
+ @app.route("/api/search-jobs/<job_id>/cancel", methods=["POST"])
622
+ def cancel_search_job(job_id):
623
+ with search_jobs_lock:
624
+ job = search_jobs.get(job_id)
625
+ if not job:
626
+ return jsonify({"error": "Search job not found"}), 404
627
+ if job["status"] in ("completed", "failed", "cancelled"):
628
+ return jsonify({"status": job["status"]})
629
+ job["cancel_requested"] = True
630
+ job["cancel_requested_at"] = time()
631
+ future = job.get("future")
632
+
633
+ cancelled_before_start = bool(future and future.cancel())
634
+ if cancelled_before_start:
635
+ update_search_job(
636
+ job_id,
637
+ status="cancelled",
638
+ stage="cancelled",
639
+ completed_at=time(),
640
+ result=None,
641
+ )
642
+ else:
643
+ terminate_search_transactions(job_id)
644
+
645
+ return jsonify({
646
+ "status": "cancelled" if cancelled_before_start else "cancelling"
647
  }), 202
648
 
649
 
 
874
  def process_gds_bfs_results(
875
  gds_result: Dict,
876
  graph_data: Dict,
877
+ job_id=None,
878
+ cancel_check=None,
879
  progress_callback=None,
880
  ):
881
  """
 
934
 
935
  with driver.session() as session:
936
  for batch_start in range(0, total_nodes, RESULT_BATCH_SIZE):
937
+ if cancel_check:
938
+ cancel_check()
939
  batch_ids = discovered_node_ids[
940
  batch_start:batch_start + RESULT_BATCH_SIZE
941
  ]
 
943
  {"id": node_id, "distance": distance_by_node[node_id]}
944
  for node_id in batch_ids
945
  ]
946
+ query = Query(
947
  """
948
  UNWIND $nodes AS node_info
949
  MATCH (n) WHERE id(n) = node_info.id
 
979
  distance: node_info.distance
980
  }) AS nodes_data
981
  """,
982
+ metadata={"search_job_id": job_id} if job_id else None,
983
+ )
984
+ record = session.run(
985
+ query,
986
  {"nodes": batch_nodes},
987
  ).single()
988
  if record:
 
1004
  if progress_callback:
1005
  progress_callback("building_relationships", 0, 0)
1006
 
1007
+ if cancel_check:
1008
+ cancel_check()
1009
+ count_query = Query(
1010
  """
1011
  UNWIND $all_ids AS source_id
1012
  MATCH (source) WHERE id(source) = source_id
 
1014
  WHERE id(target) IN $all_ids
1015
  RETURN count(relationship) AS relationship_count
1016
  """,
1017
+ metadata={"search_job_id": job_id} if job_id else None,
1018
+ )
1019
+ count_record = session.run(
1020
+ count_query,
1021
  {"all_ids": discovered_node_ids},
1022
  ).single()
1023
  total_relationships = (
 
1030
  )
1031
 
1032
  for batch_start in range(0, total_nodes, RESULT_BATCH_SIZE):
1033
+ if cancel_check:
1034
+ cancel_check()
1035
  source_ids = discovered_node_ids[
1036
  batch_start:batch_start + RESULT_BATCH_SIZE
1037
  ]
1038
+ relationship_query = Query(
1039
  """
1040
  UNWIND $source_ids AS source_id
1041
  MATCH (source) WHERE id(source) = source_id
 
1047
  targetName: endNode(relationship).name
1048
  }) AS relationships
1049
  """,
1050
+ metadata={"search_job_id": job_id} if job_id else None,
1051
+ )
1052
+ record = session.run(
1053
+ relationship_query,
1054
  {
1055
  "source_ids": source_ids,
1056
  "all_ids": discovered_node_ids,
 
1077
  total_relationships,
1078
  )
1079
 
1080
+ if cancel_check:
1081
+ cancel_check()
1082
  if progress_callback:
1083
  progress_callback("formatting_result", 0, 0)
1084
 
application_neo4j/app_algorithms.py CHANGED
@@ -3,6 +3,7 @@ from graphdatascience import GraphDataScience
3
  from typing import Dict, List, Any
4
  import pandas as pd
5
  from translations import t
 
6
 
7
  def run_gds_bfs(
8
  gds: GraphDataScience,
@@ -13,6 +14,8 @@ def run_gds_bfs(
13
  expert=False,
14
  job_id: str = None,
15
  progress_callback=None,
 
 
16
  ) -> Dict[str, Any]:
17
  """
18
  Parcourt les descendants et ascendants avec le fork OpenGDS.
@@ -53,17 +56,45 @@ def run_gds_bfs(
53
  if max_depth is not None:
54
  bfs_params["maxDepth"] = max_depth
55
 
56
- g_natural = gds.graph.get(natural_graph_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  desc_job_id = f"{job_id}-descendants" if job_id else None
58
  if desc_job_id:
59
  bfs_params["jobId"] = desc_job_id
60
  if progress_callback:
61
  progress_callback("searching_descendants", desc_job_id)
62
- desc_df = gds.bfs.stream(g_natural, **bfs_params)
63
  _validate_depths_result(desc_df)
64
  print("BFS descendants terminé avec les profondeurs.")
65
 
66
- g_reverse = gds.graph.get(reverse_graph_name)
67
  asc_job_id = f"{job_id}-ancestors" if job_id else None
68
  if asc_job_id:
69
  bfs_params["jobId"] = asc_job_id
@@ -71,7 +102,7 @@ def run_gds_bfs(
71
  del bfs_params["jobId"]
72
  if progress_callback:
73
  progress_callback("searching_ancestors", asc_job_id)
74
- asc_df = gds.bfs.stream(g_reverse, **bfs_params)
75
  _validate_depths_result(asc_df)
76
  print("BFS ascendants terminé avec les profondeurs.")
77
 
 
3
  from typing import Dict, List, Any
4
  import pandas as pd
5
  from translations import t
6
+ from neo4j import Query
7
 
8
  def run_gds_bfs(
9
  gds: GraphDataScience,
 
14
  expert=False,
15
  job_id: str = None,
16
  progress_callback=None,
17
+ neo4j_driver=None,
18
+ cancel_check=None,
19
  ) -> Dict[str, Any]:
20
  """
21
  Parcourt les descendants et ascendants avec le fork OpenGDS.
 
56
  if max_depth is not None:
57
  bfs_params["maxDepth"] = max_depth
58
 
59
+ def run_bfs(graph_name, params):
60
+ if cancel_check:
61
+ cancel_check()
62
+ if neo4j_driver is None:
63
+ graph = gds.graph.get(graph_name)
64
+ result = gds.bfs.stream(graph, **params)
65
+ else:
66
+ query = Query(
67
+ """
68
+ CALL gds.bfs.stream($graph_name, $configuration)
69
+ YIELD nodeIds, depths
70
+ RETURN nodeIds, depths
71
+ """,
72
+ metadata={"search_job_id": job_id},
73
+ )
74
+ with neo4j_driver.session() as neo4j_session:
75
+ records = neo4j_session.run(
76
+ query,
77
+ {
78
+ "graph_name": graph_name,
79
+ "configuration": params,
80
+ },
81
+ )
82
+ result = pd.DataFrame(
83
+ record.data() for record in records
84
+ )
85
+ if cancel_check:
86
+ cancel_check()
87
+ return result
88
+
89
  desc_job_id = f"{job_id}-descendants" if job_id else None
90
  if desc_job_id:
91
  bfs_params["jobId"] = desc_job_id
92
  if progress_callback:
93
  progress_callback("searching_descendants", desc_job_id)
94
+ desc_df = run_bfs(natural_graph_name, bfs_params)
95
  _validate_depths_result(desc_df)
96
  print("BFS descendants terminé avec les profondeurs.")
97
 
 
98
  asc_job_id = f"{job_id}-ancestors" if job_id else None
99
  if asc_job_id:
100
  bfs_params["jobId"] = asc_job_id
 
102
  del bfs_params["jobId"]
103
  if progress_callback:
104
  progress_callback("searching_ancestors", asc_job_id)
105
+ asc_df = run_bfs(reverse_graph_name, bfs_params)
106
  _validate_depths_result(asc_df)
107
  print("BFS ascendants terminé avec les profondeurs.")
108
 
application_neo4j/static/js/search_progress.js CHANGED
@@ -1,5 +1,6 @@
1
  (() => {
2
  const POLL_INTERVAL_MS = 750;
 
3
 
4
  function translate(key, fallback) {
5
  return window.__I18N_DATA?.[key] || fallback;
@@ -25,6 +26,7 @@
25
  building_highlights: ["search.progress_stage_highlights", "Calcul des modèles importants…"],
26
  completed: ["search.progress_stage_completed", "Recherche terminée."],
27
  failed: ["search.progress_stage_failed", "La recherche a échoué."],
 
28
  };
29
  const [key, fallback] = labels[stage] || labels.queued;
30
  return translate(key, fallback);
@@ -58,6 +60,40 @@
58
  setSubmitting(form, false);
59
  }
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  function renderStatus(progress, job) {
62
  const stage = progress.querySelector(".search-progress-stage");
63
  const status = progress.querySelector(".search-progress-status");
@@ -140,12 +176,19 @@
140
  }));
141
  renderStatus(progress, job);
142
 
 
 
 
 
 
143
  if (job.result_url) {
 
144
  window.location.assign(job.result_url);
145
  return;
146
  }
147
  window.setTimeout(() => pollJob(form, progress, statusUrl), POLL_INTERVAL_MS);
148
  } catch (error) {
 
149
  showError(form, progress, error.message);
150
  }
151
  }
@@ -177,6 +220,7 @@
177
  credentials: "same-origin",
178
  headers: { Accept: "application/json" },
179
  }));
 
180
  pollJob(form, progress, job.status_url);
181
  } catch (error) {
182
  showError(form, progress, error.message);
@@ -193,4 +237,6 @@
193
  });
194
  });
195
  });
 
 
196
  })();
 
1
  (() => {
2
  const POLL_INTERVAL_MS = 750;
3
+ let activeCancelUrl = null;
4
 
5
  function translate(key, fallback) {
6
  return window.__I18N_DATA?.[key] || fallback;
 
26
  building_highlights: ["search.progress_stage_highlights", "Calcul des modèles importants…"],
27
  completed: ["search.progress_stage_completed", "Recherche terminée."],
28
  failed: ["search.progress_stage_failed", "La recherche a échoué."],
29
+ cancelled: ["search.progress_stage_cancelled", "Recherche annulée."],
30
  };
31
  const [key, fallback] = labels[stage] || labels.queued;
32
  return translate(key, fallback);
 
60
  setSubmitting(form, false);
61
  }
62
 
63
+ function showCancelled(form, progress) {
64
+ const stage = progress.querySelector(".search-progress-stage");
65
+ const status = progress.querySelector(".search-progress-status");
66
+ const note = progress.querySelector(".search-progress-note");
67
+ const bar = progress.querySelector(".search-progress-bar");
68
+ stage.textContent = stageLabel("cancelled");
69
+ status.textContent = "";
70
+ note.textContent = "";
71
+ bar.style.width = "0%";
72
+ progress.classList.remove("alert-info", "alert-danger");
73
+ progress.classList.add("alert-secondary");
74
+ setSubmitting(form, false);
75
+ }
76
+
77
+ function cancelActiveSearch() {
78
+ if (!activeCancelUrl) return;
79
+ const cancelUrl = activeCancelUrl;
80
+ activeCancelUrl = null;
81
+ if (
82
+ navigator.sendBeacon
83
+ && navigator.sendBeacon(
84
+ cancelUrl,
85
+ new Blob([], { type: "text/plain" })
86
+ )
87
+ ) {
88
+ return;
89
+ }
90
+ fetch(cancelUrl, {
91
+ method: "POST",
92
+ credentials: "same-origin",
93
+ keepalive: true,
94
+ }).catch(() => {});
95
+ }
96
+
97
  function renderStatus(progress, job) {
98
  const stage = progress.querySelector(".search-progress-stage");
99
  const status = progress.querySelector(".search-progress-status");
 
176
  }));
177
  renderStatus(progress, job);
178
 
179
+ if (job.status === "cancelled") {
180
+ activeCancelUrl = null;
181
+ showCancelled(form, progress);
182
+ return;
183
+ }
184
  if (job.result_url) {
185
+ activeCancelUrl = null;
186
  window.location.assign(job.result_url);
187
  return;
188
  }
189
  window.setTimeout(() => pollJob(form, progress, statusUrl), POLL_INTERVAL_MS);
190
  } catch (error) {
191
+ cancelActiveSearch();
192
  showError(form, progress, error.message);
193
  }
194
  }
 
220
  credentials: "same-origin",
221
  headers: { Accept: "application/json" },
222
  }));
223
+ activeCancelUrl = job.cancel_url;
224
  pollJob(form, progress, job.status_url);
225
  } catch (error) {
226
  showError(form, progress, error.message);
 
237
  });
238
  });
239
  });
240
+
241
+ window.addEventListener("pagehide", cancelActiveSearch);
242
  })();
application_neo4j/translations.py CHANGED
@@ -119,6 +119,7 @@ TRANSLATIONS = {
119
  "search.progress_stage_highlights": "Calcul des modèles importants…",
120
  "search.progress_stage_completed": "Recherche terminée.",
121
  "search.progress_stage_failed": "La recherche a échoué.",
 
122
  "search.progress_elapsed": "Temps écoulé : {seconds} s",
123
  "search.progress_items": "Éléments traités par le serveur : {completed}/{total}",
124
  "search.progress_server_percent": "Progression indiquée par le serveur : {percent} %",
@@ -347,6 +348,7 @@ TRANSLATIONS = {
347
  "search.progress_stage_highlights": "Computing important models…",
348
  "search.progress_stage_completed": "Search completed.",
349
  "search.progress_stage_failed": "The search failed.",
 
350
  "search.progress_elapsed": "Elapsed time: {seconds} s",
351
  "search.progress_items": "Items processed by the server: {completed}/{total}",
352
  "search.progress_server_percent": "Progress reported by the server: {percent}%",
 
119
  "search.progress_stage_highlights": "Calcul des modèles importants…",
120
  "search.progress_stage_completed": "Recherche terminée.",
121
  "search.progress_stage_failed": "La recherche a échoué.",
122
+ "search.progress_stage_cancelled": "Recherche annulée.",
123
  "search.progress_elapsed": "Temps écoulé : {seconds} s",
124
  "search.progress_items": "Éléments traités par le serveur : {completed}/{total}",
125
  "search.progress_server_percent": "Progression indiquée par le serveur : {percent} %",
 
348
  "search.progress_stage_highlights": "Computing important models…",
349
  "search.progress_stage_completed": "Search completed.",
350
  "search.progress_stage_failed": "The search failed.",
351
+ "search.progress_stage_cancelled": "Search cancelled.",
352
  "search.progress_elapsed": "Elapsed time: {seconds} s",
353
  "search.progress_items": "Items processed by the server: {completed}/{total}",
354
  "search.progress_server_percent": "Progress reported by the server: {percent}%",