From e819c3c802d60157fc720fe131e6816c668770e4 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Wed, 22 Jul 2026 15:12:05 -0600 Subject: [PATCH] FOUR-32333: Isolate DevLink progress by operation --- ProcessMaker/Events/ImportLog.php | 3 +- .../Controllers/Api/DevLinkController.php | 18 ++++- ProcessMaker/ImportExport/Logger.php | 13 +++- ProcessMaker/Jobs/DevLinkInstall.php | 14 +++- .../admin/devlink/components/AssetListing.vue | 12 ++- .../devlink/components/InstallProgress.vue | 10 +++ .../js/admin/devlink/components/Instance.vue | 12 ++- .../admin/devlink/components/UpdateBundle.vue | 13 +++- .../js/admin/devlink/createOperationId.js | 9 +++ tests/Feature/Api/DevLinkTest.php | 74 +++++++++++++++++++ tests/Feature/ImportExport/LoggerTest.php | 52 +++++++++++++ tests/Feature/Jobs/DevLinkInstallTest.php | 48 ++++++++++++ 12 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 resources/js/admin/devlink/createOperationId.js create mode 100644 tests/Feature/ImportExport/LoggerTest.php create mode 100644 tests/Feature/Jobs/DevLinkInstallTest.php diff --git a/ProcessMaker/Events/ImportLog.php b/ProcessMaker/Events/ImportLog.php index 96cbfe6aec..9168795f9d 100644 --- a/ProcessMaker/Events/ImportLog.php +++ b/ProcessMaker/Events/ImportLog.php @@ -16,7 +16,8 @@ public function __construct( public $userId, public $type, public $message, - public $additionalParams = [] + public $additionalParams = [], + public $operationId = null, ) { } diff --git a/ProcessMaker/Http/Controllers/Api/DevLinkController.php b/ProcessMaker/Http/Controllers/Api/DevLinkController.php index 8994184d1a..065aa3769b 100644 --- a/ProcessMaker/Http/Controllers/Api/DevLinkController.php +++ b/ProcessMaker/Http/Controllers/Api/DevLinkController.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; use ProcessMaker\Events\CustomizeUiUpdated; use ProcessMaker\Exception\ValidationException; @@ -211,6 +212,7 @@ public function deleteBundle(Bundle $bundle) public function installRemoteBundle(Request $request, DevLink $devLink, $remoteBundleId) { $updateType = $request->input('updateType', DevLinkInstall::MODE_UPDATE); + $operationId = $this->operationId($request); DevLinkInstall::dispatch( $request->user()->id, $devLink->id, @@ -218,6 +220,7 @@ public function installRemoteBundle(Request $request, DevLink $devLink, $remoteB $remoteBundleId, $updateType, DevLinkInstall::TYPE_INSTALL_BUNDLE, + $operationId, ); return [ @@ -228,6 +231,7 @@ public function installRemoteBundle(Request $request, DevLink $devLink, $remoteB public function reinstallBundle(Request $request, Bundle $bundle) { $updateType = $request->input('updateType', DevLinkInstall::MODE_UPDATE); + $operationId = $this->operationId($request); DevLinkInstall::dispatch( $request->user()->id, $bundle->dev_link_id, @@ -235,6 +239,7 @@ public function reinstallBundle(Request $request, Bundle $bundle) $bundle->id, $updateType, DevLinkInstall::TYPE_REINSTALL_BUNDLE, + $operationId, ); return [ @@ -356,6 +361,7 @@ public function removeSharedAsset($id) public function installRemoteAsset(Request $request, DevLink $devLink) { $updateType = $request->input('updateType', DevLinkInstall::MODE_UPDATE); + $operationId = $this->operationId($request); DevLinkInstall::dispatch( $request->user()->id, @@ -363,7 +369,8 @@ public function installRemoteAsset(Request $request, DevLink $devLink) $request->input('class'), $request->input('id'), $updateType, - DevLinkInstall::TYPE_IMPORT_ASSET + DevLinkInstall::TYPE_IMPORT_ASSET, + $operationId, ); return [ @@ -416,6 +423,15 @@ public function refreshUi() CustomizeUiUpdated::dispatch([], [], false); } + private function operationId(Request $request): string + { + $validated = $request->validate([ + 'operation_id' => ['nullable', 'string', 'max:100'], + ]); + + return $validated['operation_id'] ?? (string) Str::uuid(); + } + private function writeColors($data) { // Now generate the _colors.scss file diff --git a/ProcessMaker/ImportExport/Logger.php b/ProcessMaker/ImportExport/Logger.php index f77b7adea4..2764d65eda 100644 --- a/ProcessMaker/ImportExport/Logger.php +++ b/ProcessMaker/ImportExport/Logger.php @@ -14,16 +14,19 @@ class Logger public $userId = null; + public $operationId = null; + private $warnings = []; private int $totalSteps = 1; private int $currentStep = 1; - public function __construct($userId = null) + public function __construct($userId = null, $operationId = null) { $this->pid = getmypid(); $this->userId = $userId; + $this->operationId = $operationId; if ($userId) { Event::listen(MessageLogged::class, function (MessageLogged $e) { @@ -71,7 +74,13 @@ private function dispatch($type, $message, $additionalParams = []) return; } - ImportLog::dispatch($this->userId, $type, substr($message, 0, 1000), $additionalParams); + ImportLog::dispatch( + $this->userId, + $type, + substr($message, 0, 1000), + $additionalParams, + $this->operationId, + ); $this->logToFile($type, $message, $additionalParams); } diff --git a/ProcessMaker/Jobs/DevLinkInstall.php b/ProcessMaker/Jobs/DevLinkInstall.php index c30519e201..772aefbc31 100644 --- a/ProcessMaker/Jobs/DevLinkInstall.php +++ b/ProcessMaker/Jobs/DevLinkInstall.php @@ -33,6 +33,14 @@ class DevLinkInstall implements ShouldQueue public $maxExceptions = 1; + /** + * Correlates progress events with the DevLink operation that started them. + * + * This remains nullable so jobs queued before this property was introduced + * can still be processed after an application upgrade. + */ + public $operationId = null; + public function __construct( public int $userId, public int $devLinkId, @@ -40,7 +48,9 @@ public function __construct( public int $id, public string $importMode, public string $type, + $operationId = null, ) { + $this->operationId = $operationId; } /** @@ -51,7 +61,7 @@ public function handle(): void //log \Log::info('DevLinkInstall job started: ' . $this->devLinkId); $devLink = DevLink::findOrFail($this->devLinkId); - $logger = new Logger($this->userId); + $logger = new Logger($this->userId, $this->operationId); $lock = Cache::lock(ImportV2::CACHE_LOCK_KEY, ImportV2::RELEASE_LOCK_AFTER); @@ -82,7 +92,7 @@ public function handle(): void public function failed(Throwable $exception): void { - (new Logger($this->userId))->exception($exception); + (new Logger($this->userId, $this->operationId))->exception($exception); // Unlock the job // We can't use $this->lock->release() here because this is run in a new instance diff --git a/resources/js/admin/devlink/components/AssetListing.vue b/resources/js/admin/devlink/components/AssetListing.vue index a5716181d3..5a878dcee7 100644 --- a/resources/js/admin/devlink/components/AssetListing.vue +++ b/resources/js/admin/devlink/components/AssetListing.vue @@ -7,6 +7,7 @@ import types from './assetTypes'; import moment from 'moment'; import Header from './Header.vue'; import InstallProgress from './InstallProgress.vue'; +import createOperationId from '../createOperationId'; const route = useRoute(); const vue = getCurrentInstance().proxy; @@ -20,6 +21,7 @@ const showInstallModal = ref(false); const showConfirmModal = ref(false); const selectedAsset = ref(null); const installMode = ref('update'); +const operationId = ref(''); const page = ref(1); const perPage = ref(15); @@ -89,12 +91,14 @@ const install = (asset) => { const confirmInstall = () => { if (selectedAsset.value) { + operationId.value = createOperationId(); showConfirmModal.value = false; showInstallModal.value = true; const params = { class: typeConfig.class, id: selectedAsset.value.id, - updateType: installMode.value + updateType: installMode.value, + operation_id: operationId.value, }; ProcessMaker.apiClient .post(`/devlink/${route.params.id}/install-remote-asset`, params) @@ -210,7 +214,11 @@ const handleFilterChange = () => { - + diff --git a/resources/js/admin/devlink/components/InstallProgress.vue b/resources/js/admin/devlink/components/InstallProgress.vue index 400efe0a83..e7093e76be 100644 --- a/resources/js/admin/devlink/components/InstallProgress.vue +++ b/resources/js/admin/devlink/components/InstallProgress.vue @@ -36,6 +36,12 @@ import { ref, onMounted, onUnmounted, getCurrentInstance } from 'vue'; const vue = getCurrentInstance().proxy; +const props = defineProps({ + operationId: { + type: String, + required: true, + }, +}); const progress = ref(0); const userId = window.ProcessMaker.user.id; const done = ref(false); @@ -50,6 +56,10 @@ onMounted(() => { window.Echo.private(`ProcessMaker.Models.User.${userId}`).listen( '.ImportLog', (response) => { + if (response.operationId !== props.operationId) { + return; + } + showSpinner.value = false; if (response.type === 'progress') { progress.value = response.message; diff --git a/resources/js/admin/devlink/components/Instance.vue b/resources/js/admin/devlink/components/Instance.vue index be6c70400c..17abdcfd00 100644 --- a/resources/js/admin/devlink/components/Instance.vue +++ b/resources/js/admin/devlink/components/Instance.vue @@ -4,6 +4,7 @@ import debounce from 'lodash/debounce'; import { useRouter, useRoute } from 'vue-router/composables'; import InstanceTabs from './InstanceTabs.vue'; import InstallProgress from './InstallProgress.vue'; +import createOperationId from '../createOperationId'; const vue = getCurrentInstance().proxy; const router = useRouter(); @@ -16,6 +17,7 @@ const warnings = ref([]); const showInstallModal = ref(false); const confirmUpdateVersion = ref(null); const selectedOption = ref('update'); +const operationId = ref(''); const bundleAttributes = { id: null, name: '', @@ -88,9 +90,12 @@ const install = (bundle) => { cancelTitle: vue.$t('Cancel') }).then((confirm) => { if (confirm) { + operationId.value = createOperationId(); showInstallModal.value = true; ProcessMaker.apiClient - .post(`/devlink/${route.params.id}/remote-bundles/${bundle.id}/install`) + .post(`/devlink/${route.params.id}/remote-bundles/${bundle.id}/install`, { + operation_id: operationId.value, + }) .then((response) => { // Handle the response as needed }); @@ -98,10 +103,12 @@ const install = (bundle) => { }); }; const executeUpdate = (updateType) => { + operationId.value = createOperationId(); showInstallModal.value = true; ProcessMaker.apiClient .post(`/devlink/${route.params.id}/remote-bundles/${selected.value.id}/install`, { updateType, + operation_id: operationId.value, }) .then((response) => { // Handle the response as needed @@ -163,6 +170,9 @@ const executeUpdate = (updateType) => { diff --git a/resources/js/admin/devlink/components/UpdateBundle.vue b/resources/js/admin/devlink/components/UpdateBundle.vue index f601099fdf..d3ce7914b6 100644 --- a/resources/js/admin/devlink/components/UpdateBundle.vue +++ b/resources/js/admin/devlink/components/UpdateBundle.vue @@ -1,6 +1,7 @@