Skip to content

Commit

Permalink
Asynchronous Export Progress Updates. (openemr#6977)
Browse files Browse the repository at this point in the history
* Asynchronous Export Progress Updates.

This change adds progress indicators to the export process.  It will
loop through each batch individually and process the batch while
reporting progress in 5 second intervals on the currently processed
batch.  If a batch fails it can be retried.

Added a new column to the task to hold the current result progress.

Changed up the index page to function as both the main index and an ajax
controller.  Probably could break this apart but for expediency did it
this way.

* Fix escaping, deprecation notices.

Had an escaping issue on the twig file.

There were a number of dynamic properties created in php 8.2.  Thanks to
@stephen.waite for pointing them out.
  • Loading branch information
adunsulag committed Nov 6, 2023
1 parent 0a27135 commit bbc52c8
Show file tree
Hide file tree
Showing 16 changed files with 680 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# 1.0.1
Implemented asynchronous progress status and progress updates for batch exports
# 1.0.0 Initial release of the EHI exporter
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Electronic Health Information Exporter v1.0.0
Electronic Health Information Exporter v1.0.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
(function(window, oeExporter) {

class ExporterState {
taskIds = [];
currentTaskIndex = 0;
ajaxUrl = "";
csrfToken = "";

currentTaskPollingInterval = 0;
currentTaskPollingTimeout = 5000;

startExport() {
this.currentTaskIndex = -1;
this.runNextExport();

}
runNextExport() {
this.currentTaskIndex++;
if (this.currentTaskIndex < this.taskIds.length) {
let callBack = function() {
this.startExportRequestForTask(this.taskIds[this.currentTaskIndex]);
};
// just give a way to break promise callback chain
setTimeout(callBack.bind(this), 100);
} else {
// if we've finished everything... then we should clear the polling interval
this.clearPollingForExportStatus();
}
}

showErrorCardForTaskId(taskId, errorMessage='') {
// hide the processing div template node
let processingTask = document.querySelector(".template-task-processing[data-task-id='" + taskId + "']");
if (!processingTask) {
console.error("Could not find processing task for task id: " + taskId);
return;
}
processingTask.classList.add("d-none");

// grab the error div template node
let errorTaskTemplate = document.querySelector(".template-task-failed");
let errorTask = errorTaskTemplate.cloneNode(true);

// populate the error div template node with the task id, the patient pids
errorTask.querySelector(".taskId").innerText = taskId;
errorTask.dataset['taskId'] = taskId;
// show the error div template node
if (errorMessage) {
errorTask.querySelector(".errorMessage").innerText = errorMessage;
}
// TODO: @adunsulag need to handle what happens when they retry the export and we need to move on to the next
// possible export. Should we disable all of the buttons until the export has processed everything...
errorTask.querySelector(".btn-retry-export-task").addEventListener("click", () => {
errorTask.remove();
this.startExportRequestForTask(taskId);
});
errorTask.classList.remove("d-none");
processingTask.insertAdjacentElement("afterend", errorTask);
processingTask.remove(); // remove the processing node at the end since we don't need it.
}

startExportRequestForTask(taskId) {
// hide the queued div template node
this.showProcessingCardForTaskId(taskId, {taskId: taskId});
// send off the ajax request to start the export
let formParams = new FormData();
formParams.set("taskId", taskId);
formParams.set("submit", "Start Export");
formParams.set("action", "startExport");
formParams.set("_token", this.csrfToken);
window.top.restoreSession(); // make sure the session is populated before we send off an ajax request
let resultPromise = window.fetch(this.ajaxUrl, {
method: 'POST',
body: new URLSearchParams(formParams)
});
let exporterState = this;
resultPromise.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error('Failed to receive response from server');
}
})
.then(data => {
if (data.status == 'failed') {
this.showErrorCardForTaskId(taskId, data.error_message);
// move onto the next task in the queue
return exporterState.runNextExport();
} else {
this.showSuccessCardForTaskId(taskId, data);
return exporterState.runNextExport();
}
})
.catch(error => {
console.log(error);
this.showErrorCardForTaskId(taskId, error.message);
return exporterState.runNextExport();
});
// TODO: @adunsulag start the polling for the export status
this.startPollingForExportStatus(taskId);
}

startPollingForExportStatus(taskId) {
if (this.currentTaskPollingInterval > 0) {
this.clearPollingForExportStatus();
}
this.currentTaskPollingInterval = setInterval(this.pollForExportStatus.bind(this), this.currentTaskPollingTimeout, taskId);
}
clearPollingForExportStatus() {
clearInterval(this.currentTaskPollingInterval);
}
pollForExportStatus(taskId) {
let formParams = new FormData();
formParams.set("taskId", taskId);
formParams.set("submit", "Get Status");
formParams.set("action", "statusUpdate");
formParams.set("_token", this.csrfToken);
window.top.restoreSession(); // make sure the session is populated before we send off an ajax request
let resultPromise = window.fetch(this.ajaxUrl, {
method: 'POST',
body: new URLSearchParams(formParams)
});
let exporterState = this;
resultPromise.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error('Failed to receive response from server');
}
})
.then(data => {
if (data.status == 'failed') {
this.showErrorCardForTaskId(taskId, data.error_message);
} else if (data.status == 'completed') {
this.showSuccessCardForTaskId(taskId, data);
} else {
this.showProcessingCardForTaskId(taskId, data);
}
})
.catch(error => {
this.showErrorCardForTaskId(taskId, error.message);
console.log(error);
});
}

showProcessingCardForTaskId(taskId, data) {
let queuedTask = document.querySelector(".template-task-queued[data-task-id='" + taskId + "']");
queuedTask.classList.add("d-none");

// if there are any existing processing tasks we need to remove them
let processingTasks = document.querySelectorAll(".template-task-processing[data-task-id='" + taskId + "']");
processingTasks.forEach(function (task) {
task.remove();
});

// grab the processing div template node
let processingTaskTemplate = document.querySelector(".template-task-processing");
let processingTask = processingTaskTemplate.cloneNode(true);

// populate the processing div template node with the task id, the patient pids
processingTask.querySelector(".taskId").innerText = taskId;
processingTask.dataset['taskId'] = taskId;
processingTask.querySelector(".patientPids").innerText = queuedTask.querySelector(".patientPids").innerText;
// show the processing div template node
if (data.exportedResult) {
this.populateCardWithResultData(processingTask, taskId, data);
}
processingTask.classList.remove("d-none");
queuedTask.insertAdjacentElement("afterend", processingTask);
}

populateCardWithResultData(cardNode, taskId, data) {
// .exportedTablesList needs to be looped on the data.exportedResult table
let totalTablesExported = 0;
let totalRecordsExported = 0;
if (data.exportedResult) {
if (data.exportedResult.exportedTables) {
let tableNames = Object.keys(data.exportedResult.exportedTables);
totalTablesExported = tableNames.length;
let itemTemplate = cardNode.querySelector(".exportedTableListItem");
let templateParent = itemTemplate.parentNode;
for (let i = 0; i < totalTablesExported; i++) {
let tableItem = data.exportedResult.exportedTables[tableNames[i]];
let exportedTableListItem = itemTemplate.cloneNode(true);
exportedTableListItem.classList.remove("d-none");
exportedTableListItem.querySelector(".exportedTableName").innerText = tableItem.tableName + ".csv";
exportedTableListItem.querySelector(".exportedTableCount").innerText = tableItem.count;
totalRecordsExported += tableItem.count;
templateParent.appendChild(exportedTableListItem);
}
}
if (data.exportedResult.exportedDocumentCount >= 0) {
cardNode.querySelector(".documentsExportedCount").innerText = data.exportedResult.exportedDocumentCount;
}
}
cardNode.querySelector(".total-tables-exported").innerText = totalTablesExported;
cardNode.querySelector(".total-records-exported").innerText = totalRecordsExported;

if (data.includePatientDocuments) {
cardNode.querySelector(".documentsExportedSection").classList.remove("d-none");
}
}

showSuccessCardForTaskId(taskId, data) {
let processingTask = document.querySelector(".template-task-processing[data-task-id='" + taskId + "']");
processingTask.classList.add("d-none");

// grab the error div template node
let successTemplate = document.querySelector(".template-result-success");
let successTask = successTemplate.cloneNode(true);

successTask.querySelector(".taskId").innerText = taskId;
successTask.dataset['taskId'] = taskId;

// .download-link .download-link-name need to be populated
successTask.querySelector(".download-link-name").innerText = data.downloadName;
successTask.querySelector(".download-link").href = data.downloadLink;
successTask.querySelector(".download-link").addEventListener('click', function() {
window.top.restoreSession(); // make sure the session is populated before the download starts
});
// .hash-algo-title, .hash-text need to be populated
successTask.querySelector(".hash-algo-title").innerText = data.hashAlgoTitle;
successTask.querySelector(".hash-text").innerText = data.hash;
this.populateCardWithResultData(successTask, taskId, data);
successTask.classList.remove("d-none");
processingTask.insertAdjacentElement("afterend", successTask);
processingTask.remove(); // remove the processing node at the end since we don't need it.
}
}
let exporterState;

function displayExportStartDialog(dialogId) {
let container = document.getElementById(dialogId);
let modal = new bootstrap.Modal(container, {keyboard: false, focus: true, backdrop: 'static'});
modal.show();
}

oeExporter.displayExportStartDialog = displayExportStartDialog;
oeExporter.startTaskExports = function (ajaxUrl, csrfToken) {
let queuedTasks = document.querySelectorAll(".template-task-queued[data-task-id]");
let queuedTaskIds = [];
queuedTasks.forEach(function (task) {
queuedTaskIds.push(+task.dataset.taskId);
});
if (queuedTaskIds.length > 0) {
exporterState = new ExporterState();
exporterState.ajaxUrl = ajaxUrl;
exporterState.csrfToken = csrfToken;
exporterState.taskIds = queuedTaskIds;
exporterState.startExport();
}
};
window.oeExporter = oeExporter;
})(window, window.oeExporter || window.top.oeExporter || {});
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
$defaultZipSize = 500; // size in MB
$memoryLimitUpdated = false;
$errorMessage = "";
$twig = $bootstrap->getTwig();
if (isset($_POST['submit'])) {
try {
if (!CsrfUtils::verifyCsrfToken($_POST['_token'] ?? '')) {
Expand All @@ -36,26 +37,68 @@
$pid = intval($_POST['pid'] ?? 0);
$includeDocuments = intval($_POST['include_documents'] ?? 0) === 1;
$fileSizeLimit = intval($_POST['file_size_limit'] ?? 500);
if ($pid > 0) {
$result = $exporter->exportPatient($pid, $includeDocuments, $fileSizeLimit);
} else {
$result = $exporter->exportAll($includeDocuments, $fileSizeLimit);
if ($_POST['action'] == 'createExport') {
if ($pid > 0) {
$job = $exporter->createExportPatientJob($pid, $includeDocuments, $fileSizeLimit);
// $result = $exporter->exportPatient($pid, $includeDocuments, $fileSizeLimit);
} else {
$job = $exporter->createExportPatientPopulationJob($includeDocuments, $fileSizeLimit);
// $result = $exporter->exportAll($includeDocuments, $fileSizeLimit);
}
echo $twig->render(
Bootstrap::MODULE_NAME . DIRECTORY_SEPARATOR . 'ehi-exporter-tasks.html.twig',
[
'result' => $result
,'job' => $job
, 'assetPath' => $bootstrap->getAssetPath()
,'postUrl' => $GLOBALS['webroot'] . Bootstrap::MODULE_INSTALLATION_PATH . '/'
. Bootstrap::MODULE_NAME . '/public/index.php'
]
);
// TODO: @adunsulag we really should move all of this into a controller to be cleaner, but we are time crunched here.
} else if ($_POST['action'] == 'startExport') {
try {
$taskId = intval($_POST['taskId'] ?? 0);
$task = $exporter->runExportTask($taskId);
echo json_encode($task->getJSON());
} catch (\Exception $exception) {
$errorMessage = $exception->getMessage();
$bootstrap->getLogger()->errorLogCaller($errorMessage, ['trace' => $exception->getTraceAsString()]);
echo json_encode(['status' => 'failed', 'error_message' => $errorMessage, 'taskId' => $taskId]);
}
exit;
} else if ($_POST['action'] == 'statusUpdate') {
try {
$taskId = intval($_POST['taskId'] ?? 0);
$task = $exporter->getExportTaskForStatusUpdate($taskId);
// will already have the encoded progress results in the task
echo json_encode($task->getJSON());
} catch (\Exception $exception) {
$errorMessage = $exception->getMessage();
$bootstrap->getLogger()->errorLogCaller($errorMessage, ['trace' => $exception->getTraceAsString()]);
echo json_encode(['status' => 'failed', 'error_message' => $errorMessage, 'taskId' => $taskId]);
}
exit;
}
} catch (\Exception $exception) {
$errorMessage = $exception->getMessage();
$bootstrap->getLogger()->errorLogCaller($errorMessage, ['trace' => $exception->getTraceAsString()]);
}
} else {
$exportSizeSettings = $exporter->getExportSizeSettings($defaultZipSize);

echo $twig->render(
Bootstrap::MODULE_NAME . DIRECTORY_SEPARATOR . 'ehi-exporter.html.twig',
[
'result' => $result
, 'exportSizeSettings' => $exportSizeSettings
, 'memoryLimitUpdated' => $memoryLimitUpdated
// TODO: @adunsulag add most recent exports here.
, 'errorMessage' => $errorMessage
, 'postAction' => $_SERVER['PHP_SELF']
, 'site_addr_oath' => trim($GLOBALS['site_addr_oath'] ?? '')
, 'assetPath' => $bootstrap->getAssetPath()
]
);
}
$exportSizeSettings = $exporter->getExportSizeSettings($defaultZipSize);
$twig = $bootstrap->getTwig();
echo $twig->render(
Bootstrap::MODULE_NAME . DIRECTORY_SEPARATOR . 'ehi-exporter.html.twig',
[
'result' => $result, 'exportSizeSettings' => $exportSizeSettings, 'memoryLimitUpdated' => $memoryLimitUpdated
// TODO: @adunsulag add most recent exports here.
,'errorMessage' => $errorMessage
,'postAction' => $_SERVER['PHP_SELF']
,'site_addr_oath' => trim($GLOBALS['site_addr_oath'] ?? '')
]
);
exit;
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ public static function instantiate(EventDispatcher $eventDispatcher, Kernel $ker
return self::$instance;
}

public function getAssetPath()
{
return $GLOBALS['webroot'] . self::MODULE_INSTALLATION_PATH . $this->moduleDirectoryName . "/public/assets/";
}

public function getLogger()
{
return new SystemLogger();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,29 @@ public function hasPatientIds()
{
return !empty($this->pids);
}

public function getJSON()
{
$data = [
'status' => $this->status
, 'taskId' => $this->ehi_task_id
, 'includePatientDocuments' => false
];
if (isset($this->ehiExportJob)) {
$data['includePatientDocuments'] = $this->ehiExportJob->include_patient_documents;
}
if (isset($this->exportedResult)) {
// so we can update progress on the client side
$data['exportedResult'] = $this->exportedResult;
}
if ($this->status == 'completed') {
$data['hashAlgoTitle'] = $this->document->get_hash_algo_title();
$data['hash'] = $this->document->get_hash();
$data['downloadLink'] = $this->exportedResult->downloadLink;
$data['downloadName'] = $this->document->get_name();
} else if ($this->status == 'failed') {
$data['errorMessage'] = $this->error_message;
}
return $data;
}
}
Loading

0 comments on commit bbc52c8

Please sign in to comment.