This is an automated email from the ASF dual-hosted git repository.

nmalin pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new f9e076da5a Track persisted job processing trough multiple OFBiz 
instances with a job tracker (OFBIZ-13282)
f9e076da5a is described below

commit f9e076da5a6c611eb81fb5c7638ab51fca4da130
Author: Gaetan <[email protected]>
AuthorDate: Tue Jun 2 15:56:24 2026 +0200

    Track persisted job processing trough multiple OFBiz instances with a job 
tracker (OFBIZ-13282)
    
    Some processes can generate large quantities of jobs (like mass order 
import, massive export, ...). In order to share the load over multiple OFBiz 
instances, massive amounts of jobs are pushed to multiple job pools.
    
    The result is a difficulty to monitor the process state : how many jobs are 
running/paused/to be restarted/cancelled. We came up with the JobTracker, a new 
entity that will track and manage all jobs of a specific process. It will also 
allow us to share the load across multiple instances (OFBiz can use multiple 
job pools).
    
    How it works:
     * You create a dedicated service that will collect information and run all 
needed services as persisted jobs, this service uses an engine tracker, 
(currently just the one : groovy-tracked)
     * each persisted jobs are called with dispatcher.runAsyncTracked function
    
    Anytime, during the process of the list of unit jobs, the tracker can be 
accessed, and gathers all of the data of the jobs linked to it. It can (for 
example) give the completion percentage of the generation process.
    
    You can ask the job tracker to stop the global process or restart it.
    
    At the end of each job, the result of the job can be stored in a dedicated 
new entity TrackedJobResult. This entity stores the Job final status, potential 
error messages, and can be extended for storing business related data, useful 
in a lot of ways. We didn’t use JobSandbox to store the result because it is 
automatically purged after some time.
    
    When all the jobs have been run, the JobTracker status switches to complete.
    
    You can use the jobTracker to display job evolution to end user or for 
admin directly (https://localhost:8443/webtools/control/FindJobTracker)
    
    If you want to test it for yourself, you can run the 
‘ServiceForTestingTracker’ service that uses the description above.
    
    Example to use it :
    services.xml
        <service name="MyCollectTrackedService" engine="groovy-tracker" ...>
            <attribute name="jobTrackerId" type="String" mode="OUT"/>
        </service>
        <service name="MyPersistedService" engine="groovy" .../>
    
    Groovy:
    Map myCollectTrackedServices() {
        N.times {
           dispatcher.runAsyncTracked('MyPersistedService', 
context.jobTrackerId, [:])
        }
        return success([jobTrackerId: context.jobTrackerId])
    }
    
    Map myPersistedService() {
        //Do something
        return success()
    }
    
    Thanks to Nicolas for the main idea and pair programming.
---
 framework/common/config/CommonEntityLabels.xml     | 112 ++++++++
 framework/common/config/CommonUiLabels.xml         |  13 +
 .../org/apache/ofbiz/entity/GenericEntity.java     |   2 +
 framework/service/config/serviceengine.xml         |   1 +
 framework/service/data/ScheduledServiceData.xml    |  24 ++
 framework/service/entitydef/entitymodel.xml        |  57 +++++
 framework/service/servicedef/services.xml          |  29 +++
 framework/service/servicedef/services_test_se.xml  |  19 ++
 .../apache/ofbiz/service/TrackerServices.groovy    |  43 ++++
 .../service/test/engine/TrackerEngineTest.groovy   |  49 ++++
 .../ofbiz/service/GenericDispatcherFactory.java    |   6 +
 .../org/apache/ofbiz/service/LocalDispatcher.java  |  11 +
 .../apache/ofbiz/service/ModelServiceReader.java   |   1 +
 .../ofbiz/service/engine/GenericAsyncEngine.java   |  14 +-
 .../apache/ofbiz/service/engine/GroovyEngine.java  |   2 +-
 .../ofbiz/service/engine/TrackedServiceEngine.java |  95 +++++++
 .../ofbiz/service/job/GenericServiceJob.java       |  73 +++++-
 .../java/org/apache/ofbiz/service/job/Job.java     |   2 +-
 .../org/apache/ofbiz/service/job/JobManager.java   |  29 ++-
 .../ofbiz/service/job/PersistedServiceJob.java     |  17 +-
 .../apache/ofbiz/service/tracker/JobTracker.java   | 285 +++++++++++++++++++++
 .../ofbiz/service/tracker/JobTrackerFactory.java   | 155 +++++++++++
 .../ofbiz/service/tracker/JobTrackerListener.java  | 164 ++++++++++++
 .../ofbizservice/test/TrackerTestServices.groovy   |  41 +++
 framework/service/testdef/servicetests.xml         |   4 +-
 framework/webtools/config/WebtoolsUiLabels.xml     |  20 ++
 .../webtools/service/JobTrackerDetails.groovy      |  37 +++
 framework/webtools/template/Main.ftl               |   1 +
 .../webapp/webtools/WEB-INF/controller.xml         |  23 ++
 framework/webtools/widget/Menus.xml                |   3 +
 framework/webtools/widget/ServiceForms.xml         |  73 ++++++
 framework/webtools/widget/ServiceScreens.xml       | 115 +++++++++
 32 files changed, 1509 insertions(+), 11 deletions(-)

diff --git a/framework/common/config/CommonEntityLabels.xml 
b/framework/common/config/CommonEntityLabels.xml
index 03c6f9cf62..2be11e8e0b 100644
--- a/framework/common/config/CommonEntityLabels.xml
+++ b/framework/common/config/CommonEntityLabels.xml
@@ -10905,6 +10905,118 @@
         <value xml:lang="zh">已计划</value>
         <value xml:lang="zh-TW">已排程</value>
     </property>
+    <property key="StatusItem.description.JOB_T_CANCELLED">
+        <value xml:lang="ar">ملغاة</value>
+        <value xml:lang="de">Annulliert</value>
+        <value xml:lang="en">Cancelled</value>
+        <value xml:lang="es">Cancelado</value>
+        <value xml:lang="fr">Annulé</value>
+        <value xml:lang="it">Cancellata</value>
+        <value xml:lang="ja">取消</value>
+        <value xml:lang="nl">Afgekeurd</value>
+        <value xml:lang="pt">Cancelada</value>
+        <value xml:lang="pt-BR">Cancelado</value>
+        <value xml:lang="ro">Stearsa</value>
+        <value xml:lang="ru">Отменена</value>
+        <value xml:lang="th">ยกเลิก</value>
+        <value xml:lang="vi">Đã hủy</value>
+        <value xml:lang="zh">已取消</value>
+        <value xml:lang="zh-TW">已取消</value>
+    </property>
+    <property key="StatusItem.description.JOB_T_CREATED">
+        <value xml:lang="ar">مخلقة</value>
+        <value xml:lang="de">Erstellt</value>
+        <value xml:lang="en">Created</value>
+        <value xml:lang="es">Creado</value>
+        <value xml:lang="fr">Créé</value>
+        <value xml:lang="it">Creato</value>
+        <value xml:lang="ja">作成</value>
+        <value xml:lang="nl">Aangemaakt</value>
+        <value xml:lang="pt">Criado</value>
+        <value xml:lang="pt-BR">Criado em</value>
+        <value xml:lang="ro">Creata</value>
+        <value xml:lang="ru">Создана</value>
+        <value xml:lang="th">สร้าง</value>
+        <value xml:lang="vi">Đã tạo</value>
+        <value xml:lang="zh">已创建</value>
+        <value xml:lang="zh-TW">已新建</value>
+    </property>
+    <property key="StatusItem.description.JOB_T_FAILED">
+        <value xml:lang="en">Failed</value>
+        <value xml:lang="es">Fallado</value>
+        <value xml:lang="fr">Échoué</value>
+        <value xml:lang="it">Fallito</value>
+        <value xml:lang="ja">失敗</value>
+        <value xml:lang="nl">Gefaald</value>
+        <value xml:lang="pt-BR">Falhou</value>
+        <value xml:lang="ru">Неудачый</value>
+        <value xml:lang="th">ผิดพลาด</value>
+        <value xml:lang="vi">Không thành công</value>
+        <value xml:lang="zh">已失败</value>
+        <value xml:lang="zh-TW">已失敗</value>
+    </property>
+    <property key="StatusItem.description.JOB_T_FINISHED">
+        <value xml:lang="en">Finished</value>
+        <value xml:lang="es">Finalizado</value>
+        <value xml:lang="fr">Fini</value>
+        <value xml:lang="it">Finito</value>
+        <value xml:lang="ja">完了</value>
+        <value xml:lang="nl">Afgerond</value>
+        <value xml:lang="pt-BR">Concluído</value>
+        <value xml:lang="ru">Законченный</value>
+        <value xml:lang="th">เสร็จสิ้น</value>
+        <value xml:lang="vi">Đã kết thúc</value>
+        <value xml:lang="zh">已完成</value>
+        <value xml:lang="zh-TW">已完成</value>
+    </property>
+    <property key="StatusItem.description.JOB_T_ON_HOLD">
+        <value xml:lang="ar">قيد الانتظار</value>
+        <value xml:lang="de">In Wartestellung</value>
+        <value xml:lang="en">On Hold</value>
+        <value xml:lang="es">Retenido</value>
+        <value xml:lang="fr">En attente</value>
+        <value xml:lang="it">Congelato</value>
+        <value xml:lang="ja">保留中</value>
+        <value xml:lang="pt-BR">Em Espera</value>
+        <value xml:lang="ru">В ожидании</value>
+        <value xml:lang="th">หยุดไว้ชั่วคราว</value>
+        <value xml:lang="vi">Đang giữ (Hold)</value>
+        <value xml:lang="zh">等待</value>
+        <value xml:lang="zh-TW">等待</value>
+    </property>
+    <property key="StatusItem.description.JOB_T_RUNNING">
+        <value xml:lang="de">Läuft zurzeit</value>
+        <value xml:lang="en">Running</value>
+        <value xml:lang="es">En ejecución</value>
+        <value xml:lang="fr">En cours</value>
+        <value xml:lang="it">In esecuzione</value>
+        <value xml:lang="ja">実行中</value>
+        <value xml:lang="nl">In uitvoering</value>
+        <value xml:lang="pt">Em Execução</value>
+        <value xml:lang="ro">In Executie</value>
+        <value xml:lang="ru">Выполняется</value>
+        <value xml:lang="th">ดำเนินการ</value>
+        <value xml:lang="vi">Đang chạy</value>
+        <value xml:lang="zh">正在运行</value>
+        <value xml:lang="zh-TW">正在執行</value>
+    </property>
+    <property key="StatusItem.description.JOB_T_SCHEDULED">
+        <value xml:lang="ar">محدد لها موعد</value>
+        <value xml:lang="de">Eingeplant</value>
+        <value xml:lang="en">Scheduled</value>
+        <value xml:lang="es">Planificado</value>
+        <value xml:lang="fr">Planifié</value>
+        <value xml:lang="it">Schedulato</value>
+        <value xml:lang="ja">スケジュール済</value>
+        <value xml:lang="pt">Agendada</value>
+        <value xml:lang="pt-BR">Agendado</value>
+        <value xml:lang="ro">Planificat </value>
+        <value xml:lang="ru">Запланировано</value>
+        <value xml:lang="th">ตารางเวลา</value>
+        <value xml:lang="vi">Đã đặt lịch</value>
+        <value xml:lang="zh">已计划</value>
+        <value xml:lang="zh-TW">已排程</value>
+    </property>
     <property key="StatusItem.description.LEAD_ASSIGNED">
         <value xml:lang="ar">محدد لها مسؤول</value>
         <value xml:lang="en">Assigned</value>
diff --git a/framework/common/config/CommonUiLabels.xml 
b/framework/common/config/CommonUiLabels.xml
index f443e377a8..ff5bd1c689 100644
--- a/framework/common/config/CommonUiLabels.xml
+++ b/framework/common/config/CommonUiLabels.xml
@@ -1641,6 +1641,10 @@
         <value xml:lang="zh-CN">取消所有</value>
         <value xml:lang="zh-TW">取消全部</value>
     </property>
+    <property key="CommonCancelDate">
+        <value xml:lang="en">Cancel Date</value>
+        <value xml:lang="fr">Date d'annulation</value>
+    </property>
     <property key="CommonCancelDone">
         <value xml:lang="ar">إلغاء/إتمام</value>
         <value xml:lang="cs">Storno/Dokončeno</value>
@@ -5555,6 +5559,7 @@
         <value xml:lang="en">Hold</value>
         <value xml:lang="es">Pausa</value>
         <value xml:lang="es-MX">Pausa</value>
+        <value xml:lang="fr">Pause</value>
         <value xml:lang="nl">In wacht</value>
     </property>
     <property key="CommonHome">
@@ -10269,6 +10274,10 @@
         <value xml:lang="zh-CN">重置</value>
         <value xml:lang="zh-TW">重設</value>
     </property>
+    <property key="CommonRestart">
+        <value xml:lang="en">Restart</value>
+        <value xml:lang="fr">Redémarrer</value>
+    </property>
     <property key="CommonResultLookup">
         <value xml:lang="ar">نتيجة البحث</value>
         <value xml:lang="cs">Výsledek vyhledávání</value>
@@ -12457,6 +12466,10 @@
         <value xml:lang="zh">成本价格差乘以</value>
         <value xml:lang="zh-TW">成本價格差乘以</value>
     </property>
+    <property key="CommonTotalDone">
+        <value xml:lang="en">Total done</value>
+        <value xml:lang="fr">Total terminé</value>
+    </property>
     <property key="CommonTotalPercProfit">
         <value xml:lang="ar">إجمالي نسبة الربح</value>
         <value xml:lang="cs">Celkové procento zisku</value>
diff --git 
a/framework/entity/src/main/java/org/apache/ofbiz/entity/GenericEntity.java 
b/framework/entity/src/main/java/org/apache/ofbiz/entity/GenericEntity.java
index f2a61c50e5..427444ba2a 100644
--- a/framework/entity/src/main/java/org/apache/ofbiz/entity/GenericEntity.java
+++ b/framework/entity/src/main/java/org/apache/ofbiz/entity/GenericEntity.java
@@ -877,6 +877,8 @@ public class GenericEntity implements Map<String, Object>, 
LocalizedMap<Object>,
         Object value = get(name);
         if (value instanceof Double) {
             return new BigDecimal((Double) value);
+        } else if (value instanceof Long) {
+            return new BigDecimal((Long) value);
         }
         return (BigDecimal) value;
     }
diff --git a/framework/service/config/serviceengine.xml 
b/framework/service/config/serviceengine.xml
index d5613533df..3b67064a1d 100644
--- a/framework/service/config/serviceengine.xml
+++ b/framework/service/config/serviceengine.xml
@@ -51,6 +51,7 @@ under the License.
         <!-- Engines that can be replaced by the generic script engine -->
         <engine name="groovy" 
class="org.apache.ofbiz.service.engine.GroovyEngine"/>
         <engine name="javascript" 
class="org.apache.ofbiz.service.engine.ScriptEngine"/>
+        <engine name="groovy-tracker" 
class="org.apache.ofbiz.service.engine.TrackedServiceEngine"/>
         <!--  -->
         <engine name="route" 
class="org.apache.ofbiz.service.engine.RouteEngine"/>
         <engine name="jms" 
class="org.apache.ofbiz.service.jms.JmsServiceEngine"/>
diff --git a/framework/service/data/ScheduledServiceData.xml 
b/framework/service/data/ScheduledServiceData.xml
index 309034b326..9948c66083 100644
--- a/framework/service/data/ScheduledServiceData.xml
+++ b/framework/service/data/ScheduledServiceData.xml
@@ -26,5 +26,29 @@ under the License.
     <StatusItem description="Finished" sequenceId="10" statusCode="FINISHED" 
statusId="SERVICE_FINISHED" statusTypeId="SERVICE_STATUS"/>
     <StatusItem description="Failed" sequenceId="20" statusCode="FAILED" 
statusId="SERVICE_FAILED" statusTypeId="SERVICE_STATUS"/>
     <StatusItem description="Crashed" sequenceId="40" statusCode="CRASHED" 
statusId="SERVICE_CRASHED" statusTypeId="SERVICE_STATUS"/>
+    <StatusItem description="On Hold" sequenceId="50" statusCode="ON_HOLD" 
statusId="SERVICE_ON_HOLD" statusTypeId="SERVICE_STATUS"/> <!--To commit-->
     <StatusItem description="Cancelled" sequenceId="99" statusCode="CANCELLED" 
statusId="SERVICE_CANCELLED" statusTypeId="SERVICE_STATUS"/>
+
+    <!-- Job tracker status codes -->
+    <StatusType description="Job tracker" hasTable="N" 
statusTypeId="JOB_TRACKER_STATUS"/>
+    <StatusItem description="Created" sequenceId="01" statusCode="CREATED" 
statusId="JOB_T_CREATED" statusTypeId="JOB_TRACKER_STATUS"/>
+    <StatusItem description="Scheduled" sequenceId="02" statusCode="SCHEDULED" 
statusId="JOB_T_SCHEDULED" statusTypeId="JOB_TRACKER_STATUS"/>
+    <StatusItem description="Running" sequenceId="05" statusCode="RUNNING" 
statusId="JOB_T_RUNNING" statusTypeId="JOB_TRACKER_STATUS"/>
+    <StatusItem description="Finished" sequenceId="10" statusCode="FINISHED" 
statusId="JOB_T_FINISHED" statusTypeId="JOB_TRACKER_STATUS"/>
+    <StatusItem description="Failed" sequenceId="20" statusCode="FAILED" 
statusId="JOB_T_FAILED" statusTypeId="JOB_TRACKER_STATUS"/>
+    <StatusItem description="On hold" sequenceId="30" statusCode="ON_HOLD" 
statusId="JOB_T_ON_HOLD" statusTypeId="JOB_TRACKER_STATUS"/>
+    <StatusItem description="Cancelled" sequenceId="99" statusCode="CANCELLED" 
statusId="JOB_T_CANCELLED" statusTypeId="JOB_TRACKER_STATUS"/>
+
+    <!-- Job tracker status valid change -->
+    <StatusValidChange statusId="JOB_T_CREATED" statusIdTo="JOB_T_SCHEDULED"/>
+    <StatusValidChange statusId="JOB_T_CREATED" statusIdTo="JOB_T_CANCELLED"/>
+    <StatusValidChange statusId="JOB_T_SCHEDULED" statusIdTo="JOB_T_RUNNING"/>
+    <StatusValidChange statusId="JOB_T_SCHEDULED" 
statusIdTo="JOB_T_CANCELLED"/>
+    <StatusValidChange statusId="JOB_T_RUNNING" statusIdTo="JOB_T_CANCELLED"/>
+    <StatusValidChange statusId="JOB_T_RUNNING" statusIdTo="JOB_T_ON_HOLD"/>
+    <StatusValidChange statusId="JOB_T_RUNNING" statusIdTo="JOB_T_FAILED"/>
+    <StatusValidChange statusId="JOB_T_RUNNING" statusIdTo="JOB_T_FINISHED"/>
+    <StatusValidChange statusId="JOB_T_ON_HOLD" statusIdTo="JOB_T_RUNNING"/>
+    <StatusValidChange statusId="JOB_T_ON_HOLD" statusIdTo="JOB_T_CANCELLED"/>
+    <StatusValidChange statusId="JOB_T_ON_HOLD" statusIdTo="JOB_T_FAILED"/>
 </entity-engine-xml>
diff --git a/framework/service/entitydef/entitymodel.xml 
b/framework/service/entitydef/entitymodel.xml
index 83ffa19436..69203d6498 100644
--- a/framework/service/entitydef/entitymodel.xml
+++ b/framework/service/entitydef/entitymodel.xml
@@ -67,6 +67,7 @@ under the License.
         <field name="cancelDateTime" type="date-time"></field>
         <field name="jobResult" type="value"></field>
         <field name="recurrenceTimeZone" type="id-long"/>
+        <field name="jobTrackerId" type="id-vlong"/>
         <prim-key field="jobId"/>
         <relation type="one" fk-name="JOB_SNDBX_RECINFO" 
rel-entity-name="RecurrenceInfo">
             <key-map field-name="recurrenceInfoId"/>
@@ -86,6 +87,9 @@ under the License.
         <relation type="one" fk-name="JOB_SNDBX_STTS" 
rel-entity-name="StatusItem">
             <key-map field-name="statusId"/>
         </relation>
+        <relation type="one" fk-name="JOB_SNDBX_TRACK" 
rel-entity-name="JobTracker">
+            <key-map field-name="jobTrackerId"/>
+        </relation>
         <index name="JOB_SNDBX_RUNSTAT">
             <index-field name="runByInstanceId"/>
             <index-field name="statusId"/>
@@ -218,4 +222,57 @@ under the License.
         <prim-key field="serviceName"/>
     </entity>
 
+    <!-- ========================================================= -->
+    <!-- org.apache.ofbiz.service.tracker -->
+    <!-- ========================================================= -->
+
+    <entity entity-name="JobTracker" 
package-name="org.apache.ofbiz.service.tracker" title="Job Tracker">
+        <field name="jobTrackerId" type="id-vlong"/>
+        <field name="serviceName" type="id-vlong"/>
+        <field name="runtimeDataId" type="id"/>
+        <field name="statusId" type="id"/>
+        <field name="jobsTotalQty" type="numeric"/>
+        <field name="persistResult" type="indicator"/>
+        <field name="runAsUser" type="id-vlong"/>
+        <field name="startDate" type="date-time"/>
+        <field name="completionDate" type="date-time"/>
+        <field name="cancelDate" type="date-time"/>
+        <field name="createdDate" type="date-time"/>
+        <field name="createdByUserLogin" type="id-vlong"/>
+        <field name="lastModifiedDate" type="date-time"/>
+        <field name="lastModifiedByUserLogin" type="id-vlong"/>
+        <prim-key field="jobTrackerId"/>
+        <relation type="one" fk-name="JOB_T_STATUS" 
rel-entity-name="StatusItem">
+            <key-map field-name="statusId"/>
+        </relation>
+    </entity>
+
+    <entity entity-name="TrackedJobResult" 
package-name="org.apache.ofbiz.service.tracker" title="Tracked job result">
+        <field name="jobTrackerId" type="id-vlong"/>
+        <field name="jobTrackerResultSeqId" type="id"/>
+        <field name="resultMessage" type="description"/>
+        <field name="resultCode" type="description"/>
+        <prim-key field="jobTrackerId"/>
+        <prim-key field="jobTrackerResultSeqId"/>
+        <relation type="one" fk-name="FK_TJR_TJ" rel-entity-name="JobTracker">
+            <key-map field-name="jobTrackerId"/>
+        </relation>
+    </entity>
+    <view-entity entity-name="JobTrackerAndJobResult" 
package-name="org.apache.ofbiz.service.tracker" title="job tracker and result">
+        <member-entity entity-alias="JT" entity-name="JobTracker"/>
+        <member-entity entity-alias="TJR" entity-name="TrackedJobResult"/>
+        <alias-all entity-alias="JT"/>
+        <alias-all entity-alias="TJR"/>
+        <view-link entity-alias="JT" rel-entity-alias="TJR">
+            <key-map field-name="jobTrackerId"/>
+        </view-link>
+    </view-entity>
+
+    <view-entity entity-name="JobSandboxQtyByStatusAndJobTrackerView" 
package-name="org.apache.ofbiz.service.tracker">
+        <member-entity entity-alias="JS" entity-name="JobSandbox"/>
+        <alias name="jobTrackerId" entity-alias="JS" group-by="true"/>
+        <alias name="statusId" entity-alias="JS" group-by="true"/>
+        <alias name="quantity" entity-alias="JS" field="jobId" 
function="count-distinct"/>
+    </view-entity>
+
 </entitymodel>
diff --git a/framework/service/servicedef/services.xml 
b/framework/service/servicedef/services.xml
index e01f2e0b81..6d413168bc 100644
--- a/framework/service/servicedef/services.xml
+++ b/framework/service/servicedef/services.xml
@@ -265,4 +265,33 @@ under the License.
         <auto-attributes include="pk" mode="IN"/>
     </service>
 
+    <!--  Job tracker  -->
+    <service name="createJobTracker" engine="entity-auto" auth="true" 
invoke="create" default-entity-name="JobTracker">
+        <auto-attributes include="pk" mode="IN"/>
+        <auto-attributes include="nonpk" mode="IN" optional="true"/>
+        <override name="statusId" default-value="JOB_T_CREATED"/>
+    </service>
+    <service name="updateJobTracker" engine="entity-auto" auth="true" 
invoke="update" default-entity-name="JobTracker">
+        <auto-attributes include="pk" mode="IN"/>
+        <auto-attributes include="nonpk" mode="IN" optional="true"/>
+    </service>
+
+    <service name="pauseJobTracker" engine="groovy" auth="true" 
invoke="pauseJobTracker"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/TrackerServices.groovy">
+        <attribute name="jobTrackerId" type="String" mode="IN"/>
+    </service>
+    <service name="resumeJobTracker" engine="groovy" auth="true" 
invoke="resumeJobTracker"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/TrackerServices.groovy">
+        <attribute name="jobTrackerId" type="String" mode="IN"/>
+    </service>
+    <service name="cancelJobTracker" engine="groovy" auth="true" 
invoke="cancelJobTracker"
+             
location="component://service/src/main/groovy/org/apache/ofbiz/service/TrackerServices.groovy">
+        <attribute name="jobTrackerId" type="String" mode="IN"/>
+    </service>
+
+    <service name="createTrackedJobResult" engine="entity-auto" auth="true" 
invoke="create" default-entity-name="TrackedJobResult">
+        <auto-attributes include="pk" mode="IN"/>
+        <auto-attributes include="nonpk" mode="IN" optional="true"/>
+    </service>
+
 </services>
diff --git a/framework/service/servicedef/services_test_se.xml 
b/framework/service/servicedef/services_test_se.xml
index b099d01f0d..463a97ab05 100644
--- a/framework/service/servicedef/services_test_se.xml
+++ b/framework/service/servicedef/services_test_se.xml
@@ -214,4 +214,23 @@ under the License.
              
location="component://service/src/main/groovy/org/apache/ofbiz/service/test/TestServices.groovy"
 invoke="testPingErrorWithDSLCall">
         <implements service="testGroovyPingSuccess"/>
     </service>
+
+    <!--Test job tracker engine service -->
+    <service name="TestTopLevelServiceThatPlansTrackedServices" 
engine="groovy-tracker" auth="true"
+             
location="component://service/src/test/groovy/org/apache/ofbizservice/test/TrackerTestServices.groovy"
+             invoke="testTopLevelServiceThatPlansTrackedServices">
+        <description>Test service</description>
+        <attribute name="jobTrackerId" type="String" mode="OUT"/>
+    </service>
+    <service name="ServiceForTestingTracker" engine="groovy-tracker" 
auth="true"
+             
location="component://service/src/test/groovy/org/apache/ofbizservice/test/TrackerTestServices.groovy"
+             invoke="serviceForTestingTracker">
+        <description>Test service</description>
+    </service>
+    <service name="ServiceThatWaitsAMoment" engine="groovy" auth="true"
+             
location="component://service/src/test/groovy/org/apache/ofbizservice/test/TrackerTestServices.groovy"
+             invoke="serviceThatWaitsAMoment">
+        <description>Test service</description>
+    </service>
+
 </services>
diff --git 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/TrackerServices.groovy
 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/TrackerServices.groovy
new file mode 100644
index 0000000000..72579d3f37
--- /dev/null
+++ 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/TrackerServices.groovy
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.ofbiz.service
+
+import org.apache.ofbiz.service.tracker.JobTracker
+import org.apache.ofbiz.service.tracker.JobTrackerFactory
+
+Map pauseJobTracker() {
+    String jobTrackerId = parameters.jobTrackerId
+    JobTracker tracker = JobTrackerFactory.getJobTracker(dispatcher, 
jobTrackerId)
+    tracker.pause()
+    return success()
+}
+
+Map resumeJobTracker() {
+    String jobTrackerId = parameters.jobTrackerId
+    JobTracker tracker = JobTrackerFactory.getJobTracker(dispatcher, 
jobTrackerId)
+    tracker.resume()
+    return success()
+}
+
+Map cancelJobTracker() {
+    String jobTrackerId = parameters.jobTrackerId
+    JobTracker tracker = JobTrackerFactory.getJobTracker(dispatcher, 
jobTrackerId)
+    tracker.cancel()
+    return success()
+}
diff --git 
a/framework/service/src/main/groovy/org/apache/ofbiz/service/test/engine/TrackerEngineTest.groovy
 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/test/engine/TrackerEngineTest.groovy
new file mode 100644
index 0000000000..3e82f5ade9
--- /dev/null
+++ 
b/framework/service/src/main/groovy/org/apache/ofbiz/service/test/engine/TrackerEngineTest.groovy
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.ofbiz.service.test.engine
+
+import org.apache.ofbiz.entity.GenericValue
+import org.apache.ofbiz.service.ServiceUtil
+import org.apache.ofbiz.service.testtools.OFBizTestCase
+import org.apache.ofbiz.service.tracker.JobTracker
+import org.apache.ofbiz.service.tracker.JobTrackerFactory
+import org.apache.ofbiz.service.tracker.JobTrackerListener
+
+/**
+ * First time : ./gradlew 'ofbiz  -l readers=seed,seed-initial -l 
delegator=test'
+ * ./gradlew 'ofbiz -t component=service -t suitename=servicetests -t 
case=engine-tracker-tests'
+ */
+class TrackerEngineTest extends OFBizTestCase {
+
+    TrackerEngineTest(String name) {
+        super(name)
+    }
+
+    void testTrackedServiceAreTracked() {
+        GenericValue sysUser = delegator.findOne('UserLogin', true, 
'userLoginId', 'system')
+        Map result = 
dispatcher.runSync('TestTopLevelServiceThatPlansTrackedServices', [userLogin: 
sysUser])
+        assert ServiceUtil.isSuccess(result)
+        String trackerId = result.jobTrackerId
+        JobTracker tracker = JobTrackerFactory.getJobTracker(dispatcher, 
trackerId)
+        assert tracker.getGenericValue().jobsTotalQty == 3
+        JobTrackerListener listener = new JobTrackerListener(delegator, 
tracker)
+        assert listener.state()
+    }
+
+}
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/GenericDispatcherFactory.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/GenericDispatcherFactory.java
index b89748ea5d..3bfab03111 100644
--- 
a/framework/service/src/main/java/org/apache/ofbiz/service/GenericDispatcherFactory.java
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/GenericDispatcherFactory.java
@@ -190,6 +190,12 @@ public class GenericDispatcherFactory implements 
LocalDispatcherFactory {
             runAsync(serviceName, ServiceUtil.makeContext(context), persist);
         }
 
+        @Override
+        public void runAsyncTracked(String serviceName, String jobTrackerId, 
Map<String, Object> context) throws GenericServiceException {
+            context.put("jobTrackerId", jobTrackerId);
+            runAsync(serviceName, context, true);
+        }
+
         @Override
         public void runAsync(String serviceName, Map<String, ? extends Object> 
context)
                 throws ServiceAuthException, ServiceValidationException, 
GenericServiceException {
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/LocalDispatcher.java 
b/framework/service/src/main/java/org/apache/ofbiz/service/LocalDispatcher.java
index 4ce55241b2..dabe9eb830 100644
--- 
a/framework/service/src/main/java/org/apache/ofbiz/service/LocalDispatcher.java
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/LocalDispatcher.java
@@ -168,6 +168,17 @@ public interface LocalDispatcher extends DelegatorProvider 
{
     void runAsync(String serviceName, boolean persist, Object... context)
             throws ServiceAuthException, ServiceValidationException, 
GenericServiceException;
 
+    /**
+     * Run persist service asynchronously and follow it with a dedicated 
jobTracker
+     * @param serviceName Name of the service to run.
+     * @param jobTrackerId Job tracker reference that follow this job
+     * @param context Map of name, value pairs composing the context.
+     * @throws ServiceValidationException
+     * @throws GenericServiceException
+     */
+    void runAsyncTracked(String serviceName, String jobTrackerId, Map<String, 
Object> context)
+            throws ServiceValidationException, GenericServiceException;
+
     /**
      * Run the service asynchronously and IGNORE the result. This method WILL 
persist the job.
      * @param serviceName Name of the service to run.
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/ModelServiceReader.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/ModelServiceReader.java
index 72252a8243..8e89221b6f 100644
--- 
a/framework/service/src/main/java/org/apache/ofbiz/service/ModelServiceReader.java
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/ModelServiceReader.java
@@ -484,6 +484,7 @@ public final class ModelServiceReader implements 
Serializable {
         service.addParam(createInternalParam("locale", "java.util.Locale", 
ModelService.IN_OUT_PARAM));
         service.addParam(createInternalParam("timeZone", "java.util.TimeZone", 
ModelService.IN_OUT_PARAM));
         service.addParam(createInternalParam("visualTheme", 
"org.apache.ofbiz.widget.renderer.VisualTheme", ModelService.IN_OUT_PARAM));
+        service.addParam(createInternalParam("jobTrackerId", "String", 
ModelService.IN_PARAM));
     }
 
     static ModelParam createInternalParam(String name, String type, String 
mode) {
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/engine/GenericAsyncEngine.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/engine/GenericAsyncEngine.java
index d447995001..60a3b40b00 100644
--- 
a/framework/service/src/main/java/org/apache/ofbiz/service/engine/GenericAsyncEngine.java
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/engine/GenericAsyncEngine.java
@@ -42,6 +42,8 @@ import org.apache.ofbiz.service.job.Job;
 import org.apache.ofbiz.service.job.JobManager;
 import org.apache.ofbiz.service.job.JobManagerException;
 import org.apache.ofbiz.service.job.JobPriority;
+import org.apache.ofbiz.service.tracker.JobTracker;
+import org.apache.ofbiz.service.tracker.JobTrackerFactory;
 
 /**
  * Generic Asynchronous Engine
@@ -70,6 +72,7 @@ public abstract class GenericAsyncEngine extends 
AbstractEngine {
     public void runAsync(String localName, ModelService modelService, 
Map<String, Object> context, GenericRequester requester, boolean persist)
             throws GenericServiceException {
         DispatchContext dctx = getDispatcher().getLocalContext(localName);
+        String jobTrackerId = (String) context.get("jobTrackerId");
         Job job = null;
 
         if (persist) {
@@ -108,6 +111,9 @@ public abstract class GenericAsyncEngine extends 
AbstractEngine {
                 jFields.put("maxRetry", (long) modelService.getMaxRetry());
                 jFields.put("runtimeDataId", dataId);
                 jFields.put("priority", JobPriority.NORMAL);
+                if (UtilValidate.isNotEmpty(jobTrackerId)) {
+                    jFields.put("jobTrackerId", jobTrackerId);
+                }
                 if (UtilValidate.isNotEmpty(authUserLoginId)) {
                     jFields.put("authUserLoginId", authUserLoginId);
                 }
@@ -128,7 +134,13 @@ public abstract class GenericAsyncEngine extends 
AbstractEngine {
             if (jMgr != null) {
                 String name = Long.toString(System.currentTimeMillis());
                 String jobId = modelService.getName() + "." + name;
-                job = new GenericServiceJob(dctx, jobId, name, 
modelService.getName(), context, requester);
+                JobTracker jobTracker;
+                try {
+                    jobTracker = 
JobTrackerFactory.getJobTracker(dctx.getDispatcher(), jobTrackerId);
+                } catch (GenericEntityException e) {
+                    throw new RuntimeException(e);
+                }
+                job = new GenericServiceJob(dctx, jobId, name, 
modelService.getName(), context, requester, jobTracker);
                 try {
                     getDispatcher().getJobManager().runJob(job);
                 } catch (JobManagerException jse) {
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/engine/GroovyEngine.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/engine/GroovyEngine.java
index 35936a0dcd..4543a110f7 100644
--- 
a/framework/service/src/main/java/org/apache/ofbiz/service/engine/GroovyEngine.java
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/engine/GroovyEngine.java
@@ -45,7 +45,7 @@ import groovy.lang.Script;
 /**
  * Groovy Script Service Engine
  */
-public final class GroovyEngine extends GenericAsyncEngine {
+public class GroovyEngine extends GenericAsyncEngine {
 
     private static final String MODULE = GroovyEngine.class.getName();
     private static final Object[] EMPTY_ARGS = {};
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/engine/TrackedServiceEngine.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/engine/TrackedServiceEngine.java
new file mode 100644
index 0000000000..e29171bdc8
--- /dev/null
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/engine/TrackedServiceEngine.java
@@ -0,0 +1,95 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.service.engine;
+
+import org.apache.ofbiz.base.util.UtilDateTime;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.service.ServiceUtil;
+import org.apache.ofbiz.service.tracker.JobTracker;
+import org.apache.ofbiz.service.tracker.JobTrackerFactory;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.GenericServiceException;
+import org.apache.ofbiz.service.ModelService;
+import org.apache.ofbiz.service.ServiceDispatcher;
+
+import java.util.Map;
+import java.util.Set;
+
+public class TrackedServiceEngine extends GroovyEngine {
+
+    private static final String MODULE = TrackedServiceEngine.class.getName();
+    private static final Object[] EMPTY_ARGS = {};
+    private static final Set<String> PROTECTED_KEYS = createProtectedKeys();
+
+    private static Set<String> createProtectedKeys() {
+        return Set.of("dctx", "dispatcher", "delegator", "visualTheme");
+    }
+
+    public TrackedServiceEngine(ServiceDispatcher dispatcher) {
+        super(dispatcher);
+    }
+
+    /**
+     * @param localName Name of the LocalDispatcher.
+     * @param modelService Service model object.
+     * @param context Map of name, value pairs composing the context.
+     * @throws GenericServiceException
+     */
+    public void runSyncIgnore(String localName, ModelService modelService, 
Map<String, Object> context) throws GenericServiceException {
+        runSync(localName, modelService, context);
+    }
+
+    /**
+     * @param localName Name of the LocalDispatcher.
+     * @param modelService Service model object.
+     * @param context Map of name, value pairs composing the context.
+     * @return service result called
+     */
+    public Map<String, Object> runSync(String localName, ModelService 
modelService, Map<String, Object> context) {
+        DispatchContext dctx = getDispatcher().getLocalContext(localName);
+
+        JobTracker tracker;
+        Map<String, Object> result;
+        try {
+            tracker = new JobTrackerFactory(dctx.getDispatcher())
+                    .setServiceName(modelService.getName())
+                    .setServiceParams(context)
+                    .persistResult()
+                    .instantiate();
+            tracker.persist();
+
+            context.put("jobTrackerId", tracker.getJobTrackerId());
+
+            tracker.updateStatus("JOB_T_SCHEDULED", Map.of("startDate", 
UtilDateTime.nowTimestamp()));
+            try {
+                result = super.runSync(localName, modelService, context);
+            } catch (GeneralException e) {
+                tracker.updateStatus("JOB_T_FAILED", Map.of("cancelDate", 
UtilDateTime.nowTimestamp()));
+                return ServiceUtil.returnError(e.getMessage());
+            }
+            tracker.computeJobsTotalQty();
+            tracker.updateStatus("JOB_T_RUNNING");
+            result.put("jobTrackerId", tracker.getJobTrackerId());
+        } catch (GenericServiceException | GenericEntityException e) {
+            throw new RuntimeException(e);
+        }
+        return result;
+    }
+}
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/job/GenericServiceJob.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/job/GenericServiceJob.java
index 8923b6fff5..1bb68003c2 100644
--- 
a/framework/service/src/main/java/org/apache/ofbiz/service/job/GenericServiceJob.java
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/job/GenericServiceJob.java
@@ -19,16 +19,26 @@
 package org.apache.ofbiz.service.job;
 
 import java.io.Serializable;
+import java.util.HashMap;
 import java.util.Map;
 
 import org.apache.ofbiz.base.util.Assert;
 import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
 import org.apache.ofbiz.service.DispatchContext;
 import org.apache.ofbiz.service.GenericRequester;
+import org.apache.ofbiz.service.GenericServiceException;
 import org.apache.ofbiz.service.LocalDispatcher;
+import org.apache.ofbiz.service.ModelService;
 import org.apache.ofbiz.service.ServiceUtil;
 import org.apache.ofbiz.service.semaphore.SemaphoreFailException;
 import org.apache.ofbiz.service.semaphore.SemaphoreWaitException;
+import org.apache.ofbiz.service.tracker.JobTracker;
+import org.apache.ofbiz.service.tracker.JobTrackerListener;
+
 /**
  * A generic async-service job.
  */
@@ -41,6 +51,7 @@ public class GenericServiceJob extends AbstractJob implements 
Serializable {
     private final transient DispatchContext dctx;
     private final String service;
     private final Map<String, Object> context;
+    private final JobTracker jobTracker;
     /**
      * Gets dctx.
      * @return the dctx
@@ -49,13 +60,15 @@ public class GenericServiceJob extends AbstractJob 
implements Serializable {
         return dctx;
     }
 
-    public GenericServiceJob(DispatchContext dctx, String jobId, String 
jobName, String service, Map<String, Object> context, GenericRequester req) {
+    public GenericServiceJob(DispatchContext dctx, String jobId, String 
jobName, String service, Map<String, Object> context,
+                             GenericRequester req, JobTracker jobTracker) {
         super(jobId, jobName);
         Assert.notNull("dctx", dctx);
         this.dctx = dctx;
         this.service = service;
         this.context = context;
         this.requester = req;
+        this.jobTracker = jobTracker;
     }
 
     /**
@@ -63,6 +76,14 @@ public class GenericServiceJob extends AbstractJob 
implements Serializable {
      */
     @Override
     public void exec() throws InvalidJobException {
+        try {
+            refreshStatus();
+        } catch (GenericEntityException ignored) {
+        }
+        if (getCurrentState() == State.ON_HOLD) {
+            deQueue();
+            return;
+        }
         if (getCurrentState() != State.QUEUED) {
             throw new InvalidJobException("Illegal state change");
         }
@@ -94,6 +115,56 @@ public class GenericServiceJob extends AbstractJob 
implements Serializable {
         } else {
             failed(thrown);
         }
+        handleTrackerActions(result);
+    }
+
+    /**
+     * @param result
+     */
+    void handleTrackerActions(Map<String, Object> result) {
+        Delegator delegator = this.dctx.getDelegator();
+        if (UtilValidate.isEmpty(this.jobTracker) || 
!this.jobTracker.getPersistResult()) {
+            return;
+        }
+        try {
+            JobTrackerListener jtl = new JobTrackerListener(delegator, 
this.jobTracker);
+            if (jtl.isFinished()) {
+                jobTracker.complete();
+            }
+            if (!this.jobTracker.getPersistResult()) {
+                return;
+            }
+            Map<String, Object> contextAndResult = new HashMap<>(getContext());
+            contextAndResult.putAll(result);
+            Map<String, Object> createResultContext = 
dctx.makeValidContext("createTrackedJobResult",
+                    ModelService.IN_PARAM, contextAndResult);
+            createResultContext.put("jobTrackerId", 
this.jobTracker.getJobTrackerId());
+            createResultContext.put("jobTrackerResultSeqId", this.getJobId());
+            createResultContext.put("userLogin", 
this.jobTracker.getUserLogin());
+            if (ServiceUtil.isError(result) || ServiceUtil.isFailure(result)) {
+                createResultContext.put("resultMessage", 
ServiceUtil.makeErrorMessage(result, "", "", "", ""));
+                createResultContext.put("resultCode", 
ServiceUtil.isError(result) ? ModelService.RESPOND_ERROR : 
ModelService.RESPOND_FAIL);
+            } else {
+                createResultContext.put("resultMessage", 
ServiceUtil.makeSuccessMessage(result, "", "", "", ""));
+                createResultContext.put("resultCode", 
ModelService.RESPOND_SUCCESS);
+            }
+            dctx.getDispatcher().runSync("createTrackedJobResult", 
createResultContext);
+        } catch (GenericServiceException | InvalidJobException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * @throws GenericEntityException
+     */
+    protected void refreshStatus() throws GenericEntityException {
+        GenericValue job = dctx.getDelegator().findOne("JobSandbox", false, 
"jobId", getJobId());
+        if (job != null) {
+            GenericValue statusItem = 
dctx.getDelegator().findOne("StatusItem", true, "statusId", 
job.get("statusId"));
+            if (currentState() != 
State.valueOf(statusItem.getString("statusCode"))) {
+                
setCurrentState(State.valueOf(statusItem.getString("statusCode")));
+            }
+        }
     }
 
     /**
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/job/Job.java 
b/framework/service/src/main/java/org/apache/ofbiz/service/job/Job.java
index f012654d72..de4b0b3923 100644
--- a/framework/service/src/main/java/org/apache/ofbiz/service/job/Job.java
+++ b/framework/service/src/main/java/org/apache/ofbiz/service/job/Job.java
@@ -29,7 +29,7 @@ import java.util.Date;
  */
 public interface Job extends Runnable {
 
-    enum State { CREATED, QUEUED, RUNNING, FINISHED, FAILED }
+    enum State { CREATED, QUEUED, ON_HOLD, RUNNING, FINISHED, FAILED }
 
     /**
      * Returns the current state of this job.
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/job/JobManager.java 
b/framework/service/src/main/java/org/apache/ofbiz/service/job/JobManager.java
index 585f113f98..fdf3ebe987 100644
--- 
a/framework/service/src/main/java/org/apache/ofbiz/service/job/JobManager.java
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/job/JobManager.java
@@ -31,6 +31,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
+import org.apache.ofbiz.service.tracker.JobTracker;
 import org.apache.ofbiz.base.config.GenericConfigException;
 import org.apache.ofbiz.base.util.Assert;
 import org.apache.ofbiz.base.util.Debug;
@@ -57,6 +58,7 @@ import org.apache.ofbiz.service.calendar.RecurrenceInfo;
 import org.apache.ofbiz.service.calendar.RecurrenceInfoException;
 import org.apache.ofbiz.service.config.ServiceConfigUtil;
 import org.apache.ofbiz.service.config.model.RunFromPool;
+import org.apache.ofbiz.service.tracker.JobTrackerFactory;
 
 /**
  * Job manager. The job manager queues and manages jobs. Client code can queue 
a job to be run immediately
@@ -203,6 +205,7 @@ public final class JobManager {
                                 EntityCondition.makeCondition("runTime",
                                         EntityOperator.LESS_THAN_EQUAL_TO, 
UtilDateTime.nowTimestamp()))),
                 EntityCondition.makeCondition("startDateTime", 
EntityOperator.EQUALS, null),
+                EntityCondition.makeCondition("statusId", "SERVICE_PENDING"),
                 EntityCondition.makeCondition("cancelDateTime", 
EntityOperator.EQUALS, null),
                 EntityCondition.makeCondition("runByInstanceId", 
EntityOperator.EQUALS, null));
         // limit to just defined pools
@@ -243,7 +246,11 @@ public final class JobManager {
                     int rowsUpdated = delegator.storeByCondition("JobSandbox", 
UtilMisc.toMap("runByInstanceId", INSTANCE_ID),
                             EntityCondition.makeCondition(updateExpression));
                     if (rowsUpdated == 1) {
-                        poll.add(new PersistedServiceJob(dctx, jobValue, 
null));
+                        JobTracker jobTracker = null;
+                        if 
(UtilValidate.isNotEmpty(jobValue.getString("jobTrackerId"))) {
+                            jobTracker = 
JobTrackerFactory.getJobTracker(getDispatcher(), 
jobValue.getString("jobTrackerId"));
+                        }
+                        poll.add(new PersistedServiceJob(dctx, jobValue, null, 
jobTracker));
                         if (poll.size() == limit) {
                             break;
                         }
@@ -391,6 +398,9 @@ public final class JobManager {
                     Debug.logWarning(e, MODULE);
                 }
             }
+
+            // If some jobs was followed by a job tracker, call it to 
recalculate the quantity
+            updateJobTrackersThatFollowCrashedJobs(crashed);
             if (Debug.infoOn()) {
                 Debug.logInfo("-- " + rescheduled + " jobs re-scheduled", 
MODULE);
             }
@@ -402,6 +412,23 @@ public final class JobManager {
         crashedJobsReloaded = true;
     }
 
+    private void updateJobTrackersThatFollowCrashedJobs(List<GenericValue> 
crashed) {
+        List<String> jobTrackerIds = crashed.stream()
+                .filter(job -> 
UtilValidate.isNotEmpty(job.getString("jobTrackerId")))
+                .map(job -> job.getString("jobTrackerId"))
+                .distinct()
+                .toList();
+        if (!jobTrackerIds.isEmpty()) {
+            jobTrackerIds.forEach(jobTrackerId -> {
+                try {
+                    JobTrackerFactory.getJobTracker(getDispatcher(), 
jobTrackerId).computeJobsTotalQty();
+                } catch (Exception e) {
+                    Debug.logWarning(e, "Failed to recalculate jobs quantity 
for the jobTracker " + jobTrackerId, MODULE);
+                }
+            });
+        }
+    }
+
     /** Queues a Job to run now.
      * @throws IllegalStateException if the Job Manager is shut down.
      */
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/job/PersistedServiceJob.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/job/PersistedServiceJob.java
index 32c2a9ccf4..92e2c0df52 100644
--- 
a/framework/service/src/main/java/org/apache/ofbiz/service/job/PersistedServiceJob.java
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/job/PersistedServiceJob.java
@@ -29,6 +29,7 @@ import java.time.format.SignStyle;
 import java.time.temporal.ChronoField;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 import javax.xml.parsers.ParserConfigurationException;
@@ -53,6 +54,7 @@ import 
org.apache.ofbiz.service.calendar.RecurrenceInfoException;
 import org.apache.ofbiz.service.calendar.TemporalExpression;
 import org.apache.ofbiz.service.calendar.TemporalExpressionWorker;
 import org.apache.ofbiz.service.config.ServiceConfigUtil;
+import org.apache.ofbiz.service.tracker.JobTracker;
 import org.xml.sax.SAXException;
 
 import com.ibm.icu.util.Calendar;
@@ -88,9 +90,10 @@ public class PersistedServiceJob extends GenericServiceJob {
      * @param dctx
      * @param jobValue
      * @param req
+     * @param jobTracker
      */
-    public PersistedServiceJob(DispatchContext dctx, GenericValue jobValue, 
GenericRequester req) {
-        super(dctx, jobValue.getString("jobId"), 
jobValue.getString("jobName"), null, null, req);
+    public PersistedServiceJob(DispatchContext dctx, GenericValue jobValue, 
GenericRequester req, JobTracker jobTracker) {
+        super(dctx, jobValue.getString("jobId"), 
jobValue.getString("jobName"), null, null, req, jobTracker);
         this.delegator = dctx.getDelegator();
         this.jobValue = jobValue;
         /*
@@ -397,15 +400,19 @@ public class PersistedServiceJob extends 
GenericServiceJob {
 
     @Override
     public void deQueue() throws InvalidJobException {
-        if (getCurrentState() != State.QUEUED) {
+        if (!List.of(State.QUEUED, State.ON_HOLD).contains(getCurrentState())) 
{
             throw new InvalidJobException("Illegal state change");
         }
-        setCurrentState(State.CREATED);
+        if (getCurrentState() == State.QUEUED) {
+            setCurrentState(State.CREATED);
+        }
         try {
             jobValue.refresh();
             jobValue.set("startDateTime", null);
             jobValue.set("runByInstanceId", null);
-            jobValue.set("statusId", "SERVICE_PENDING");
+            if (getCurrentState() == State.QUEUED) {
+                jobValue.set("statusId", "SERVICE_PENDING");
+            }
             jobValue.store();
         } catch (GenericEntityException e) {
             throw new InvalidJobException("Unable to dequeue job [" + 
getJobId() + "]", e);
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/tracker/JobTracker.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/tracker/JobTracker.java
new file mode 100644
index 0000000000..c5c372510f
--- /dev/null
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/tracker/JobTracker.java
@@ -0,0 +1,285 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.service.tracker;
+
+import java.sql.Timestamp;
+import org.apache.ofbiz.base.util.UtilDateTime;
+import org.apache.ofbiz.base.util.UtilGenerics;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.serialize.SerializeException;
+import org.apache.ofbiz.entity.serialize.XmlSerializer;
+import org.apache.ofbiz.entity.util.EntityQuery;
+import org.apache.ofbiz.service.GenericServiceException;
+import org.apache.ofbiz.service.LocalDispatcher;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TimeZone;
+
+import static org.apache.ofbiz.base.util.UtilMisc.toList;
+import static org.apache.ofbiz.base.util.UtilMisc.toMap;
+import static org.apache.ofbiz.entity.condition.EntityCondition.makeCondition;
+import static org.apache.ofbiz.entity.condition.EntityOperator.IN;
+
+public class JobTracker {
+    private final String trackerId;
+    private final LocalDispatcher dispatcher;
+    private final Delegator delegator;
+    private final String serviceName;
+    private final Map<String, Object> processParams;
+    private final Map<String, Object> serviceParams;
+    private final Boolean persistResult;
+    private final GenericValue userLogin;
+    private GenericValue jobTrackerGV;
+    private String runtimeDataId = null;
+
+    JobTracker(LocalDispatcher dispatcher, TimeZone timeZone, Locale locale,
+               GenericValue userLogin, String trackerId, String serviceName, 
Map<String, Object> parameters,
+               Boolean persistResult) throws GenericEntityException {
+        this.trackerId = trackerId;
+        this.dispatcher = dispatcher;
+        this.delegator = dispatcher.getDelegator();
+        this.serviceName = serviceName;
+        this.processParams = toMap(
+                "jobTrackerId", trackerId,
+                "userLogin", userLogin,
+                "timeZone", timeZone,
+                "locale", locale);
+        this.serviceParams = parameters;
+        this.serviceParams.putAll(processParams);
+        this.jobTrackerGV = 
EntityQuery.use(delegator).from("JobTracker").where("jobTrackerId", 
trackerId).queryOne();
+        this.persistResult = persistResult;
+        this.userLogin = userLogin;
+    }
+
+    JobTracker(LocalDispatcher dispatcher, GenericValue jobTracker) throws 
GenericEntityException {
+        if (!"JobTracker".equals(jobTracker.getEntityName())) {
+            throw new GenericEntityException("Cannot construct a JobTracker 
object with a "
+                    + jobTracker.getEntityName() + " GenericValue");
+        }
+        this.trackerId = jobTracker.getString("jobTrackerId");
+        this.dispatcher = dispatcher;
+        this.delegator = jobTracker.getDelegator();
+        this.serviceName = jobTracker.getString("serviceName");
+        this.userLogin = delegator.findOne("UserLogin", false,
+                "userLoginId", jobTracker.getString("runAsUser"));
+        this.processParams = toMap("jobTrackerId", trackerId,
+                "userLogin", this.userLogin,
+                "timeZone", TimeZone.getDefault(),
+                "locale", Locale.getDefault());
+        this.runtimeDataId = jobTracker.getString("runtimeDataId");
+        this.serviceParams = retrieveServiceParams();
+        this.persistResult = jobTracker.getBoolean("persistResult");
+        this.jobTrackerGV = jobTracker;
+    }
+
+    /**
+     * @return the id of a job tracker
+     */
+    public String getJobTrackerId() {
+        return this.trackerId;
+    }
+
+    /**
+     * @return the GenericValue corresponding to this tracker
+     */
+    public GenericValue getGenericValue() {
+        return jobTrackerGV;
+    }
+
+    /**
+     * @return true if the jobTracker store the job result after run
+     */
+    public Boolean getPersistResult() {
+        return persistResult;
+    }
+
+    /**
+     * @return the UserLogin linked
+     */
+    public GenericValue getUserLogin() {
+        return userLogin;
+    }
+
+    /**
+     * Store the job tracker in database to share it
+     *
+     * @throws GenericEntityException
+     * @throws GenericServiceException
+     */
+    public void persist() throws GenericEntityException, 
GenericServiceException {
+        persistParameters();
+        Map<String, Object> context = new HashMap<>(processParams);
+        context.putAll(serviceParams);
+        context.put("serviceName", this.serviceName);
+        context.put("runtimeDataId", this.runtimeDataId);
+        context.put("persistResult", this.persistResult ? "Y" : "N");
+        context.put("runAsUser", this.userLogin.getString("userLoginId"));
+        dispatcher.runSync("createJobTracker", context, 60, true);
+        this.jobTrackerGV = 
EntityQuery.use(delegator).from("JobTracker").where("jobTrackerId", 
trackerId).queryOne();
+        if (UtilValidate.isEmpty(this.jobTrackerGV)) {
+            throw new GenericEntityException("Couldn't find or create 
jobTracker GV");
+        }
+    }
+
+    /**
+     * @return the current status of a jobTracker
+     * @throws GenericEntityException
+     */
+    public String getStatusId() throws GenericEntityException {
+        if (this.jobTrackerGV == null) {
+            return "JOB_T_FAILED";
+        }
+        jobTrackerGV.refresh();
+        return jobTrackerGV.getString("statusId");
+    }
+
+    /**
+     * Set to pause the jobTracker and all followed jobs
+     *
+     * @throws GenericEntityException
+     * @throws GenericServiceException
+     */
+    public void pause() throws GenericEntityException, GenericServiceException 
{
+        delegator.storeByCondition("JobSandbox", toMap("statusId", 
"SERVICE_ON_HOLD"),
+                makeCondition(
+                        makeCondition("jobTrackerId", trackerId),
+                        makeCondition("statusId", IN, 
toList("SERVICE_PENDING", "SERVICE_QUEUED"))));
+        updateStatus("JOB_T_ON_HOLD");
+    }
+
+    /**
+     * This restart a paused jobTracker and all followed job
+     *
+     * @throws GenericEntityException
+     * @throws GenericServiceException
+     */
+    public void resume() throws GenericEntityException, 
GenericServiceException {
+        delegator.storeByCondition("JobSandbox", toMap("statusId", 
"SERVICE_PENDING"),
+                makeCondition(
+                        makeCondition("jobTrackerId", trackerId),
+                        makeCondition("statusId", "SERVICE_ON_HOLD")));
+        updateStatus("JOB_T_RUNNING");
+    }
+
+    /**
+     * Stop a jobTracker and all followed job not finished
+     *
+     * @throws GenericEntityException
+     * @throws GenericServiceException
+     */
+    public void cancel() throws GenericEntityException, 
GenericServiceException {
+        Timestamp now = UtilDateTime.nowTimestamp();
+        delegator.storeByCondition("JobSandbox", toMap("statusId", 
"SERVICE_CANCELLED",
+                        "cancelDateTime", now),
+                makeCondition(
+                        makeCondition("jobTrackerId", trackerId),
+                        makeCondition("statusId", IN, 
toList("SERVICE_PENDING", "SERVICE_QUEUED", "SERVICE_ON_HOLD"))));
+        updateStatus("JOB_T_CANCELLED", toMap("cancelDate", now));
+    }
+
+    /**
+     * Move the jobTracker to a completed state
+     *
+     * @throws GenericServiceException
+     */
+    public void complete() throws GenericServiceException {
+        updateStatus("JOB_T_FINISHED", toMap("completionDate", 
UtilDateTime.nowTimestamp()));
+    }
+
+    /**
+     * call to update the status of this jobTracker on isolated transaction
+     *
+     * @param statusId
+     * @throws GenericServiceException
+     */
+    public void updateStatus(String statusId) throws GenericServiceException {
+        updateStatus(statusId, null);
+    }
+
+    /**
+     * call to update the status of this jobTracker on isolated transaction
+     *
+     * @param statusId
+     * @param statusParams
+     * @throws GenericServiceException
+     */
+    public void updateStatus(String statusId, Map<String, Object> 
statusParams) throws GenericServiceException {
+        if (UtilValidate.isEmpty(jobTrackerGV)) {
+            return;
+        }
+        Map<String, Object> updateParameters = new HashMap<>(processParams);
+        updateParameters.put("statusId", statusId);
+        if (UtilValidate.isNotEmpty(statusParams)) {
+            updateParameters.putAll(statusParams);
+        }
+        dispatcher.runSync("updateJobTracker", updateParameters, 60, true);
+    }
+
+    /**
+     * compute the number of followed jobs by the tracker after the service to 
populate them is finished
+     *
+     * @throws GenericEntityException
+     * @throws GenericServiceException
+     */
+    public void computeJobsTotalQty() throws GenericEntityException, 
GenericServiceException {
+        if (UtilValidate.isEmpty(jobTrackerGV)) {
+            return;
+        }
+        Map<String, Object> context = new HashMap<>(processParams);
+        context.put("jobsTotalQty", EntityQuery.use(delegator)
+                .from("JobSandbox")
+                .where("jobTrackerId", trackerId)
+                .queryCount());
+        dispatcher.runSync("updateJobTracker", context, 60, true);
+        JobTrackerFactory.clean(trackerId);
+    }
+
+    private void persistParameters() throws GenericEntityException {
+        this.runtimeDataId = delegator.getNextSeqId("RuntimeData");
+        String serializedParams;
+        try {
+            serviceParams.remove("timeZone"); // unsupport by serializer
+            serializedParams = XmlSerializer.serialize(serviceParams);
+        } catch (SerializeException | IOException e) {
+            throw new RuntimeException(e);
+        }
+        delegator.create("RuntimeData", toMap(
+                "runtimeDataId", runtimeDataId,
+                "runtimeInfo", serializedParams));
+    }
+
+    private Map<String, Object> retrieveServiceParams() throws 
GenericEntityException {
+        GenericValue runtimeData = delegator
+                .findOne("RuntimeData", true, "runtimeDataId", runtimeDataId);
+        try {
+            return UtilGenerics.checkMap(
+                    
XmlSerializer.deserialize(runtimeData.getString("runtimeInfo"), delegator), 
String.class, Object.class);
+        } catch (SerializeException | SAXException | 
ParserConfigurationException | IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/tracker/JobTrackerFactory.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/tracker/JobTrackerFactory.java
new file mode 100644
index 0000000000..853d4a7572
--- /dev/null
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/tracker/JobTrackerFactory.java
@@ -0,0 +1,155 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.service.tracker;
+
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.base.util.cache.UtilCache;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.util.EntityQuery;
+import org.apache.ofbiz.service.GenericServiceException;
+import org.apache.ofbiz.service.LocalDispatcher;
+
+import java.util.Locale;
+import java.util.Map;
+import java.util.TimeZone;
+
+import static org.apache.ofbiz.base.util.UtilValidate.isEmpty;
+
+public class JobTrackerFactory {
+    private static final UtilCache<String, JobTracker> TRACKER_CACHE = 
UtilCache.createUtilCache("service.tracker");
+
+    private final String jobTrackerId;
+    private final LocalDispatcher dispatcher;
+    private final Delegator delegator;
+    private final TimeZone timeZone;
+    private final Locale locale;
+    private final GenericValue userLogin;
+    private String serviceName;
+    private Map<String, Object> serviceParams;
+    private Boolean persistResult;
+
+    public JobTrackerFactory(LocalDispatcher dispatcher, TimeZone timeZone, 
Locale locale, GenericValue userLogin) {
+        this.dispatcher = dispatcher;
+        this.delegator = dispatcher.getDelegator();
+        this.timeZone = timeZone;
+        this.userLogin = userLogin;
+        this.locale = locale;
+        jobTrackerId = delegator.getNextSeqId("JobTracker");
+    }
+
+    public JobTrackerFactory(LocalDispatcher dispatcher) {
+        this.dispatcher = dispatcher;
+        this.delegator = dispatcher.getDelegator();
+        this.timeZone = TimeZone.getDefault();
+        this.userLogin = null;
+        this.locale = Locale.getDefault();
+        jobTrackerId = delegator.getNextSeqId("JobTracker");
+    }
+
+    /**
+     * set the service name need to call to populate the jobTracker with 
followed jobs
+     * @param serviceName
+     * @return this
+     */
+    public JobTrackerFactory setServiceName(String serviceName) {
+        this.serviceName = serviceName;
+        return this;
+    }
+
+    /**
+     * set parameters needs for the called service to populate the jobTracker 
with followed jobs
+     * @param serviceParams
+     * @return this
+     */
+    public JobTrackerFactory setServiceParams(Map<String, Object> 
serviceParams) {
+        this.serviceParams = serviceParams;
+        return this;
+    }
+
+    /**
+     * indicate that we want to persist result for this tracker
+     * @return this
+     */
+    public JobTrackerFactory persistResult() {
+        this.persistResult = Boolean.TRUE;
+        return this;
+    }
+
+    /**
+     * indicate if we want to persist result for this tracker
+     * @return this
+     */
+    public JobTrackerFactory persistResult(Boolean persistResult) {
+        this.persistResult = persistResult;
+        return this;
+    }
+
+    /**
+     * @param dispatcher
+     * @param jobTrackerId
+     * @return the jobTracker corresponding to the jobTrackerId
+     * @throws GenericEntityException
+     */
+    public static JobTracker getJobTracker(LocalDispatcher dispatcher, String 
jobTrackerId) throws GenericEntityException {
+        if (UtilValidate.isEmpty(jobTrackerId)) {
+            return null;
+        }
+        JobTracker jobTracker = TRACKER_CACHE.get(jobTrackerId);
+        if (jobTracker == null) {
+            GenericValue trackerValue = 
EntityQuery.use(dispatcher.getDelegator()).from("JobTracker")
+                    .where("jobTrackerId", jobTrackerId)
+                    .queryOne();
+            if (trackerValue == null) {
+                throw new GenericEntityException("Could not find jobTracker 
with ID: " + jobTrackerId);
+            }
+            jobTracker = TRACKER_CACHE.putIfAbsentAndGet(jobTrackerId, new 
JobTracker(dispatcher, trackerValue));
+        }
+        return jobTracker;
+    }
+
+    /**
+     * @return a jobTracker instance with values prepare with the factory
+     * @throws GenericServiceException
+     * @throws GenericEntityException
+     */
+    public JobTracker instantiate() throws GenericServiceException, 
GenericEntityException {
+        GenericValue trackerUserLogin;
+        if (!isEmpty(this.userLogin)) {
+            trackerUserLogin = this.userLogin;
+        } else if (!isEmpty(serviceParams.get("userLogin"))) {
+            trackerUserLogin = (GenericValue) serviceParams.get("userLogin");
+        } else {
+            throw new GenericServiceException("No user login found for 
JobTracker init.");
+        }
+
+        JobTracker tracker = new JobTracker(dispatcher, timeZone, locale, 
trackerUserLogin,
+                jobTrackerId, serviceName, serviceParams,
+                isEmpty(persistResult) ? Boolean.FALSE : persistResult);
+        return TRACKER_CACHE.putIfAbsentAndGet(jobTrackerId, tracker);
+    }
+
+    /**
+     * clean cache for a jobTracker instance
+     */
+    public static void clean(String jobTrackerId) {
+        TRACKER_CACHE.remove(jobTrackerId);
+    }
+}
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/tracker/JobTrackerListener.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/tracker/JobTrackerListener.java
new file mode 100644
index 0000000000..c3bd9ab1bd
--- /dev/null
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/tracker/JobTrackerListener.java
@@ -0,0 +1,164 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+
+package org.apache.ofbiz.service.tracker;
+
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.util.EntityQuery;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.sql.Timestamp;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.ofbiz.base.util.UtilDateTime.adjustTimestamp;
+import static org.apache.ofbiz.base.util.UtilDateTime.nowTimestamp;
+import static org.apache.ofbiz.entity.condition.EntityCondition.makeCondition;
+import static org.apache.ofbiz.entity.condition.EntityOperator.IN;
+
+public class JobTrackerListener {
+    private String trackerId;
+    private Delegator delegator;
+
+    public JobTrackerListener(Delegator delegator, String trackerId) {
+        this.delegator = delegator;
+        this.trackerId = trackerId;
+    }
+
+    public JobTrackerListener(Delegator delegator, JobTracker tracker) {
+        this.delegator = delegator;
+        this.trackerId = tracker.getJobTrackerId();
+    }
+
+    /**
+     * give information of jobTracker instance with followed jobs
+     *
+     * @return a Map with all jobtracker information and
+     * for each jobs status the number and percentage of job in it
+     */
+    public Map<String, Object> state() {
+        List<GenericValue> jobQtyByStatus;
+        Map<String, Object> state;
+        try {
+            jobQtyByStatus = EntityQuery.use(delegator)
+                    .from("JobSandboxQtyByStatusAndJobTrackerView")
+                    .where("jobTrackerId", trackerId)
+                    .queryList();
+
+            GenericValue jobTracker = EntityQuery.use(delegator)
+                    .from("JobTracker")
+                    .where("jobTrackerId", trackerId)
+                    .queryOne();
+            if (UtilValidate.isEmpty(jobTracker)) {
+                return Collections.emptyMap();
+            }
+            state = jobTracker.getAllFields();
+            List<String> completedStatusIds = List.of("SERVICE_FAILED", 
"SERVICE_FINISHED", "SERVICE_CRASHED", "SERVICE_CANCELLED");
+            jobQtyByStatus.forEach(jobQty -> {
+                BigDecimal jobsCurrentQuantityForStatus = 
jobQty.getBigDecimal("quantity");
+                state.put(jobQty.getString("statusId"), 
jobsCurrentQuantityForStatus);
+                String percentageName = String.format("%s_percentage", 
jobQty.getString("statusId"));
+                BigDecimal jobsTotalQty = BigDecimal.valueOf((Long) 
state.get("jobsTotalQty"));
+                BigDecimal percentageValue = 
jobsCurrentQuantityForStatus.divide(
+                        BigDecimal.ZERO.compareTo(jobsTotalQty) < 0 ? 
jobsTotalQty : jobsCurrentQuantityForStatus,
+                        2, RoundingMode.HALF_UP);
+                state.put(percentageName, percentageValue);
+                if (completedStatusIds.contains(jobQty.getString("statusId"))) 
{
+                    UtilMisc.addToBigDecimalInMap(state, "totalCompleted", 
jobsCurrentQuantityForStatus);
+                    UtilMisc.addToBigDecimalInMap(state, 
"totalCompleted_percentage", percentageValue);
+                }
+            });
+        } catch (GenericEntityException e) {
+            throw new RuntimeException(e);
+        }
+        return state;
+    }
+
+    /**
+     * "SERVICE_PENDING" , "SERVICE_QUEUED" , "SERVICE_RUNNING" , 
"SERVICE_FINISHED" ,
+     * "SERVICE_FAILED" , "SERVICE_CRASHED" , "SERVICE_ON_HOLD" , 
"SERVICE_CANCELLED" ,
+     * Checks that all job related to this tracker are done running
+     *
+     * @return true if all related jobs are at a terminal status
+     */
+    public boolean isFinished() {
+        final List<String> notFinishedStatuses = List.of("SERVICE_PENDING", 
"SERVICE_QUEUED", "SERVICE_RUNNING", "SERVICE_ON_HOLD");
+        try {
+            return 
EntityQuery.use(delegator).from("JobSandbox").where(makeCondition(
+                            makeCondition("jobTrackerId", trackerId),
+                            makeCondition("statusId", IN, 
notFinishedStatuses)))
+                    .queryCount() < 1;
+        } catch (GenericEntityException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * @return the estimated completion time when the jobTacker seems to be 
completed
+     */
+    public Timestamp getEstimatedCompletionTime() {
+        long remainingSeconds = getEstimatedTotalTime();
+        return adjustTimestamp(nowTimestamp(), Calendar.MILLISECOND, 
Math.toIntExact(remainingSeconds));
+    }
+
+    /**
+     * Return the estimated number of seconds remaining for the full process 
to end.
+     * Return -1 if finished
+     *
+     * @return
+     */
+    public long getEstimatedTotalTime() {
+        Map<String, Object> state = state();
+        if (UtilValidate.isEmpty(state)) {
+            return -1;
+        }
+        BigDecimal totalCompleted = (BigDecimal) state.get("totalCompleted");
+        if (totalCompleted == null) {
+            return -1;
+        }
+        long totalJobsCompleted = totalCompleted.longValue();
+        Timestamp startDate = (Timestamp) state.get("startDate");
+        long jobsTotalQty = (long) state.get("jobsTotalQty");
+        if (totalJobsCompleted == jobsTotalQty) {
+            return -1;
+        }
+        long milliSecSinceStart = (nowTimestamp().getTime() - 
startDate.getTime());
+        return totalJobsCompleted == 0L ? 0L : (milliSecSinceStart * 
jobsTotalQty) / totalJobsCompleted; // Produit en croix
+    }
+
+    /**
+     * @return the remaining time when the jobTacker seems to be completed
+     */
+    public long getEstimatedRemainingTime() {
+        Timestamp startDate = (Timestamp) state().get("startDate");
+        if (startDate == null) {
+            return 0;
+        }
+        long milliSecSinceStart = (nowTimestamp().getTime() - 
startDate.getTime());
+        return getEstimatedTotalTime() - milliSecSinceStart;
+    }
+
+}
diff --git 
a/framework/service/src/test/groovy/org/apache/ofbizservice/test/TrackerTestServices.groovy
 
b/framework/service/src/test/groovy/org/apache/ofbizservice/test/TrackerTestServices.groovy
new file mode 100644
index 0000000000..c71a54b6c6
--- /dev/null
+++ 
b/framework/service/src/test/groovy/org/apache/ofbizservice/test/TrackerTestServices.groovy
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.ofbizservice.test
+
+import org.apache.ofbiz.base.util.UtilDateTime
+import org.apache.ofbiz.service.ModelService
+
+Map testTopLevelServiceThatPlansTrackedServices() {
+    3.times {
+        dispatcher.runAsyncTracked('ping', context.jobTrackerId, [:])
+    }
+    return success([jobTrackerId: context.jobTrackerId])
+}
+
+Map serviceForTestingTracker() {
+    20.times {
+        dispatcher.runAsyncTracked('ServiceThatWaitsAMoment', 
context.jobTrackerId, [userLogin: context.userLogin])
+    }
+    return success()
+}
+
+Map serviceThatWaitsAMoment() {
+    sleep(2 * 1000) // 10s
+    return success([(ModelService.SUCCESS_MESSAGE): "Message at 
${UtilDateTime.nowDateString()}"])
+}
diff --git a/framework/service/testdef/servicetests.xml 
b/framework/service/testdef/servicetests.xml
index 6bd2b788e8..826879ee7d 100644
--- a/framework/service/testdef/servicetests.xml
+++ b/framework/service/testdef/servicetests.xml
@@ -36,7 +36,9 @@ under the License.
     <test-case case-name="service-dead-lock-retry-assert-data">
         <entity-xml action="assert" 
entity-xml-url="component://service/testdef/data/ServiceDeadLockRetryAssertData.xml"/>
     </test-case>
-
+    <test-case case-name="engine-tracker-tests">
+        <junit-test-suite name="engine-tracker" 
class-name="org.apache.ofbiz.service.test.engine.TrackerEngineTest"/>
+    </test-case>
     <!-- this case is failing, so commenting out by default until an automatic 
fix can be found
     <test-case case-name="service-lock-wait-timeout-retry-test">
         <service-test service-name="testServiceLockWaitTimeoutRetry"/>
diff --git a/framework/webtools/config/WebtoolsUiLabels.xml 
b/framework/webtools/config/WebtoolsUiLabels.xml
index 00443a0220..1df293e5ed 100644
--- a/framework/webtools/config/WebtoolsUiLabels.xml
+++ b/framework/webtools/config/WebtoolsUiLabels.xml
@@ -593,6 +593,14 @@
         <value xml:lang="zh">查找任务</value>
         <value xml:lang="zh-TW">尋找任務</value>
     </property>
+    <property key="PageTitleFindJobTracker">
+        <value xml:lang="en">Job tracker</value>
+        <value xml:lang="fr">Traceur de job</value>
+    </property>
+    <property key="PageTitleFindJobTrackerResults">
+        <value xml:lang="en">Job tracker results</value>
+        <value xml:lang="fr">Résultat du traceur de job</value>
+    </property>
     <property key="PageTitleFindUtilCache">
         <value xml:lang="de">Cache Wartungsseite</value>
         <value xml:lang="en">Cache Maintenance Page</value>
@@ -685,6 +693,10 @@
         <value xml:lang="en">Job manager locks list</value>
         <value xml:lang="fr">Blocage du gestionnaire de service</value>
     </property>
+    <property key="PageTitleJobTrackerDetails">
+        <value xml:lang="en">Job tracker details</value>
+        <value xml:lang="fr">Détails du traceur de job</value>
+    </property>
     <property key="PageTitleScheduleJob">
         <value xml:lang="de">Job anlegen</value>
         <value xml:lang="en">Schedule Job</value>
@@ -2917,6 +2929,14 @@
         <value xml:lang="en">Job manager locks enabled</value>
         <value xml:lang="fr">Blocages actifs sur le gestionnaire de 
service</value>
     </property>
+    <property key="WebtoolsJobTrackerJobsEvolution">
+        <value xml:lang="en">Tracked jobs evolution</value>
+        <value xml:lang="fr">État des jobs suivis</value>
+    </property>
+    <property key="WebtoolsJobTrackerList">
+        <value xml:lang="en">Job Tracker List</value>
+        <value xml:lang="fr">Liste des traceurs de job</value>
+    </property>
     <property key="WebtoolsLHSMapName">
         <value xml:lang="de">Name der LHS-Map</value>
         <value xml:lang="en">LHS map name</value>
diff --git 
a/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/service/JobTrackerDetails.groovy
 
b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/service/JobTrackerDetails.groovy
new file mode 100644
index 0000000000..e9ef62df65
--- /dev/null
+++ 
b/framework/webtools/src/main/groovy/org/apache/ofbiz/webtools/service/JobTrackerDetails.groovy
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+*/
+package org.apache.ofbiz.webtools.service
+
+import org.apache.ofbiz.service.tracker.JobTrackerListener
+import java.text.SimpleDateFormat
+
+JobTrackerListener listener = new JobTrackerListener(delegator, 
parameters.jobTrackerId as String)
+context.state = listener?.state() ?: [:]
+long remainingTime = listener.getEstimatedRemainingTime() ?: 0L
+if (remainingTime <= 0) {
+    context.state.remainingTime = 'Not applicable'
+} else {
+    SimpleDateFormat df = remainingTime > (60 * 60 * 1000) ? // > 1h ?
+            new SimpleDateFormat('HH:mm:ss', context.locale as Locale) :
+            new SimpleDateFormat('mm:ss', context.locale as Locale)
+
+    context.state.remainingTime = df.format(new Date(remainingTime))
+}
+
+context.state = listener.state()
diff --git a/framework/webtools/template/Main.ftl 
b/framework/webtools/template/Main.ftl
index e683f42b14..3fe4b09780 100644
--- a/framework/webtools/template/Main.ftl
+++ b/framework/webtools/template/Main.ftl
@@ -89,6 +89,7 @@ under the License.
           <li><a 
href="<@ofbizUrl>FindJob</@ofbizUrl>">${uiLabelMap.PageTitleJobList}</a></li>
           <li><a 
href="<@ofbizUrl>threadList</@ofbizUrl>">${uiLabelMap.PageTitleThreadList}</a></li>
           <li><a 
href="<@ofbizUrl>FindJobManagerLock</@ofbizUrl>">${uiLabelMap.PageTitleJobManagerLockList}</a></li>
+          <li><a 
href="<@ofbizUrl>FindJobTracker</@ofbizUrl>">${uiLabelMap.WebtoolsJobTrackerList}</a></li>
           <li><a 
href="<@ofbizUrl>ServiceLog</@ofbizUrl>">${uiLabelMap.WebtoolsServiceLog}</a></li>
         </#if>
         <#if security.hasPermission("DATAFILE_MAINT", session)>
diff --git a/framework/webtools/webapp/webtools/WEB-INF/controller.xml 
b/framework/webtools/webapp/webtools/WEB-INF/controller.xml
index a2499a9204..e6ce70effa 100644
--- a/framework/webtools/webapp/webtools/WEB-INF/controller.xml
+++ b/framework/webtools/webapp/webtools/WEB-INF/controller.xml
@@ -330,6 +330,25 @@ under the License.
         <security https="true" auth="true"/>
         <response name="success" type="view" value="JobDetails"/>
     </request-map>
+    <request-map uri="FindJobTracker"><security/><response name="success" 
type="view" value="FindJobTracker"/></request-map>
+    <request-map uri="JobTrackerDetails"><security/><response name="success" 
type="view" value="JobTrackerDetails"/></request-map>
+    <request-map uri="FindJobTrackerResult"><security/><response 
name="success" type="view" value="FindJobTrackerResult"/></request-map>
+    <request-map uri="JobTrackerState"><security/><response name="success" 
type="view" value="JobTrackerState"/></request-map>
+    <request-map uri="pauseJobTracker"><security/>
+        <event type="service" invoke="pauseJobTracker"/>
+        <response name="success" type="view" value="JobTrackerDetails"/>
+        <response name="error" type="view" value="JobTrackerDetails"/>
+    </request-map>
+    <request-map uri="resumeJobTracker"><security/>
+        <event type="service" invoke="resumeJobTracker"/>
+        <response name="success" type="view" value="JobTrackerDetails"/>
+        <response name="error" type="view" value="JobTrackerDetails"/>
+    </request-map>
+    <request-map uri="cancelJobTracker"><security/>
+        <event type="service" invoke="cancelJobTracker"/>
+        <response name="success" type="view" value="JobTrackerDetails"/>
+        <response name="error" type="view" value="JobTrackerDetails"/>
+    </request-map>
     <request-map uri="cancelJob">
         <security https="true" auth="true"/>
         <event type="service" invoke="cancelScheduledJob"/>
@@ -688,6 +707,10 @@ under the License.
     <view-map name="ServiceList" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#ServiceList"/>
     <view-map name="FindJob" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#FindJob"/>
     <view-map name="JobDetails" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#JobDetails"/>
+    <view-map name="FindJobTracker" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#FindJobTrackers"/>
+    <view-map name="JobTrackerDetails" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#JobTrackerDetails"/>
+    <view-map name="JobTrackerState" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#JobTrackerState"/>
+    <view-map name="FindJobTrackerResult" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#FindJobTrackerResults"/>
     <view-map name="serviceResult" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#ServiceResult"/>
     <view-map name="threadList" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#ThreadList"/>
     <view-map name="scheduleJob" type="screen" 
page="component://webtools/widget/ServiceScreens.xml#ScheduleJob"/>
diff --git a/framework/webtools/widget/Menus.xml 
b/framework/webtools/widget/Menus.xml
index 34b6e93c25..f4deacd485 100644
--- a/framework/webtools/widget/Menus.xml
+++ b/framework/webtools/widget/Menus.xml
@@ -108,6 +108,9 @@ under the License.
         <menu-item name="findJob" title="${uiLabelMap.WebtoolsJobList}">
             <link target="FindJob"/>
         </menu-item>
+        <menu-item name="jobTrackerList" 
title="${uiLabelMap.WebtoolsJobTrackerList}">
+            <link target="FindJobTracker"/>
+        </menu-item>
         <menu-item name="threadList" title="${uiLabelMap.WebtoolsThreadList}">
             <link target="threadList"/>
         </menu-item>
diff --git a/framework/webtools/widget/ServiceForms.xml 
b/framework/webtools/widget/ServiceForms.xml
index 9dc23ccd20..7393f1d549 100644
--- a/framework/webtools/widget/ServiceForms.xml
+++ b/framework/webtools/widget/ServiceForms.xml
@@ -230,4 +230,77 @@ under the License.
             </hyperlink>
         </field>
     </grid>
+    <form name="FindJobTrackers" type="single" target="FindJobTracker" 
default-entity-name="JobTracker">
+        <field name="noConditionFind"><hidden value="Y"/><!-- if this isn't 
there then with all fields empty no query will be done --></field>
+        <field name="jobTrackerId"><text-find/></field>
+        <field name="statusId">
+            <drop-down allow-empty="true">
+                <entity-options key-field-name="statusId" 
entity-name="StatusItem">
+                    <entity-constraint name="statusTypeId" operator="equals" 
value="JOB_TRACKER_STATUS"/>
+                    <entity-order-by field-name="description"/>
+                </entity-options>
+            </drop-down>
+        </field>
+        <field name="searchButton"><submit/></field>
+    </form>
+    <grid name="ListJobTrackers" list-name="listIt" 
paginate-target="FindJobTracker" default-entity-name="JobTracker" 
separate-columns="true">
+        <actions>
+            <set field="orderBy" default-value="-startDate" 
from-field="parameters.sortField"/>
+            <service service-name="performFind" result-map="result" 
result-map-list="listIt">
+                <field-map field-name="inputFields" from-field="parameters"/>
+                <field-map field-name="entityName" value="JobTracker"/>
+                <field-map field-name="orderBy" from-field="orderBy"/>
+            </service>
+        </actions>
+        <field name="jobTrackerId" sort-field="true">
+            <hyperlink description="${jobTrackerId}" 
target="JobTrackerDetails" also-hidden="false">
+                <parameter param-name="jobTrackerId"/>
+            </hyperlink>
+        </field>
+        <field name="startDate" title="${uiLabelMap.CommonStartDate}" 
sort-field="true"><display format="date-time"/></field>
+        <field name="completionDate" title="${uiLabelMap.CommonEndDate}" 
sort-field="true"><display format="date-time"/></field>
+        <field name="cancelDate" title="${uiLabelMap.CommonCancelDate}" 
sort-field="true"><display format="date-time"/></field>
+        <field name="serviceName" title="${uiLabelMap.WebtoolsService}" 
sort-field="true">
+            <hyperlink description="${serviceName}" target="ServiceList" 
also-hidden="false">
+                <parameter param-name="sel_service_name" 
from-field="serviceName"/>
+            </hyperlink>
+        </field>
+        <field name="statusId" sort-field="true"><display-entity 
entity-name="StatusItem"/></field>
+        <field name="jobsTotalQty" title="${uiLabelMap.CommonTotal}" 
sort-field="true"><display/></field>
+    </grid>
+    <form name="JobTrackerDetails" type="single" default-map-name="state">
+        <auto-fields-entity entity-name="JobTracker" 
default-field-type="display"/>
+        <field name="statusId"><display-entity 
entity-name="StatusItem"/></field>
+        <field title=" " name="pauseTracker" use-when="state.statusId == 
'JOB_T_RUNNING'">
+            <hyperlink description="${uiLabelMap.CommonHold}" 
target="pauseJobTracker" link-type="anchor" style="buttontext">
+                <parameter param-name="jobTrackerId" 
from-field="parameters.jobTrackerId"/>
+            </hyperlink>
+        </field>
+        <field title=" " name="resumeTracker" use-when="state.statusId == 
'JOB_T_ON_HOLD'">
+            <hyperlink description="${uiLabelMap.CommonRestart}" 
target="resumeJobTracker" link-type="anchor" style="buttontext">
+                <parameter param-name="jobTrackerId" 
from-field="parameters.jobTrackerId"/>
+            </hyperlink>
+        </field>
+        <field title=" " name="resultTracker" use-when="state.statusId == 
'JOB_T_FINISHED'">
+            <hyperlink description="${uiLabelMap.WebtoolsResults}" 
target="FindJobTrackerResult" link-type="anchor" style="buttontext">
+                <parameter param-name="jobTrackerId" 
from-field="parameters.jobTrackerId"/>
+            </hyperlink>
+        </field>
+    </form>
+    <form name="FindJobTrackerResults" type="single" target="FindJobTracker" 
default-entity-name="JobTracker">
+        <auto-fields-entity entity-name="TrackedJobResult" 
default-field-type="find"/>
+        <field name="noConditionFind"><hidden value="N"/></field>
+        <field name="searchButton"><submit/></field>
+    </form>
+    <grid name="ListJobTrackerResults" list-name="listIt" 
paginate-target="FindJobTrackerResult" default-entity-name="TrackedJobResult">
+        <actions>
+            <set field="orderBy" default-value="jobTrackerResultSeqId" 
from-field="parameters.sortField"/>
+            <service service-name="performFind" result-map="result" 
result-map-list="listIt">
+                <field-map field-name="inputFields" from-field="parameters"/>
+                <field-map field-name="entityName" value="TrackedJobResult"/>
+                <field-map field-name="orderBy" from-field="orderBy"/>
+            </service>
+        </actions>
+        <auto-fields-entity entity-name="TrackedJobResult" 
default-field-type="display"/>
+    </grid>
 </forms>
diff --git a/framework/webtools/widget/ServiceScreens.xml 
b/framework/webtools/widget/ServiceScreens.xml
index 373f6d2fa8..99ff9579e9 100644
--- a/framework/webtools/widget/ServiceScreens.xml
+++ b/framework/webtools/widget/ServiceScreens.xml
@@ -121,6 +121,121 @@ under the License.
         </section>
     </screen>
 
+    <screen name="FindJobTrackers">
+        <section>
+            <actions>
+                <set field="titleProperty" value="PageTitleFindJobTracker"/>
+                <set field="tabButtonItem" value="jobTrackerList"/>
+            </actions>
+            <widgets>
+                <decorator-screen name="CommonServiceDecorator" 
location="${parameters.mainDecoratorLocation}">
+                    <decorator-section name="body">
+                        <container style="page-title"><label 
text="${uiLabelMap[titleProperty]}"/></container>
+                        <screenlet id="searchOptions" name="findScreenlet" 
collapsible="true" title="${uiLabelMap.CommonSearchOptions}">
+                            <container id="search-options">
+                                <include-form name="FindJobTrackers" 
location="component://webtools/widget/ServiceForms.xml"/>
+                            </container>
+                        </screenlet>
+                        <screenlet padded="false">
+                            <label style="h3" 
text="${uiLabelMap.CommonSearchResults}"/>
+                            <container id="search-results">
+                                <include-grid name="ListJobTrackers" 
location="component://webtools/widget/ServiceForms.xml"/>
+                            </container>
+                        </screenlet>
+                    </decorator-section>
+                </decorator-screen>
+            </widgets>
+        </section>
+    </screen>
+    <screen name="JobTrackerDetails">
+        <section>
+            <actions>
+                <set field="titleProperty" value="PageTitleJobTrackerDetails"/>
+                <set field="tabButtonItem" value="jobTrackerList"/>
+                <script 
location="component://webtools/src/main/groovy/org/apache/ofbiz/webtools/service/JobTrackerDetails.groovy"/>
+            </actions>
+            <widgets>
+                <decorator-screen name="CommonServiceDecorator" 
location="${parameters.mainDecoratorLocation}">
+                    <decorator-section name="body">
+                        <screenlet 
title="${uiLabelMap.PageTitleJobTrackerDetails}">
+                            <include-form name="JobTrackerDetails" 
location="component://webtools/widget/ServiceForms.xml"/>
+                        </screenlet>
+                        <container 
auto-update-target="JobTrackerState?jobTrackerId=${parameters.jobTrackerId}"
+                                   auto-update-interval="2">
+                            <include-screen name="JobTrackerState"/>
+                        </container>
+                    </decorator-section>
+                </decorator-screen>
+            </widgets>
+        </section>
+    </screen>
+    <screen name="JobTrackerState">
+        <section>
+            <actions>
+                <property-map resource="WebtoolsUiLabels" 
map-name="uiLabelMap" global="true"/>
+                <property-map resource="CommonUiLabels" map-name="uiLabelMap" 
global="true"/>
+                <entity-and entity-name="StatusItem" list="statusItems">
+                    <field-map field-name="statusTypeId" 
value="SERVICE_STATUS"/>
+                    <order-by field-name="sequenceId"/>
+                </entity-and>
+                <script 
location="component://webtools/src/main/groovy/org/apache/ofbiz/webtools/service/JobTrackerDetails.groovy"/>
+            </actions>
+            <widgets>
+                <decorator-screen name="EmbeddedDecorator" 
location="component://common/widget/CommonScreens.xml">
+                    <decorator-section name="list">
+                        <screenlet 
title="${uiLabelMap.WebtoolsJobTrackerJobsEvolution}">
+                            <iterate-section entry="status" list="statusItems">
+                                <section>
+                                    <actions>
+                                        <set field="percentage" 
value="${groovy:state.get(status.statusId + '_percentage')}" default-value="0" 
type="BigDecimal"/>
+                                    </actions>
+                                    <widgets>
+                                        <label>${status.description}: 
${groovy: state.(status.statusId)} (${groovy: percentage * 100} %)</label>
+                                        <horizontal-separator/>
+                                    </widgets>
+                                </section>
+                            </iterate-section>
+                            <section>
+                                <actions>
+                                    <set field="percentage" 
from-field="state.totalCompleted_percentage" default-value="0" 
type="BigDecimal"/>
+                                </actions>
+                                <widgets>
+                                    <label 
style="h2">${uiLabelMap.CommonTotalDone}: ${state.totalCompleted} (${groovy: 
percentage * 100} %)</label>
+                                </widgets>
+                            </section>
+                        </screenlet>
+                    </decorator-section>
+                </decorator-screen>
+            </widgets>
+        </section>
+    </screen>
+    <screen name="FindJobTrackerResults">
+        <section>
+            <actions>
+                <set field="titleProperty" 
value="PageTitleFindJobTrackerResults"/>
+                <set field="tabButtonItem" value="jobTrackerList"/>
+            </actions>
+            <widgets>
+                <decorator-screen name="CommonServiceDecorator" 
location="${parameters.mainDecoratorLocation}">
+                    <decorator-section name="body">
+                        <container style="page-title"><label 
text="${uiLabelMap[titleProperty]}"/></container>
+                        <screenlet id="searchOptions" name="findScreenlet" 
collapsible="true" title="${uiLabelMap.CommonSearchOptions}">
+                            <container id="search-options">
+                                <include-form name="FindJobTrackerResults" 
location="component://webtools/widget/ServiceForms.xml"/>
+                            </container>
+                        </screenlet>
+                        <screenlet padded="false">
+                            <label style="h3" 
text="${uiLabelMap.CommonSearchResults}"/>
+                            <container id="search-results">
+                                <include-grid name="ListJobTrackerResults" 
location="component://webtools/widget/ServiceForms.xml"/>
+                            </container>
+                        </screenlet>
+                    </decorator-section>
+                </decorator-screen>
+            </widgets>
+        </section>
+    </screen>
+
     <screen name="ScheduleJob">
         <section>
             <actions>

Reply via email to