From 38a74391fdfa2ba5a8ea921b1dc9d5043d23790a Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 4 May 2026 12:32:28 +0300 Subject: [PATCH 001/168] Changed the "Delete" button to confirm deletion of selected servers (_options_servers.php) --- web/skins/classic/views/_options_servers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/_options_servers.php b/web/skins/classic/views/_options_servers.php index 601599f10..0a4133397 100644 --- a/web/skins/classic/views/_options_servers.php +++ b/web/skins/classic/views/_options_servers.php @@ -7,7 +7,7 @@
- +
From a91a297bd0cdad4481c506479f62e7a41cfe6d3e Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 4 May 2026 12:37:02 +0300 Subject: [PATCH 002/168] - Added the DeleteServer() function - Pass the "formName" argument when calling getDelConfirmModal() - Added a translation --- web/lang/en_gb.php | 2 ++ web/lang/ru_ru.php | 2 ++ web/skins/classic/views/js/options.js | 10 +++++++--- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/web/lang/en_gb.php b/web/lang/en_gb.php index 3f9db81de..40d71085c 100644 --- a/web/lang/en_gb.php +++ b/web/lang/en_gb.php @@ -239,6 +239,8 @@ $SLANG = array( 'ConfirmDeleteTitle' => 'Delete Confirmation', 'ConfirmDeleteUserTitle'=> 'Confirm User Deletion', 'ConfirmDeleteUser' => 'Are you sure you wish to delete the selected users?', + 'ConfirmDeleteServerTitle'=> 'Confirm Server Deletion', + 'ConfirmDeleteServer' => 'Are you sure you wish to delete the selected servers?', 'ConfirmPassword' => 'Confirm Password', 'ConfirmUnarchiveEvents'=> 'Are you sure you wish to unarchive the selected events?', 'ConjAnd' => 'and', diff --git a/web/lang/ru_ru.php b/web/lang/ru_ru.php index 34cf7f7e5..f7d378a64 100644 --- a/web/lang/ru_ru.php +++ b/web/lang/ru_ru.php @@ -1007,6 +1007,8 @@ $SLANG = array( 'ConfirmDeleteTitle' => 'Подтвердите удаление', 'ConfirmDeleteUserTitle'=> 'Подтвердите удаление пользователя', 'ConfirmDeleteUser' => 'Вы уверены, что хотите удалить выбранных пользователей?', + 'ConfirmDeleteServerTitle'=> 'Подтвердите удаление сервера', + 'ConfirmDeleteServer' => 'Вы уверены, что хотите удалить выбранные серверы?', 'Continuous' => 'Непрерывный', 'ONVIF_Alarm_Text' => 'Текст сигнала тревоги ONVIF', //added 18/07/2022 'None' => 'Нет', diff --git a/web/skins/classic/views/js/options.js b/web/skins/classic/views/js/options.js index dd71bae3f..4b6325dbf 100644 --- a/web/skins/classic/views/js/options.js +++ b/web/skins/classic/views/js/options.js @@ -68,7 +68,7 @@ function sortMenuItems(button) { } // Load the Delete Confirmation Modal HTML via Ajax call -function getDelConfirmModal(key, title) { +function getDelConfirmModal(key, title, formName) { $j.getJSON(thisUrl, { request: 'modal', modal: 'delconfirm', @@ -80,14 +80,18 @@ function getDelConfirmModal(key, title) { $j('#deleteConfirm').modal('show'); document.getElementById("delConfirmBtn").addEventListener("click", function onDelConfirmClick(evt) { $j('#deleteConfirm').modal('hide'); - submitThisForm(document.querySelector('form[name="userForm"]')); + submitThisForm(document.querySelector('form[name="'+formName+'"]')); }); }) .fail(logAjaxFail); } function DeleteUser() { - getDelConfirmModal('ConfirmDeleteUser', 'ConfirmDeleteUserTitle'); + getDelConfirmModal('ConfirmDeleteUser', 'ConfirmDeleteUserTitle', 'userForm'); +} + +function DeleteServer() { + getDelConfirmModal('ConfirmDeleteServer', 'ConfirmDeleteServerTitle', 'serversForm'); } function initPage() { From 8009884171e1c224284da8522ad95f07f235f71b Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 4 May 2026 19:09:10 +0300 Subject: [PATCH 003/168] - Moved the getDelConfirmModal() function from options.js to skin.js - Removed the getDelConfirmModal() function (and its call) from report.js, options.js, reports.js, devices.js, +++controlcaps.js(?view=options&tab=control), and snapshots.js. This is because it's now a single function in skin.js, which is called when the "Delete" button is pressed, rather than during page initialization. - Slightly modified the local functions of manageDelConfirmModalBtns(). Removed the listener, so the code is executed immediately. - You can now call local functions from the global getDelConfirmModal() function (instead of just executing submitThisForm() ). Applies to report.js, reports.js, devices.js, controlcaps.js, snapshots.js - Changed the style of the "Delete" button on the Groups page - Added deletion confirmation to the Storage, Roles, and Groups pages - On the Event page, deletion permissions were broken. Deletes were allowed for those who did NOT have edit permissions, but deletions were blocked for those who did. ! - On the Report page, deletion didn't work and still doesn't work. This needs to be addressed, or maybe it's possible to delete reports only on the Reports page. (FIXED...) - On the Devices page, the "canEdit.Device" permissions check was being performed, but it should be performed like this: "canEdit.Devices" - The $action variable was not defined in \ajax\devices.php - Added translations. - Added a style for the disabled "Delete" button - Minor fixes --- web/ajax/devices.php | 3 +- web/ajax/reports.php | 3 +- web/includes/Report.php | 16 +++--- web/lang/en_gb.php | 8 ++- web/lang/ru_ru.php | 6 +++ web/skins/classic/css/base/skin.css | 5 ++ web/skins/classic/js/skin.js | 33 ++++++++++++ web/skins/classic/views/_options_roles.php | 2 +- web/skins/classic/views/_options_storage.php | 2 +- web/skins/classic/views/groups.php | 2 +- web/skins/classic/views/js/controlcaps.js | 41 +++++---------- web/skins/classic/views/js/devices.js | 54 ++++++-------------- web/skins/classic/views/js/groups.js | 11 ++-- web/skins/classic/views/js/options.js | 27 +++------- web/skins/classic/views/js/report.js | 49 ++++-------------- web/skins/classic/views/js/reports.js | 46 +++++------------ web/skins/classic/views/js/snapshots.js | 52 +++++-------------- web/skins/classic/views/report.php | 15 ++++-- 18 files changed, 152 insertions(+), 223 deletions(-) diff --git a/web/ajax/devices.php b/web/ajax/devices.php index 66b80124d..53ea02614 100644 --- a/web/ajax/devices.php +++ b/web/ajax/devices.php @@ -24,6 +24,7 @@ if ( !canEdit('Devices') ) { return; } +$action = validStr($_REQUEST['action']); if ( $action == 'delete' ) { if ( isset($_REQUEST['markDids']) ) { foreach( $_REQUEST['markDids'] as $markDid ) { @@ -32,7 +33,7 @@ if ( $action == 'delete' ) { } ajaxResponse(); } else { - ajaxError('Unrecognised action '.$_REQUEST['action']); + ajaxError('Unrecognised action "'.$action.'"'); } ?> diff --git a/web/ajax/reports.php b/web/ajax/reports.php index a4596c864..4c610a131 100644 --- a/web/ajax/reports.php +++ b/web/ajax/reports.php @@ -109,7 +109,8 @@ function deleteRequest($id) { $report = new ZM\Report($id); if ( !$report->Id() ) { $message[] = array($id=>'Report not found.'); - } else if (!$report->canEdit()) { + //} else if (!$report->canEdit()) { + } else if (!canEdit('Events')) { // IgorA100 Since we don't have permissions configured for Reports yet, we'll analyze permissions for Events. $message[] = array($id=>'You do not have permission to delete report '.$report->Id()); } else { $report->delete(); diff --git a/web/includes/Report.php b/web/includes/Report.php index 1ba6a4d1e..37de36e6f 100644 --- a/web/includes/Report.php +++ b/web/includes/Report.php @@ -6,14 +6,14 @@ require_once('Object.php'); class Report extends ZM_Object { protected static $table = 'Reports'; - protected $defaults = array( - 'Id' => null, - 'Name' => '', - 'FilterId' => null, - 'StartDateTime' => null, - 'EndDateTime' => null, - 'Interval' => '86400', - ); + protected $defaults = array( + 'Id' => null, + 'Name' => '', + 'FilterId' => null, + 'StartDateTime' => null, + 'EndDateTime' => null, + 'Interval' => '86400', + ); public static function find( $parameters = array(), $options = array() ) { return ZM_Object::_find(self::class, $parameters, $options); diff --git a/web/lang/en_gb.php b/web/lang/en_gb.php index 40d71085c..b95ec6ac3 100644 --- a/web/lang/en_gb.php +++ b/web/lang/en_gb.php @@ -240,7 +240,13 @@ $SLANG = array( 'ConfirmDeleteUserTitle'=> 'Confirm User Deletion', 'ConfirmDeleteUser' => 'Are you sure you wish to delete the selected users?', 'ConfirmDeleteServerTitle'=> 'Confirm Server Deletion', - 'ConfirmDeleteServer' => 'Are you sure you wish to delete the selected servers?', + 'ConfirmDeleteServer' => 'Are you sure you wish to delete the selected servers?', + 'ConfirmDeleteReport' => 'Are you sure you wish to delete the selected reports?', + 'ConfirmDeleteStorage' => 'Are you sure you wish to delete the selected storages?', + 'ConfirmDeleteStorageTitle'=> 'Confirm Storage Deletion', + 'ConfirmDeleteRole' => 'Are you sure you wish to delete the selected roles?', + 'ConfirmDeleteRoleTitle'=> 'Confirm Role Deletion', + 'ConfirmDeleteSnapshots'=> 'Are you sure you wish to delete the selected snapshots?', 'ConfirmPassword' => 'Confirm Password', 'ConfirmUnarchiveEvents'=> 'Are you sure you wish to unarchive the selected events?', 'ConjAnd' => 'and', diff --git a/web/lang/ru_ru.php b/web/lang/ru_ru.php index f7d378a64..5db574ed3 100644 --- a/web/lang/ru_ru.php +++ b/web/lang/ru_ru.php @@ -1009,6 +1009,12 @@ $SLANG = array( 'ConfirmDeleteUser' => 'Вы уверены, что хотите удалить выбранных пользователей?', 'ConfirmDeleteServerTitle'=> 'Подтвердите удаление сервера', 'ConfirmDeleteServer' => 'Вы уверены, что хотите удалить выбранные серверы?', + 'ConfirmDeleteReport' => 'Вы уверены, что хотите удалить выбранные отчеты', + 'ConfirmDeleteStorageTitle'=> 'Подтвердите удаление хранилища', + 'ConfirmDeleteStorage' => 'Вы уверены, что хотите удалить выбранные хранилища?', + 'ConfirmDeleteRoleTitle'=> 'Подтвердите удаление ролей', + 'ConfirmDeleteRole' => 'Вы уверены, что хотите удалить выбранные роли?', + 'ConfirmDeleteSnapshots'=> 'Вы уверены, что хотите удалить выбранные снапшоты?', 'Continuous' => 'Непрерывный', 'ONVIF_Alarm_Text' => 'Текст сигнала тревоги ONVIF', //added 18/07/2022 'None' => 'Нет', diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css index dbe0c5ed8..72feed568 100644 --- a/web/skins/classic/css/base/skin.css +++ b/web/skins/classic/css/base/skin.css @@ -1793,6 +1793,11 @@ video-stream[id^='liveStream'] video{ } /* --- */ +.btn-danger.disabled, .btn-danger:disabled { + color: #888888; + background-color: #b1414c; +} + /* +++ This block should always be located at the end! */ .hidden { display: none; diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index b979bf263..59b572ec6 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -663,6 +663,39 @@ function confirmDelete( message ) { return ( confirm( message?message:'Are you sure you wish to delete?' ) ); } +// Load the Delete Confirmation Modal HTML via Ajax call +function getDelConfirmModal(key, title, formName=null) { + $j.getJSON(thisUrl, { + request: 'modal', + modal: 'delconfirm', + key: key, + title: title + }) + .done(function(data) { + insertModalHtml('deleteConfirm', data.html); + $j('#deleteConfirm').modal('show'); + document.getElementById("delConfirmBtn").addEventListener("click", function onDelConfirmClick(evt) { + $j('#deleteConfirm').modal('hide'); + if (!formName) { + if (typeof manageDelConfirmModalBtns === "function") { + manageDelConfirmModalBtns(); + } else { + console.warn(`Функция manageDelConfirmModalBtns не найдена.`); + } + } else { + const form = document.querySelector('form[name="'+formName+'"]'); + if (form) { + if (currentView == 'groups') form.elements['action'].value = 'delete'; + submitThisForm(form); + } else { + console.warn(`Форма с именем=${formName} не найдена.`); + } + } + }); + }) + .fail(logAjaxFail); +} + window.addEventListener( 'DOMContentLoaded', checkSize ); function convertLabelFormat(LabelFormat, monitorName) { diff --git a/web/skins/classic/views/_options_roles.php b/web/skins/classic/views/_options_roles.php index 3f391f861..604669b9c 100644 --- a/web/skins/classic/views/_options_roles.php +++ b/web/skins/classic/views/_options_roles.php @@ -10,7 +10,7 @@ require_once('includes/User_Role.php');
- +
diff --git a/web/skins/classic/views/_options_storage.php b/web/skins/classic/views/_options_storage.php index 37233b729..1ee8e361a 100644 --- a/web/skins/classic/views/_options_storage.php +++ b/web/skins/classic/views/_options_storage.php @@ -7,7 +7,7 @@
- +
diff --git a/web/skins/classic/views/groups.php b/web/skins/classic/views/groups.php index 019814bd8..e4ce61a86 100644 --- a/web/skins/classic/views/groups.php +++ b/web/skins/classic/views/groups.php @@ -61,7 +61,7 @@ getBodyTopHTML(); add_circle - diff --git a/web/skins/classic/views/js/controlcaps.js b/web/skins/classic/views/js/controlcaps.js index c9cfb945d..6bf3fdbf3 100644 --- a/web/skins/classic/views/js/controlcaps.js +++ b/web/skins/classic/views/js/controlcaps.js @@ -24,34 +24,21 @@ function getIdSelections() { }); } -// Load the Delete Confirmation Modal HTML via Ajax call -function getDelConfirmModal(key) { - $j.getJSON(thisUrl + '?request=modal&modal=delconfirm&key=' + key) - .done(function(data) { - insertModalHtml('deleteConfirm', data.html); - manageDelConfirmModalBtns(); - }) - .fail(logAjaxFail); -} - // Manage the DELETE CONFIRMATION modal button function manageDelConfirmModalBtns() { - document.getElementById("delConfirmBtn").addEventListener("click", function onDelConfirmClick(evt) { - if ( ! canEdit.Control ) { - enoperm(); - return; - } + if ( ! canEdit.Control ) { + enoperm(); + return; + } - var selections = getIdSelections(); + var selections = getIdSelections(); - evt.preventDefault(); - $j.getJSON(thisUrl + '?request=controlcaps&action=delete&cids[]='+selections.join('&cids[]=')) - .done( function(data) { - $j('#eventTable').bootstrapTable('refresh'); - window.location.reload(true); - }) - .fail(logAjaxFail); - }); + $j.getJSON(thisUrl + '?request=controlcaps&action=delete&cids[]='+selections.join('&cids[]=')) + .done( function(data) { + $j('#eventTable').bootstrapTable('refresh'); + window.location.reload(true); + }) + .fail(logAjaxFail); } function initPageControlCaps() { @@ -91,13 +78,9 @@ function initPageControlCaps() { return; } - evt.preventDefault(); - $j('#deleteConfirm').modal('show'); + getDelConfirmModal('ConfirmDeleteControl'); }); - // Load the delete confirmation modal into the DOM - getDelConfirmModal('ConfirmDeleteControl'); - // Hide these columns on first run when no cookie is saved if ( !getCookie("zmControlTable.bs.table.columns") ) { tableControlCaps.bootstrapTable('hideColumn', 'Id'); diff --git a/web/skins/classic/views/js/devices.js b/web/skins/classic/views/js/devices.js index 0190f8672..8ad2e98b4 100644 --- a/web/skins/classic/views/js/devices.js +++ b/web/skins/classic/views/js/devices.js @@ -29,39 +29,21 @@ function enableDeviceModal() { }); } -// Load the Delete Confirmation Modal HTML via Ajax call -function getDelConfirmModal(key) { - $j.getJSON(thisUrl + '?request=modal&modal=delconfirm&key=' + key) - .done(function(data) { - insertModalHtml('deleteConfirm', data.html); - manageDelConfirmModalBtns(); - }) - .fail(logAjaxFail); -} - // Manage the DELETE CONFIRMATION modal button function manageDelConfirmModalBtns() { - document.getElementById("delConfirmBtn").addEventListener("click", function onDelConfirmClick(evt) { - if ( ! canEdit.Device ) { - enoperm(); - return; - } + if ( ! canEdit.Devices ) { + enoperm(); + return; + } - var selections = getIdSelections(); + var selections = getIdSelections(); - evt.preventDefault(); - $j.getJSON(thisUrl + '?request=devices&action=delete&markDids[]='+selections.join('&markDids[]=')) - .done( function(data) { - $j('#devicesTable').bootstrapTable('refresh'); - window.location.reload(true); - }) - .fail(logAjaxFail); - }); - - // Manage the CANCEL modal button - document.getElementById("delCancelBtn").addEventListener("click", function onDelCancelClick(evt) { - $j('#deleteConfirm').modal('hide'); - }); + $j.getJSON(thisUrl + '?request=devices&action=delete&markDids[]='+selections.join('&markDids[]=')) + .done( function(data) { + $j('#devicesTable').bootstrapTable('refresh'); + window.location.reload(true); + }) + .fail(logAjaxFail); } // Returns the event id's of the selected rows @@ -91,9 +73,9 @@ function initPage() { // Init the bootstrap-table table.bootstrapTable({icons: icons}); - if ( canEdit.Device ) enableDeviceModal(); + if ( canEdit.Devices ) enableDeviceModal(); - newDeviceBtn.prop('disabled', !canEdit.Device); + newDeviceBtn.prop('disabled', !canEdit.Devices); // Manage the BACK button document.getElementById("backBtn").addEventListener("click", function onBackClick(evt) { @@ -112,25 +94,21 @@ function initPage() { // Manage the DELETE button document.getElementById("deleteBtn").addEventListener("click", function onDeleteClick(evt) { - if ( ! canEdit.Device ) { + if ( ! canEdit.Devices ) { enoperm(); return; } - evt.preventDefault(); - $j('#deleteConfirm').modal('show'); + getDelConfirmModal('ConfirmDeleteDevices'); }); - // Load the delete confirmation modal into the DOM - getDelConfirmModal('ConfirmDeleteDevices'); - // enable or disable buttons based on current selection and user rights table.on('check.bs.table uncheck.bs.table ' + 'check-all.bs.table uncheck-all.bs.table', function() { selections = table.bootstrapTable('getSelections'); - deleteBtn.prop('disabled', !(selections.length && canEdit.Device)); + deleteBtn.prop('disabled', !(selections.length && canEdit.Devices)); }); // Process mouse clicks on the table cells diff --git a/web/skins/classic/views/js/groups.js b/web/skins/classic/views/js/groups.js index e7846d57e..66c395f0b 100644 --- a/web/skins/classic/views/js/groups.js +++ b/web/skins/classic/views/js/groups.js @@ -33,17 +33,14 @@ function editGroup( element ) { } function deleteGroup(element) { - const form = element.form; - form.elements['action'].value = 'delete'; - form.submit(); + getDelConfirmModal('ConfirmDeleteGroup', 'ConfirmDeleteGroupTitle', 'groupsForm'); } function configureButtons(element) { if (canEdit.Groups) { - const form = element.form; - if (element.checked) { - form.deleteBtn.disabled = (element.value == 0); - } + configureDeleteButton(element); + } else { + form.deleteBtn.disabled = 'disabled'; } } diff --git a/web/skins/classic/views/js/options.js b/web/skins/classic/views/js/options.js index 4b6325dbf..ee51786d1 100644 --- a/web/skins/classic/views/js/options.js +++ b/web/skins/classic/views/js/options.js @@ -67,25 +67,6 @@ function sortMenuItems(button) { button.classList.toggle('btn-success'); } -// Load the Delete Confirmation Modal HTML via Ajax call -function getDelConfirmModal(key, title, formName) { - $j.getJSON(thisUrl, { - request: 'modal', - modal: 'delconfirm', - key: key, - title: title - }) - .done(function(data) { - insertModalHtml('deleteConfirm', data.html); - $j('#deleteConfirm').modal('show'); - document.getElementById("delConfirmBtn").addEventListener("click", function onDelConfirmClick(evt) { - $j('#deleteConfirm').modal('hide'); - submitThisForm(document.querySelector('form[name="'+formName+'"]')); - }); - }) - .fail(logAjaxFail); -} - function DeleteUser() { getDelConfirmModal('ConfirmDeleteUser', 'ConfirmDeleteUserTitle', 'userForm'); } @@ -94,6 +75,14 @@ function DeleteServer() { getDelConfirmModal('ConfirmDeleteServer', 'ConfirmDeleteServerTitle', 'serversForm'); } +function DeleteStorage() { + getDelConfirmModal('ConfirmDeleteStorage', 'ConfirmDeleteStorageTitle', 'storageForm'); +} + +function DeleteRole() { + getDelConfirmModal('ConfirmDeleteRole', 'ConfirmDeleteRoleTitle', 'roleForm'); +} + function initPage() { const NewStorageBtn = $j('#NewStorageBtn'); const NewServerBtn = $j('#NewServerBtn'); diff --git a/web/skins/classic/views/js/report.js b/web/skins/classic/views/js/report.js index d6a81bcb4..b6fedc8f3 100644 --- a/web/skins/classic/views/js/report.js +++ b/web/skins/classic/views/js/report.js @@ -1,37 +1,14 @@ var backBtn = $j('#backBtn'); var deleteBtn = $j('#deleteBtn'); -// Load the Delete Confirmation Modal HTML via Ajax call -function getDelConfirmModal() { - $j.getJSON(thisUrl + '?request=modal&modal=delconfirm') - .done(function(data) { - insertModalHtml('deleteConfirm', data.html); - manageDelConfirmModalBtns(); - }) - .fail(logAjaxFail); -} - // Manage the DELETE CONFIRMATION modal button function manageDelConfirmModalBtns() { - document.getElementById("delConfirmBtn").addEventListener('click', function onDelConfirmClick(evt) { - if ( ! canEdit.Events ) { - enoperm(); - return; - } - evt.preventDefault(); + if ( ! canEdit.Events ) { + enoperm(); + return; + } - const selections = getIdSelections(); - if (!selections.length) { - alert('Please select reports to delete.'); - } else { - deleteReports(selections); - } - }); - - // Manage the CANCEL modal button - document.getElementById("delCancelBtn").addEventListener('click', function onDelCancelClick(evt) { - $j('#deleteConfirm').modal('hide'); - }); + deleteReports([document.getElementById("reportForm").getAttribute("data-report_id")]); } function deleteReports(ids) { @@ -41,30 +18,23 @@ function deleteReports(ids) { $j.getJSON(thisUrl + '?request=reports&task=delete&ids[]='+chunk.join('&ids[]=')) .done( function(data) { - if (!ids.length) { - $j('#reportsTable').bootstrapTable('refresh'); - $j('#deleteConfirm').modal('hide'); - } else { + if (ids.length) { if (ticker.innerHTML.length < 1 || ticker.innerHTML.length > 10) { ticker.innerHTML = '.'; } else { ticker.innerHTML = ticker.innerHTML + '.'; } - deleteReports(ids); } + window.location.assign("?view=reports"); }) .fail( function(jqxhr) { logAjaxFail(jqxhr); $j('#reportsTable').bootstrapTable('refresh'); - $j('#deleteConfirm').modal('hide'); }); } function initPage() { - // Load the delete confirmation modal into the DOM - getDelConfirmModal(); - - deleteBtn.prop('disabled', canEdit.Events); + deleteBtn.prop('disabled', !canEdit.Events); // Don't enable the back button if there is no previous zm page to go back to backBtn.prop('disabled', !document.referrer.length); @@ -82,8 +52,7 @@ function initPage() { return; } - evt.preventDefault(); - $j('#deleteConfirm').modal('show'); + getDelConfirmModal('ConfirmDeleteReport'); }); } diff --git a/web/skins/classic/views/js/reports.js b/web/skins/classic/views/js/reports.js index 900f059a7..c37ca28ac 100644 --- a/web/skins/classic/views/js/reports.js +++ b/web/skins/classic/views/js/reports.js @@ -71,37 +71,20 @@ function getIdSelections() { }); } -// Load the Delete Confirmation Modal HTML via Ajax call -function getDelConfirmModal() { - $j.getJSON(thisUrl + '?request=modal&modal=delconfirm') - .done(function(data) { - insertModalHtml('deleteConfirm', data.html); - manageDelConfirmModalBtns(); - }) - .fail(logAjaxFail); -} // Manage the DELETE CONFIRMATION modal button function manageDelConfirmModalBtns() { - document.getElementById("delConfirmBtn").addEventListener('click', function onDelConfirmClick(evt) { - if ( ! canEdit.Events ) { - enoperm(); - return; - } - evt.preventDefault(); + if ( ! canEdit.Events ) { + enoperm(); + return; + } - const selections = getIdSelections(); - if (!selections.length) { - alert('Please select reports to delete.'); - } else { - deleteReports(selections); - } - }); - - // Manage the CANCEL modal button - document.getElementById("delCancelBtn").addEventListener('click', function onDelCancelClick(evt) { - $j('#deleteConfirm').modal('hide'); - }); + const selections = getIdSelections(); + if (!selections.length) { + alert('Please select reports to delete.'); + } else { + deleteReports(selections); + } } function deleteReports(ids) { @@ -113,7 +96,6 @@ function deleteReports(ids) { .done( function(data) { if (!ids.length) { $j('#reportsTable').bootstrapTable('refresh'); - $j('#deleteConfirm').modal('hide'); } else { if (ticker.innerHTML.length < 1 || ticker.innerHTML.length > 10) { ticker.innerHTML = '.'; @@ -126,14 +108,10 @@ function deleteReports(ids) { .fail( function(jqxhr) { logAjaxFail(jqxhr); $j('#reportsTable').bootstrapTable('refresh'); - $j('#deleteConfirm').modal('hide'); }); } function initPage() { - // Load the delete confirmation modal into the DOM - getDelConfirmModal(); - // Init the bootstrap-table table.bootstrapTable({icons: icons}); @@ -174,8 +152,8 @@ function initPage() { return; } - evt.preventDefault(); - $j('#deleteConfirm').modal('show'); + getDelConfirmModal('ConfirmDeleteReport'); + }); table.bootstrapTable('resetSearch'); diff --git a/web/skins/classic/views/js/snapshots.js b/web/skins/classic/views/js/snapshots.js index bdba87db6..40cb63285 100644 --- a/web/skins/classic/views/js/snapshots.js +++ b/web/skins/classic/views/js/snapshots.js @@ -87,43 +87,23 @@ function getArchivedSelections() { return selection.includes("Yes"); } -// Load the Delete Confirmation Modal HTML via Ajax call -function getDelConfirmModal() { - $j.getJSON(thisUrl + '?request=modal&modal=delconfirm') - .done(function(data) { - insertModalHtml('deleteConfirm', data.html); - manageDelConfirmModalBtns(); - }) - .fail(logAjaxFail); -} - // Manage the DELETE CONFIRMATION modal button function manageDelConfirmModalBtns() { - document.getElementById("delConfirmBtn").addEventListener("click", function onDelConfirmClick(evt) { - if (!canEdit.Events) { - enoperm(); - return; - } + if (!canEdit.Events) { + enoperm(); + return; + } - var selections = getIdSelections(); + var selections = getIdSelections(); - evt.preventDefault(); - $j.getJSON(thisUrl + '?request=snapshots&task=delete&ids[]='+selections.join('&ids[]=')) - .done( function(data) { - $j('#snapshotTable').bootstrapTable('refresh'); - $j('#deleteConfirm').modal('hide'); - }) - .fail( function(jqxhr) { - logAjaxFail(jqxhr); - $j('#snapshotTable').bootstrapTable('refresh'); - $j('#deleteConfirm').modal('hide'); - }); - }); - - // Manage the CANCEL modal button - document.getElementById("delCancelBtn").addEventListener("click", function onDelCancelClick(evt) { - $j('#deleteConfirm').modal('hide'); - }); + $j.getJSON(thisUrl + '?request=snapshots&task=delete&ids[]='+selections.join('&ids[]=')) + .done( function(data) { + $j('#snapshotTable').bootstrapTable('refresh'); + }) + .fail( function(jqxhr) { + logAjaxFail(jqxhr); + $j('#snapshotTable').bootstrapTable('refresh'); + }); } function getEventDetailModal(eid) { @@ -153,9 +133,6 @@ function initPage() { // Remove the thumbnail column from the DOM if thumbnails are off globally if ( !WEB_LIST_THUMBS ) $j('th[data-field="Thumbnail"]').remove(); - // Load the delete confirmation modal into the DOM - getDelConfirmModal(); - // Init the bootstrap-table table.bootstrapTable({icons: icons}); @@ -275,8 +252,7 @@ function initPage() { return; } - evt.preventDefault(); - $j('#deleteConfirm').modal('show'); + getDelConfirmModal('ConfirmDeleteSnapshots'); }); // Update table links each time after new data is loaded diff --git a/web/skins/classic/views/report.php b/web/skins/classic/views/report.php index ff871fc1c..aefd1144d 100644 --- a/web/skins/classic/views/report.php +++ b/web/skins/classic/views/report.php @@ -41,14 +41,14 @@ getBodyTopHTML();
-
+
- +
@@ -94,7 +94,14 @@ var events = Array(); FilterId()) return; +if (!$report->FilterId()) { + echo ' + + + '.PHP_EOL; + xhtmlFooter(); + return; +} $filter = new ZM\Filter($report->FilterId()); if (count($user->unviewableMonitorIds())) { @@ -180,6 +187,6 @@ new Chart(document.getElementById("bar-chart"), { } }); */ - + From fa27e46862c558f1f2daebc70dcdb4e3ee0ef23b Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 4 May 2026 19:19:31 +0300 Subject: [PATCH 004/168] Fix: ESLint --- web/skins/classic/views/js/reports.js | 1 - 1 file changed, 1 deletion(-) diff --git a/web/skins/classic/views/js/reports.js b/web/skins/classic/views/js/reports.js index c37ca28ac..f1f54d214 100644 --- a/web/skins/classic/views/js/reports.js +++ b/web/skins/classic/views/js/reports.js @@ -153,7 +153,6 @@ function initPage() { } getDelConfirmModal('ConfirmDeleteReport'); - }); table.bootstrapTable('resetSearch'); From 57cfcc3c4afad8f76cdbd9f726daed246c1d6aff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 01:46:22 +0000 Subject: [PATCH 005/168] Initial plan From 1be352c79074a4ab8ce431a9215e07e0613b5002 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 01:58:48 +0000 Subject: [PATCH 006/168] fix: add stopped state for CMD_STOP, fixing paused=true response bug When CMD_STOP is sent via AJAX using ZMS MJPEG streaming, the response was incorrectly returning paused=true instead of indicating a stopped state (paused=false). Changes: - Add `stopped` boolean to StreamBase (zm_stream.h) - MonitorStream: CMD_STOP now sets stopped=true, paused=false instead of paused=true; run loop skips frame sending when stopped - EventStream: CMD_STOP sets stopped=true (was already setting paused=false); run loop skips frame sending when stopped - All other play/pause commands reset stopped=false - Both streams include stopped field in the status response struct - stream.php unpacks the new stopped field from MSG_DATA_WATCH and MSG_DATA_EVENT responses - MonitorStream.js handles stopped status in UI (shows 'Stopped' mode) - EventStream.js tracks stopped state from server response Fixes issue: CMD_STOP response paused=true should be paused=false Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/ba9cb47a-a3e8-4e13-aec7-c9cd258e2a3d Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_eventstream.cpp | 18 ++++++++++++++++-- src/zm_monitorstream.cpp | 21 +++++++++++++++++++-- src/zm_stream.h | 2 ++ web/ajax/stream.php | 6 +++--- web/js/EventStream.js | 6 +++++- web/js/MonitorStream.js | 7 ++++++- 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/zm_eventstream.cpp b/src/zm_eventstream.cpp index 75e24cb96..93d4e9e06 100644 --- a/src/zm_eventstream.cpp +++ b/src/zm_eventstream.cpp @@ -449,10 +449,12 @@ void EventStream::processCommand(const CmdMsg *msg) { switch ((MsgCommand)msg->msg_data[0]) { case CMD_PAUSE : Debug(1, "Got PAUSE command"); + stopped = false; paused = true; break; case CMD_PLAY : { Debug(1, "Got PLAY command"); + stopped = false; paused = false; // If we are in single event mode and at the last frame, replay the current event @@ -476,6 +478,7 @@ void EventStream::processCommand(const CmdMsg *msg) { } case CMD_VARPLAY : { Debug(1, "Got VARPLAY command"); + stopped = false; paused = false; replay_rate = ntohs(((unsigned char)msg->msg_data[2]<<8)|(unsigned char)msg->msg_data[1])-32768; if (replay_rate > 50 * ZM_RATE_BASE) { @@ -489,10 +492,12 @@ void EventStream::processCommand(const CmdMsg *msg) { } case CMD_STOP : Debug(1, "Got STOP command"); + stopped = true; paused = false; break; case CMD_FASTFWD : { Debug(1, "Got FAST FWD command"); + stopped = false; paused = false; // Set play rate switch (replay_rate) { @@ -517,6 +522,7 @@ void EventStream::processCommand(const CmdMsg *msg) { break; } case CMD_SLOWFWD : { + stopped = false; paused = true; replay_rate = ZM_RATE_BASE; step = 1; @@ -526,6 +532,7 @@ void EventStream::processCommand(const CmdMsg *msg) { break; } case CMD_SLOWREV : { + stopped = false; paused = true; replay_rate = ZM_RATE_BASE; step = -1; @@ -535,6 +542,7 @@ void EventStream::processCommand(const CmdMsg *msg) { } case CMD_FASTREV : Debug(1, "Got FAST REV command"); + stopped = false; paused = false; // Set play rate switch (replay_rate) { @@ -693,6 +701,7 @@ void EventStream::processCommand(const CmdMsg *msg) { int zoom; int scale; bool paused; + bool stopped; } status_data = {}; { @@ -707,6 +716,7 @@ void EventStream::processCommand(const CmdMsg *msg) { status_data.zoom = zoom; status_data.scale = scale; status_data.paused = paused; + status_data.stopped = stopped; FPSeconds elapsed = now - last_fps_update; if (elapsed.count() > 0) { @@ -719,10 +729,11 @@ void EventStream::processCommand(const CmdMsg *msg) { status_data.fps = actual_fps; - Debug(2, "Event:%" PRIu64 ", Duration %f, Paused:%d, progress:%f Rate:%d, Zoom:%d Scale:%d", + Debug(2, "Event:%" PRIu64 ", Duration %f, Paused:%d, Stopped:%d, progress:%f Rate:%d, Zoom:%d Scale:%d", status_data.event_id, FPSeconds(status_data.duration).count(), status_data.paused, + status_data.stopped, FPSeconds(status_data.progress).count(), status_data.rate, status_data.zoom, @@ -1037,7 +1048,10 @@ void EventStream::runStream() { send_frame = false; TimePoint::duration time_since_last_send = now - last_frame_sent; - if (!paused) { + if (stopped) { + // In stopped state, do nothing except wait for a new command + send_frame = false; + } else if (!paused) { // Figure out if we should send this frame Debug(3, "not paused at curr_frame_id (%d-1) mod frame_mod(%d)", curr_frame_id, frame_mod); // If we are streaming and this frame is due to be sent diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index 68532864c..6cb7f625e 100644 --- a/src/zm_monitorstream.cpp +++ b/src/zm_monitorstream.cpp @@ -92,12 +92,14 @@ void MonitorStream::processCommand(const CmdMsg *msg) { switch ((MsgCommand)msg->msg_data[0]) { case CMD_PAUSE : Debug(1, "Got PAUSE command"); + stopped = false; paused = true; delayed = true; last_frame_sent = now; break; case CMD_PLAY : Debug(1, "Got PLAY command"); + stopped = false; if (paused) { paused = false; delayed = true; @@ -106,6 +108,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { break; case CMD_VARPLAY : Debug(1, "Got VARPLAY command"); + stopped = false; if (paused) { paused = false; delayed = true; @@ -114,11 +117,13 @@ void MonitorStream::processCommand(const CmdMsg *msg) { break; case CMD_STOP : Debug(1, "Got STOP command"); - paused = true; + stopped = true; + paused = false; delayed = false; break; case CMD_FASTFWD : Debug(1, "Got FAST FWD command"); + stopped = false; if (paused) { paused = false; delayed = true; @@ -156,6 +161,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { } case CMD_SLOWFWD : Debug(1, "Got SLOW FWD command"); + stopped = false; paused = true; delayed = true; replay_rate = ZM_RATE_BASE; @@ -163,6 +169,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { break; case CMD_SLOWREV : Debug(1, "Got SLOW REV command"); + stopped = false; paused = true; delayed = true; replay_rate = ZM_RATE_BASE; @@ -170,6 +177,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { break; case CMD_FASTREV : Debug(1, "Got FAST REV command"); + stopped = false; if (paused) { paused = false; delayed = true; @@ -256,6 +264,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { int score; int analysing; bool analysis_image; + bool stopped; } status_data; status_data.id = monitor->Id(); @@ -299,6 +308,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { } // end monitor_mutex scope status_data.delayed = delayed; status_data.paused = paused; + status_data.stopped = stopped; status_data.rate = replay_rate; status_data.delay = FPSeconds(now - last_frame_sent).count(); status_data.zoom = zoom; @@ -306,13 +316,14 @@ void MonitorStream::processCommand(const CmdMsg *msg) { status_data.analysis_image = (frame_type == FRAME_ANALYSIS) && monitor->ShmValid() && (monitor->Analysing() != Monitor::ANALYSING_NONE); - Debug(2, "viewing fps: %.2f capture_fps: %.2f analysis_fps: %.2f Buffer Level:%d, Delayed:%d, Paused:%d, Rate:%d, delay:%.3f, Zoom:%d, Enabled:%d Forced:%d score: %d analysis_image: %d", + Debug(2, "viewing fps: %.2f capture_fps: %.2f analysis_fps: %.2f Buffer Level:%d, Delayed:%d, Paused:%d, Stopped:%d, Rate:%d, delay:%.3f, Zoom:%d, Enabled:%d Forced:%d score: %d analysis_image: %d", status_data.fps, status_data.capture_fps, status_data.analysis_fps, status_data.buffer_level, status_data.delayed, status_data.paused, + status_data.stopped, status_data.rate, status_data.delay, status_data.zoom, @@ -639,6 +650,12 @@ void MonitorStream::runStream() { if (frame_type == FRAME_ANALYSIS) monitor->setLastAnalysisViewed(); + if (stopped) { + // In stopped state, do nothing except wait for a new command + std::this_thread::sleep_for(MAX_SLEEP); + continue; + } + if (paused) { if (!was_paused) { int index = monitor->shared_data->last_write_index % monitor->image_buffer_count; diff --git a/src/zm_stream.h b/src/zm_stream.h index 0422d8cc1..9993a8581 100644 --- a/src/zm_stream.h +++ b/src/zm_stream.h @@ -125,6 +125,7 @@ class StreamBase { char sock_path_lock[108]; int lock_fd; bool paused; + bool stopped; int step; bool send_twice; // flag to send the same frame twice @@ -188,6 +189,7 @@ class StreamBase { sd(-1), lock_fd(0), paused(false), + stopped(false), step(0), maxfps(DEFAULT_MAXFPS), base_fps(0.0), diff --git a/web/ajax/stream.php b/web/ajax/stream.php index 6f8c77593..408174f12 100644 --- a/web/ajax/stream.php +++ b/web/ajax/stream.php @@ -151,7 +151,7 @@ default : $data = unpack('ltype', $msg); switch ( $data['type'] ) { case MSG_DATA_WATCH : - $data = unpack('ltype/imonitor/istate/dfps/dcapturefps/danalysisfps/ilevel/irate/ddelay/izoom/iscale/Cdelayed/Cpaused/Cenabled/Cforced/iscore/ianalysing/Canalysisimage', $msg); + $data = unpack('ltype/imonitor/istate/dfps/dcapturefps/danalysisfps/ilevel/irate/ddelay/izoom/iscale/Cdelayed/Cpaused/Cenabled/Cforced/iscore/ianalysing/Canalysisimage/Cstopped', $msg); $data['fps'] = round( $data['fps'], 2 ); $data['capturefps'] = round( $data['capturefps'], 2 ); $data['analysisfps'] = round( $data['analysisfps'], 2 ); @@ -176,10 +176,10 @@ case MSG_DATA_WATCH : case MSG_DATA_EVENT : if ( PHP_INT_SIZE===4 || version_compare( phpversion(), '5.6.0', '<') ) { ZM\Debug('Using old unpack methods to handle 64bit event id'); - $data = unpack('ltype/ieventlow/ieventhigh/dduration/dprogress/dfps/irate/izoom/iscale/Cpaused', $msg); + $data = unpack('ltype/ieventlow/ieventhigh/dduration/dprogress/dfps/irate/izoom/iscale/Cpaused/Cstopped', $msg); $data['event'] = $data['eventhigh'] << 32 | $data['eventlow']; } else { - $data = unpack('ltype/Qevent/dduration/dprogress/dfps/irate/izoom/iscale/Cpaused', $msg); + $data = unpack('ltype/Qevent/dduration/dprogress/dfps/irate/izoom/iscale/Cpaused/Cstopped', $msg); } $data['rate'] /= RATE_BASE; $data['zoom'] = round($data['zoom']/SCALE_BASE, 1); diff --git a/web/js/EventStream.js b/web/js/EventStream.js index 24dc64ced..52eb8ee99 100644 --- a/web/js/EventStream.js +++ b/web/js/EventStream.js @@ -32,6 +32,7 @@ function EventStream(config) { this.img = null; this.started = false; this.paused = false; + this.stopped = false; this.currentEventId = null; this.rate = 100; this.status = null; @@ -457,10 +458,13 @@ function EventStream(config) { } } - // Track paused state from server + // Track paused and stopped state from server if (this.status.paused !== undefined) { this.paused = !!this.status.paused; } + if (this.status.stopped !== undefined) { + this.stopped = !!this.status.stopped; + } // Notify consumer if (this.onStatus) { diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index baed62744..a063f2e6d 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -1271,7 +1271,12 @@ function MonitorStream(monitorData) { const delayString = secsToTime(this.status.delay); - if (this.status.paused == true) { + if (this.status.stopped == true) { + $j('#modeValue'+this.id).text('Stopped'); + $j('#rate'+this.id).addClass('hidden'); + $j('#delay'+this.id).addClass('hidden'); + $j('#level'+this.id).addClass('hidden'); + } else if (this.status.paused == true) { $j('#modeValue'+this.id).text('Paused'); $j('#rate'+this.id).addClass('hidden'); $j('#delayValue'+this.id).text(delayString); From 35b453c9356467da3ea8106ffd1369cc97689a1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 02:11:15 +0000 Subject: [PATCH 007/168] fix: improve stopped state handling in EventStream run loop Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/ba9cb47a-a3e8-4e13-aec7-c9cd258e2a3d Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_eventstream.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/zm_eventstream.cpp b/src/zm_eventstream.cpp index 93d4e9e06..cf08b81d4 100644 --- a/src/zm_eventstream.cpp +++ b/src/zm_eventstream.cpp @@ -1049,8 +1049,9 @@ void EventStream::runStream() { TimePoint::duration time_since_last_send = now - last_frame_sent; if (stopped) { - // In stopped state, do nothing except wait for a new command - send_frame = false; + // In stopped state, skip all frame processing until a new command is received. + // send_frame is already false from initialization above. + delta = MAX_SLEEP; } else if (!paused) { // Figure out if we should send this frame Debug(3, "not paused at curr_frame_id (%d-1) mod frame_mod(%d)", curr_frame_id, frame_mod); @@ -1083,7 +1084,7 @@ void EventStream::runStream() { } // end if streaming stepping or doing nothing // time_to_event > 0 means that we are not in the event - if (time_to_event > Seconds(0) and mode == MODE_ALL) { + if (!stopped && time_to_event > Seconds(0) and mode == MODE_ALL) { Debug(1, "Time since last send = %.2f s", FPSeconds(time_since_last_send).count()); if (time_since_last_send > Seconds(1)) { char frame_text[64]; @@ -1131,7 +1132,7 @@ void EventStream::runStream() { frame_count++; } - if (!paused && !event_data->frames.empty() + if (!paused && !stopped && !event_data->frames.empty() && curr_frame_id >= 1 && curr_frame_id <= (int)event_data->frames.size()) { // Get current frame data, curr_frame_id may have changed FrameData *last_frame_data = &event_data->frames[curr_frame_id-1]; @@ -1194,14 +1195,14 @@ void EventStream::runStream() { ); } // end if not at end of event } else { - // Paused + // Paused or stopped delta = MAX_SLEEP; // We are paused, so might be stepping //if ( step != 0 )// Adding 0 is cheaper than an if 0 // curr_frame_id starts at 1 though, so we might skip the first frame? curr_frame_id += step; - } // end if !paused + } // end if !paused && !stopped } // end scope for mutex lock if (type == STREAM_SINGLE) { From ea11498dc1fa65ca4ffe44e7f727c5381313fcf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 02:41:57 +0000 Subject: [PATCH 008/168] docs: document CMD_PLAY vs CMD_VARPLAY and CMD_STOP in MsgCommand enum Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/f82e2bf4-0e4e-48a4-bccd-42c61e66c6b3 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_stream.h | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/zm_stream.h b/src/zm_stream.h index 9993a8581..f729cc504 100644 --- a/src/zm_stream.h +++ b/src/zm_stream.h @@ -75,7 +75,11 @@ class StreamBase { typedef enum { CMD_NONE=0, CMD_PAUSE, + // CMD_PLAY resumes or starts playback at normal speed (1x, i.e. replay_rate = ZM_RATE_BASE). + // Use CMD_VARPLAY to resume at an arbitrary rate. CMD_PLAY, + // CMD_STOP halts all streaming activity. Unlike CMD_PAUSE, no keepalive frames are sent + // and the stream does no work until a new command is received. CMD_STOP, CMD_FASTFWD, CMD_SLOWFWD, @@ -88,6 +92,13 @@ class StreamBase { CMD_PREV, CMD_NEXT, CMD_SEEK, + // CMD_VARPLAY resumes or starts playback at a caller-specified rate. + // The desired rate is packed as a big-endian uint16 offset by +32768 so that the range + // [-32768, +32767] maps to [0, 65535]. ZM_RATE_BASE (100) represents 1x speed, so: + // 32868 (= 32768 + 100) encodes 1x forward playback, + // 32668 (= 32768 - 100) encodes 1x reverse playback. + // Negative rates play in reverse; rates > ZM_RATE_BASE play faster than real-time. + // MSG payload: msg_data[1..2] = (rate + 32768) as network-byte-order uint16. CMD_VARPLAY, CMD_GET_IMAGE, CMD_QUIT, From b4f48b91d0fcaca7b3fef15cde76eb08ba666054 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 6 May 2026 10:22:08 +0300 Subject: [PATCH 009/168] Lost "?" (ru_ru.php) --- web/lang/ru_ru.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/lang/ru_ru.php b/web/lang/ru_ru.php index 5db574ed3..77343e26f 100644 --- a/web/lang/ru_ru.php +++ b/web/lang/ru_ru.php @@ -1009,7 +1009,7 @@ $SLANG = array( 'ConfirmDeleteUser' => 'Вы уверены, что хотите удалить выбранных пользователей?', 'ConfirmDeleteServerTitle'=> 'Подтвердите удаление сервера', 'ConfirmDeleteServer' => 'Вы уверены, что хотите удалить выбранные серверы?', - 'ConfirmDeleteReport' => 'Вы уверены, что хотите удалить выбранные отчеты', + 'ConfirmDeleteReport' => 'Вы уверены, что хотите удалить выбранные отчеты?', 'ConfirmDeleteStorageTitle'=> 'Подтвердите удаление хранилища', 'ConfirmDeleteStorage' => 'Вы уверены, что хотите удалить выбранные хранилища?', 'ConfirmDeleteRoleTitle'=> 'Подтвердите удаление ролей', From 5c0d75ea977828e834b6af9250e44cc49c22d7a9 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 6 May 2026 10:27:31 +0300 Subject: [PATCH 010/168] Removed warning in the browser console if the manageDelConfirmModalBtns() function is not found (skin.js) --- web/skins/classic/js/skin.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index 59b572ec6..2018929d9 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -679,8 +679,6 @@ function getDelConfirmModal(key, title, formName=null) { if (!formName) { if (typeof manageDelConfirmModalBtns === "function") { manageDelConfirmModalBtns(); - } else { - console.warn(`Функция manageDelConfirmModalBtns не найдена.`); } } else { const form = document.querySelector('form[name="'+formName+'"]'); From bd096514326717ad53faf1efd83308456b960da6 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 6 May 2026 10:52:38 +0300 Subject: [PATCH 011/168] Use $report->canEdit() instead of canEdit('Events') (reports.php) As canEdit analysis has been added to reports in https://github.com/ZoneMinder/zoneminder/commit/2630d55ffbaf5024cfa8a5dc13ce611fd951080e --- web/ajax/reports.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/ajax/reports.php b/web/ajax/reports.php index 4c610a131..a4596c864 100644 --- a/web/ajax/reports.php +++ b/web/ajax/reports.php @@ -109,8 +109,7 @@ function deleteRequest($id) { $report = new ZM\Report($id); if ( !$report->Id() ) { $message[] = array($id=>'Report not found.'); - //} else if (!$report->canEdit()) { - } else if (!canEdit('Events')) { // IgorA100 Since we don't have permissions configured for Reports yet, we'll analyze permissions for Events. + } else if (!$report->canEdit()) { $message[] = array($id=>'You do not have permission to delete report '.$report->Id()); } else { $report->delete(); From 5fc72e9a011bbcb545509dd40c7a249629042bf2 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 6 May 2026 11:05:45 +0300 Subject: [PATCH 012/168] Get the "form" for an element (groups.js) --- web/skins/classic/views/js/groups.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/groups.js b/web/skins/classic/views/js/groups.js index 66c395f0b..32e58fdc6 100644 --- a/web/skins/classic/views/js/groups.js +++ b/web/skins/classic/views/js/groups.js @@ -40,7 +40,8 @@ function configureButtons(element) { if (canEdit.Groups) { configureDeleteButton(element); } else { - form.deleteBtn.disabled = 'disabled'; + const form = element.form; + if (form) form.deleteBtn.disabled = true; } } From 39398b137b00bbcbce80878ad94828a8b8a4188e Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 6 May 2026 11:09:59 +0300 Subject: [PATCH 013/168] Update groups.js --- web/skins/classic/views/js/groups.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/groups.js b/web/skins/classic/views/js/groups.js index 32e58fdc6..8b652543f 100644 --- a/web/skins/classic/views/js/groups.js +++ b/web/skins/classic/views/js/groups.js @@ -33,7 +33,7 @@ function editGroup( element ) { } function deleteGroup(element) { - getDelConfirmModal('ConfirmDeleteGroup', 'ConfirmDeleteGroupTitle', 'groupsForm'); + getDelConfirmModal('ConfirmDelete', 'Delete', 'groupsForm'); } function configureButtons(element) { From 3a2d31aaf773b7e347c547987b972c9dbb6f8d3b Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 6 May 2026 11:12:59 +0300 Subject: [PATCH 014/168] Replaced casual Russian text with English (skin.js) --- web/skins/classic/js/skin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index 2018929d9..bde6791a7 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -686,7 +686,7 @@ function getDelConfirmModal(key, title, formName=null) { if (currentView == 'groups') form.elements['action'].value = 'delete'; submitThisForm(form); } else { - console.warn(`Форма с именем=${formName} не найдена.`); + console.warn(`Form with name=${formName} not found.`); } } }); From 6ec7ada0db82cf80ef28e7811005bf515af219fd Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 6 May 2026 11:20:47 +0300 Subject: [PATCH 015/168] Replaced random Russian text with English Display a message when a report deletion error occurs instead of updating a non-existent '#reportsTable' (report.js) --- web/skins/classic/views/js/report.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/report.js b/web/skins/classic/views/js/report.js index b6fedc8f3..f0e2962f6 100644 --- a/web/skins/classic/views/js/report.js +++ b/web/skins/classic/views/js/report.js @@ -29,7 +29,8 @@ function deleteReports(ids) { }) .fail( function(jqxhr) { logAjaxFail(jqxhr); - $j('#reportsTable').bootstrapTable('refresh'); + window.alert('Failed to delete report.'); + window.location.reload(); }); } From 8bd761220a938a1cb1cfbaa8537e5269cbab693d Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 6 May 2026 11:37:47 +0300 Subject: [PATCH 016/168] Replacing $j('#eventTable') with tableControlCaps (controlcaps.js) --- web/skins/classic/views/js/controlcaps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/controlcaps.js b/web/skins/classic/views/js/controlcaps.js index 6bf3fdbf3..cf8a8aa2c 100644 --- a/web/skins/classic/views/js/controlcaps.js +++ b/web/skins/classic/views/js/controlcaps.js @@ -35,7 +35,7 @@ function manageDelConfirmModalBtns() { $j.getJSON(thisUrl + '?request=controlcaps&action=delete&cids[]='+selections.join('&cids[]=')) .done( function(data) { - $j('#eventTable').bootstrapTable('refresh'); + tableControlCaps.bootstrapTable('refresh'); window.location.reload(true); }) .fail(logAjaxFail); From 73126938c4311425dc51eeaad9e41a5358880a21 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 3 May 2026 09:39:41 -0400 Subject: [PATCH 017/168] feat: auto-retry zmDbDo on InnoDB deadlock errno 1213 Deadlock detection (errno 1213) is part of normal InnoDB operation under contention; the engine rolls back the loser and expects the caller to re-run the statement on a fresh transaction. Most callers into zmDbDo go through autocommit, where there's no caller-managed transaction state for a retry to disturb. When AutoCommit is on, retry the statement up to 5 times with exponential backoff (~100ms -> ~1.6s, jittered). When AutoCommit is off, the caller owns the transaction and a unilateral retry would silently succeed against a TX that no longer reflects the work the caller staged before this statement; preserve the existing behavior of logging and returning undef so the caller can rebuild the TX itself. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/ZoneMinder/lib/ZoneMinder/Database.pm | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm index cebaa50ef..4e61daad4 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm @@ -263,15 +263,28 @@ sub _sql_with_bind_values { } # Basic execution of $dbh->do but with some pretty logging of the sql on error. +# Auto-retries on deadlock (errno 1213) only when AutoCommit is on, since +# inside a caller-managed transaction the caller has to rebuild the whole TX. sub zmDbDo { - my $sql = shift; - my $rows = $dbh->do($sql, undef, @_); - if ( ! defined $rows ) { - Error('Failed '._sql_with_bind_values($sql, @_).' : '.$dbh->errstr()); - } elsif ( ZoneMinder::Logger::logLevel() > INFO ) { + my $sql = shift; + my @params = @_; + my $max_attempts = $dbh->{AutoCommit} ? 5 : 1; + my $rows; + for ( my $attempt = 1; $attempt <= $max_attempts; $attempt++ ) { + $rows = $dbh->do($sql, undef, @params); + last if defined $rows; + if ( ($dbh->err() // 0) == 1213 and $attempt < $max_attempts ) { + Debug("Deadlock on '"._sql_with_bind_values($sql, @params)."' attempt $attempt/$max_attempts, retrying"); + select(undef, undef, undef, 0.05 * (1 << $attempt) + rand(0.05)); + next; + } + Error('Failed '._sql_with_bind_values($sql, @params).' : '.$dbh->errstr()); + last; + } + if ( defined $rows and ZoneMinder::Logger::logLevel() > INFO ) { ($rows) = $rows =~ /^(.*)$/; # de-taint - Debug('Succeeded '._sql_with_bind_values($sql, @_)." : $rows rows affected"); - } + Debug('Succeeded '._sql_with_bind_values($sql, @params)." : $rows rows affected"); + } return $rows; } From fe85f1dedd89dfb868855b32c13d9d7c26a46a85 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 3 May 2026 09:39:58 -0400 Subject: [PATCH 018/168] fix: rollback and retry Event::delete under deadlock at READ COMMITTED Three issues in the existing Stats/Event_Data/Frames/Events delete sequence: - On any zmDbDo error inside the transaction the code called dbh->commit() instead of dbh->rollback(). The server-side transaction was already rolled back when InnoDB picked us as the deadlock victim, so the commit() was effectively against a fresh auto-started TX, but the bug pattern leaked through as confusing state and prevented any retry. - There was no retry. errno 1213 is expected under contention with zmstats and zma touching the same Event_Summaries[MonitorId] row, and the loser is supposed to re-run. - At REPEATABLE READ, two concurrent filter workers deleting events with adjacent EventIds take next-key/gap locks on each other's rows in the bucket tables. Rewrite the delete block as a retry loop: SET TRANSACTION ISOLATION LEVEL READ COMMITTED, begin_work, run the four DELETEs, commit on success. On any error rollback (was: commit). On errno 1213 retry up to 5 times with backoff. Skip both the isolation switch and the rollback-then-retry when the caller is managing their own transaction (in_transaction); they would be the wrong scope to act in. Falls through to the storage DiskSpace adjustment only on commit, so a deadlocked delete leaves the event for the next filter pass instead of orphaning the row with stale storage accounting. Note: do NOT pre-lock Event_Summaries[MonitorId] FOR UPDATE here, even though the trigger touches it last. Pre-locking puts ES before buckets[Id] in the lock acquisition order, which inverts against zma's event_update_trigger path (Events[A] -> buckets[A] -> ES[N]) and re-introduces the cycle the rest of this work is removing. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/ZoneMinder/lib/ZoneMinder/Event.pm | 71 ++++++++++++++++------ 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm index b1b5ec50f..c24d9f435 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm @@ -395,28 +395,59 @@ sub delete { my $in_transaction = $ZoneMinder::Database::dbh->{AutoCommit} ? 0 : 1; - $ZoneMinder::Database::dbh->begin_work() if ! $in_transaction; + # event_delete_trigger fires BEFORE DELETE on Events; after the + # accompanying triggers.sql change, event_update_trigger now also fires + # BEFORE UPDATE. That gives every Events writer the same lock acquisition + # order: buckets[Id] -> Event_Summaries[MonitorId] -> Events[Id] (the + # outer DML row last). zmstats.pl runs at the same RC isolation and takes + # bucket-row X-locks first, then UPDATE Event_Summaries, which is the + # same prefix order — so no cycle is possible across filter / zma / + # zmstats. Do NOT pre-lock Event_Summaries here: that puts ES before + # buckets and re-introduces the inversion against zma's UPDATE path. + # + # READ COMMITTED drops the next-key/gap locks that two concurrent filter + # workers deleting adjacent EventIds in the bucket tables would otherwise + # take. SET TRANSACTION applies to the next transaction only, so it has + # to be re-issued before each begin_work (and is skipped when the caller + # is managing the TX). + # + # Retry on errno 1213 only when we own the TX; if the caller is managing + # one, bail and let them decide. + my $attempt = 0; + my $max_attempts = 5; + while (1) { + $attempt++; + if (!$in_transaction) { + ZoneMinder::Database::zmDbDo('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); + $ZoneMinder::Database::dbh->begin_work(); + } - # Going to delete in order of least value to greatest value. Stats is least and references Frames - ZoneMinder::Database::zmDbDo('DELETE FROM Stats WHERE EventId=?', $$event{Id}); - if ( $ZoneMinder::Database::dbh->errstr() ) { - $ZoneMinder::Database::dbh->commit() if ! $in_transaction; - return; - } - ZoneMinder::Database::zmDbDo('DELETE FROM Event_Data WHERE EventId=?', $$event{Id}); - if ( $ZoneMinder::Database::dbh->errstr() ) { - $ZoneMinder::Database::dbh->commit() if ! $in_transaction; - return; - } - ZoneMinder::Database::zmDbDo('DELETE FROM Frames WHERE EventId=?', $$event{Id}); - if ( $ZoneMinder::Database::dbh->errstr() ) { - $ZoneMinder::Database::dbh->commit() if ! $in_transaction; - return; - } + # Order: Stats -> Event_Data -> Frames -> Events (least to greatest reference depth) + my $err = 0; + foreach my $stmt ( + ['DELETE FROM Stats WHERE EventId=?', $$event{Id}], + ['DELETE FROM Event_Data WHERE EventId=?', $$event{Id}], + ['DELETE FROM Frames WHERE EventId=?', $$event{Id}], + ['DELETE FROM Events WHERE Id=?', $$event{Id}], + ) { + my ($sql, @bind) = @$stmt; + ZoneMinder::Database::zmDbDo($sql, @bind); + $err = $ZoneMinder::Database::dbh->err() // 0; + last if $err; + } - # Do it individually to avoid locking up the table for new events - ZoneMinder::Database::zmDbDo('DELETE FROM Events WHERE Id=?', $$event{Id}); - $ZoneMinder::Database::dbh->commit() if ! $in_transaction; + if (!$err) { + $ZoneMinder::Database::dbh->commit() if !$in_transaction; + last; + } + + $ZoneMinder::Database::dbh->rollback() if !$in_transaction; + if ($in_transaction or $err != 1213 or $attempt >= $max_attempts) { + return; + } + Debug("Deadlock deleting event $$event{Id} attempt $attempt/$max_attempts, retrying"); + select(undef, undef, undef, 0.05 * (1 << $attempt) + rand(0.05)); + } my $storage = $event->Storage(); if ($event->DiskSpace() and $storage->Id()) { From 830a92542de1975ceb47cc045bf87844f9a8bdb3 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 3 May 2026 09:40:40 -0400 Subject: [PATCH 019/168] perf: snapshot bucket aggregates instead of joining in Event_Summaries resync The previous resync code in zmstats and zmaudit used multi-table UPDATEs against Event_Summaries that joined the bucket tables: UPDATE Event_Summaries es LEFT JOIN (SELECT ... FROM Events_Hour ...) h ON ... LEFT JOIN (SELECT ... FROM Events_Day ...) d ON ... ... SET es.HourEvents = h.c, ... zmaudit additionally used scalar correlated subqueries against Events for the Total/Archived columns and against Events for Storage.DiskSpace. MariaDB takes S-locks on the joined and sub-queried rows for the duration of any multi-table UPDATE statement, regardless of isolation level. event_update_trigger and event_delete_trigger hold X-locks on those same bucket rows while they walk the trigger body, so the resync deadlocks against active event lifecycle traffic. Captured a textbook example in SHOW ENGINE INNODB STATUS: TX(1) zma: HOLDS X Events_Hour[42229643] WAITS X Event_Summaries[28] TX(2) zmstats: HOLDS X Event_Summaries[2,4,5,...,28,...,73] WAITS S Events_Hour[42229643] Replace the JOIN/subquery pattern in both scripts with a snapshot phase followed by per-monitor UPDATEs: 1. SELECT MonitorId, COUNT(*), SUM(DiskSpace) FROM each bucket (and the equivalent Total/Archived aggregate from Events). Plain SELECTs do consistent reads and take no row locks. 2. SELECT MonitorId FROM Event_Summaries to widen the universe so monitors with empty buckets still get zeroed out. 3. For each monitor, UPDATE Event_Summaries SET ... WHERE MonitorId=?. Each UPDATE only X-locks one ES row and reads no other table. zmaudit's five separate UPDATEs collapse to one snapshot phase plus one UPDATE per monitor. Storage DiskSpace gets the same treatment. zmstats keeps the same outer transaction (BEGIN ... COMMIT, RC isolation, retry on 1213) so the bucket DELETEs and the resync stay atomic, but the resync no longer reads the bucket tables under lock. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/zmaudit.pl.in | 161 ++++++++++++++++++++++++++++-------------- scripts/zmstats.pl.in | 124 +++++++++++++++++++++++++++++--- 2 files changed, 224 insertions(+), 61 deletions(-) diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index 66d815886..1f7813ffe 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -926,64 +926,119 @@ FROM `Frames` WHERE `EventId`=?'; } # end if ZM_LOG_AUDIT_DATABASE_LIMIT $loop = $continuous; - my $eventcounts_sql = ' - UPDATE `Event_Summaries` SET - `TotalEvents`=(SELECT COUNT(`Id`) FROM `Events` WHERE `MonitorId`=`Event_Summaries`.`MonitorId`), - `TotalEventDiskSpace`=(SELECT SUM(`DiskSpace`) FROM `Events` WHERE `MonitorId`=`Event_Summaries`.`MonitorId` AND `DiskSpace` IS NOT NULL), - `ArchivedEvents`=(SELECT COUNT(`Id`) FROM `Events` WHERE `MonitorId`=`Event_Summaries`.`MonitorId` AND `Archived`=1), - `ArchivedEventDiskSpace`=(SELECT SUM(`DiskSpace`) FROM `Events` WHERE `MonitorId`=`Event_Summaries`.`MonitorId` AND `Archived`=1 AND `DiskSpace` IS NOT NULL) - '; + # Resync Event_Summaries from ground truth in Events + bucket tables. + # + # Previous implementation used multi-table UPDATEs (UPDATE Event_Summaries + # INNER JOIN (... FROM Events_Hour ...)) and correlated subqueries against + # Events. Both patterns make MariaDB take S-locks on the joined/sub-queried + # rows for the duration of the UPDATE statement, regardless of isolation + # level — which deadlocks against event_update_trigger / event_delete_trigger + # holding X-locks on those same bucket rows. + # + # Snapshot all five aggregations with plain SELECTs (consistent reads, no + # row locks), merge them per MonitorId, then issue one single-row UPDATE + # per monitor against Event_Summaries. Each UPDATE only X-locks the one ES + # row it targets and reads no other table, so it can't form a cycle with + # the trigger writers. + { + my %agg; - ZoneMinder::Database::zmDbDo($eventcounts_sql); - aud_print('Finished updating TotalEvents, ArchivedEvents'); + my $events_rows = $dbh->selectall_arrayref(q{ + SELECT MonitorId, + COUNT(Id), + COALESCE(SUM(CASE WHEN DiskSpace IS NOT NULL THEN DiskSpace ELSE 0 END), 0), + COUNT(CASE WHEN Archived = 1 THEN 1 END), + COALESCE(SUM(CASE WHEN Archived = 1 AND DiskSpace IS NOT NULL THEN DiskSpace ELSE 0 END), 0) + FROM Events + GROUP BY MonitorId + }); + if ($dbh->err()) { + Error("zmaudit Events aggregate failed: ".$dbh->errstr()); + } else { + for my $r (@$events_rows) { + $agg{$r->[0]}{total_c} = $r->[1]; + $agg{$r->[0]}{total_s} = $r->[2]; + $agg{$r->[0]}{archived_c} = $r->[3]; + $agg{$r->[0]}{archived_s} = $r->[4]; + } + } - my $eventcounts_hour_sql = ' - UPDATE `Event_Summaries` INNER JOIN ( - SELECT `MonitorId`, COUNT(*) AS `HourEvents`, SUM(COALESCE(`DiskSpace`,0)) AS `HourEventDiskSpace` - FROM `Events_Hour` GROUP BY `MonitorId` - ) AS `E` ON `E`.`MonitorId`=`Event_Summaries`.`MonitorId` SET - `Event_Summaries`.`HourEvents` = `E`.`HourEvents`, - `Event_Summaries`.`HourEventDiskSpace` = `E`.`HourEventDiskSpace` - '; - ZoneMinder::Database::zmDbDo($eventcounts_hour_sql); - aud_print("Finished updating HourEvents"); + foreach my $bucket ( + ['Events_Hour', 'h'], + ['Events_Day', 'd'], + ['Events_Week', 'w'], + ['Events_Month', 'm'], + ) { + my ($table, $key) = @$bucket; + my $rows = $dbh->selectall_arrayref( + "SELECT MonitorId, COUNT(*), COALESCE(SUM(DiskSpace), 0) FROM $table GROUP BY MonitorId" + ); + if ($dbh->err()) { + Error("zmaudit $table aggregate failed: ".$dbh->errstr()); + next; + } + for my $r (@$rows) { + $agg{$r->[0]}{$key.'_c'} = $r->[1]; + $agg{$r->[0]}{$key.'_s'} = $r->[2]; + } + } + # Include monitors that have an Event_Summaries row but no Events rows so + # we still zero them out instead of leaving stale counters. + my $existing = $dbh->selectcol_arrayref('SELECT MonitorId FROM Event_Summaries'); + if ($dbh->err()) { + Error("zmaudit Event_Summaries enumerate failed: ".$dbh->errstr()); + } else { + $agg{$_} ||= {} for @$existing; + } - my $eventcounts_day_sql = ' - UPDATE `Event_Summaries` INNER JOIN ( - SELECT `MonitorId`, COUNT(*) AS `DayEvents`, SUM(COALESCE(`DiskSpace`,0)) AS `DayEventDiskSpace` - FROM `Events_Day` GROUP BY `MonitorId` - ) AS `E` ON `E`.`MonitorId`=`Event_Summaries`.`MonitorId` SET - `Event_Summaries`.`DayEvents` = `E`.`DayEvents`, - `Event_Summaries`.`DayEventDiskSpace` = `E`.`DayEventDiskSpace` - '; - ZoneMinder::Database::zmDbDo($eventcounts_day_sql); - aud_print("Finished updating DayEvents"); + for my $mid (sort { $a <=> $b } keys %agg) { + my $a = $agg{$mid}; + ZoneMinder::Database::zmDbDo( + 'UPDATE Event_Summaries SET '. + 'TotalEvents=?, TotalEventDiskSpace=?, '. + 'ArchivedEvents=?, ArchivedEventDiskSpace=?, '. + 'HourEvents=?, HourEventDiskSpace=?, '. + 'DayEvents=?, DayEventDiskSpace=?, '. + 'WeekEvents=?, WeekEventDiskSpace=?, '. + 'MonthEvents=?, MonthEventDiskSpace=? '. + 'WHERE MonitorId=?', + $a->{total_c} // 0, $a->{total_s} // 0, + $a->{archived_c} // 0, $a->{archived_s} // 0, + $a->{h_c} // 0, $a->{h_s} // 0, + $a->{d_c} // 0, $a->{d_s} // 0, + $a->{w_c} // 0, $a->{w_s} // 0, + $a->{m_c} // 0, $a->{m_s} // 0, + $mid + ); + } + aud_print('Finished resyncing Event_Summaries from Events + bucket tables'); + } - my $eventcounts_week_sql = ' - UPDATE `Event_Summaries` INNER JOIN ( - SELECT `MonitorId`, COUNT(*) AS `WeekEvents`, SUM(COALESCE(`DiskSpace`,0)) AS `WeekEventDiskSpace` - FROM `Events_Week` GROUP BY `MonitorId` - ) AS `E` ON `E`.`MonitorId`=`Event_Summaries`.`MonitorId` SET - `Event_Summaries`.`WeekEvents` = `E`.`WeekEvents`, - `Event_Summaries`.`WeekEventDiskSpace` = `E`.`WeekEventDiskSpace` - '; - ZoneMinder::Database::zmDbDo($eventcounts_week_sql); - aud_print("Finished updating WeekEvents"); - - my $eventcounts_month_sql = ' - UPDATE `Event_Summaries` INNER JOIN ( - SELECT `MonitorId`, COUNT(*) AS `MonthEvents`, SUM(COALESCE(`DiskSpace`,0)) AS `MonthEventDiskSpace` - FROM `Events_Month` GROUP BY `MonitorId` - ) AS `E` ON `E`.`MonitorId`=`Event_Summaries`.`MonitorId` SET - `Event_Summaries`.`MonthEvents` = `E`.`MonthEvents`, - `Event_Summaries`.`MonthEventDiskSpace` = `E`.`MonthEventDiskSpace` - '; - ZoneMinder::Database::zmDbDo($eventcounts_month_sql); - aud_print("Finished updating MonthEvents"); - - ZoneMinder::Database::zmDbDo('UPDATE Storage SET DiskSpace=(SELECT SUM(DiskSpace) FROM Events WHERE StorageId=Storage.Id)'); - aud_print("Finished updating Storage DiskSpace"); + # Storage DiskSpace resync: same trap. Snapshot per-Storage sums via plain + # SELECT, then UPDATE Storage one row at a time. + { + my $rows = $dbh->selectall_arrayref( + 'SELECT StorageId, COALESCE(SUM(DiskSpace), 0) FROM Events GROUP BY StorageId' + ); + if ($dbh->err()) { + Error('zmaudit Storage aggregate failed: '.$dbh->errstr()); + } else { + my %disk = map { $_->[0] => $_->[1] } grep { defined $_->[0] } @$rows; + my $storage_ids = $dbh->selectcol_arrayref('SELECT Id FROM Storage'); + if ($dbh->err()) { + Error('zmaudit Storage enumerate failed: '.$dbh->errstr()); + } else { + for my $sid (@$storage_ids) { + ZoneMinder::Database::zmDbDo( + 'UPDATE Storage SET DiskSpace=? WHERE Id=?', + $disk{$sid} // 0, $sid + ); + } + } + } + aud_print('Finished updating Storage DiskSpace'); + } sleep($Config{ZM_AUDIT_CHECK_INTERVAL}) if $continuous; }; diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index 2301e7f22..c717c13f5 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -87,17 +87,125 @@ while (!$zm_terminate) { my $monitor_ids = $dbh->selectcol_arrayref('SELECT MonitorId FROM Monitor_Status WHERE UpdatedOn < timestamp(DATE_SUB(NOW(), INTERVAL 1 MINUTE))'); zmDbDo('DELETE FROM Monitor_Status WHERE MonitorId IN ('.join(',', map { '?' } @$monitor_ids).')', @$monitor_ids) if $monitor_ids and @$monitor_ids; - my $event_ids = $dbh->selectcol_arrayref('SELECT EventId FROM Events_Hour WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL 1 hour)'); - zmDbDo('DELETE FROM Events_Hour WHERE EventId IN ('.join(',', map { '?' } @$event_ids).')', @$event_ids) if $event_ids and @$event_ids; + # Prune aged rows from Events_Hour/Day/Week/Month and resync Event_Summaries + # in one transaction. + # + # The resync MUST NOT use a multi-table UPDATE that joins Event_Summaries to + # the bucket tables: a multi-table UPDATE takes S-locks on the joined rows + # and holds them to TX commit *regardless of isolation level*, which + # deadlocks against event_update_trigger / event_delete_trigger holding + # X-locks on those same bucket rows. Snapshot the bucket aggregates first + # via plain SELECT (consistent read at RC -> no locks), then UPDATE + # Event_Summaries one row at a time using the snapshotted values. + # + # READ COMMITTED is still set for the bucket DELETE range scans, so they + # don't take next-key/gap locks against concurrent filter deletes / zma + # trigger updates on adjacent EventIds. + { + my $attempt = 0; + my $max_attempts = 5; + while (1) { + $attempt++; + # SET TRANSACTION ... applies only to the next transaction, so it must + # be issued before begin_work and re-issued on each retry. + zmDbDo('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); + $dbh->begin_work(); - $event_ids = $dbh->selectcol_arrayref('SELECT EventId FROM Events_Day WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL 1 day)'); - zmDbDo('DELETE FROM Events_Day WHERE EventId IN ('.join(',', map { '?' } @$event_ids).')', @$event_ids) if $event_ids and @$event_ids; + my $err = 0; + foreach my $bucket ( + ['Events_Hour', '1 hour'], + ['Events_Day', '1 day'], + ['Events_Week', '1 week'], + ['Events_Month', '1 month'], + ) { + my ($table, $interval) = @$bucket; + my $event_ids = $dbh->selectcol_arrayref( + "SELECT EventId FROM $table WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL $interval)" + ); + if ($event_ids and @$event_ids) { + zmDbDo( + "DELETE FROM $table WHERE EventId IN (".join(',', map { '?' } @$event_ids).')', + @$event_ids + ); + $err = $dbh->err() // 0; + last if $err; + } + } - $event_ids = $dbh->selectcol_arrayref('SELECT EventId FROM Events_Week WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL 1 week)'); - zmDbDo('DELETE FROM Events_Week WHERE EventId IN ('.join(',', map { '?' } @$event_ids).')', @$event_ids) if $event_ids and @$event_ids; + # Snapshot the per-monitor bucket aggregates. Plain SELECT under RC is + # a consistent read and takes no row locks, so this can't deadlock with + # the trigger writers. + my %agg; + if (!$err) { + foreach my $bucket ( + ['Events_Hour', 'h'], + ['Events_Day', 'd'], + ['Events_Week', 'w'], + ['Events_Month', 'm'], + ) { + my ($table, $key) = @$bucket; + my $rows = $dbh->selectall_arrayref( + "SELECT MonitorId, COUNT(*), COALESCE(SUM(DiskSpace), 0) FROM $table GROUP BY MonitorId" + ); + $err = $dbh->err() // 0; + last if $err; + for my $r (@$rows) { + $agg{$r->[0]}{$key.'_c'} = $r->[1]; + $agg{$r->[0]}{$key.'_s'} = $r->[2]; + } + } + } - $event_ids = $dbh->selectcol_arrayref('SELECT EventId FROM Events_Month WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL 1 month)'); - zmDbDo('DELETE FROM Events_Month WHERE EventId IN ('.join(',', map { '?' } @$event_ids).')', @$event_ids) if $event_ids and @$event_ids; + # Pull the universe of MonitorIds from Event_Summaries so any monitor + # with zero rows in every bucket still gets zeroed out. + if (!$err) { + my $monitor_ids = $dbh->selectcol_arrayref('SELECT MonitorId FROM Event_Summaries'); + $err = $dbh->err() // 0; + if (!$err) { + for my $mid (@$monitor_ids) { + $agg{$mid} ||= {}; + } + } + } + + # One UPDATE per monitor: each takes a single ES X-lock and reads + # nothing else, so it can't hold any bucket-row lock that would + # deadlock with the trigger path. + if (!$err) { + for my $mid (sort { $a <=> $b } keys %agg) { + my $a = $agg{$mid}; + zmDbDo( + 'UPDATE Event_Summaries SET '. + 'HourEvents=?, HourEventDiskSpace=?, '. + 'DayEvents=?, DayEventDiskSpace=?, '. + 'WeekEvents=?, WeekEventDiskSpace=?, '. + 'MonthEvents=?, MonthEventDiskSpace=? '. + 'WHERE MonitorId=?', + $a->{h_c} // 0, $a->{h_s} // 0, + $a->{d_c} // 0, $a->{d_s} // 0, + $a->{w_c} // 0, $a->{w_s} // 0, + $a->{m_c} // 0, $a->{m_s} // 0, + $mid + ); + $err = $dbh->err() // 0; + last if $err; + } + } + + if (!$err) { + $dbh->commit(); + last; + } + + $dbh->rollback(); + if ($err != 1213 or $attempt >= $max_attempts) { + Error("Event_Summaries prune+resync gave up after $attempt attempt(s): ".$dbh->errstr()); + last; + } + Debug("Deadlock during Event_Summaries prune+resync, attempt $attempt/$max_attempts"); + select(undef, undef, undef, 0.05 * (1 << $attempt) + rand(0.05)); + } + } # Prune the Logs table if required (excluding AUDIT entries) if ( $Config{ZM_LOG_DATABASE_LIMIT} ) { From fb230ebbba0013e0f605da73fab6405d07731ae8 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 6 May 2026 16:48:18 -0400 Subject: [PATCH 020/168] docs: document canonical Events lock acquisition order in triggers.sql Records the InnoDB X-lock order that all writers (zma trigger path, zmstats prune+resync, zmfilter/Event::delete) must follow to avoid the deadlock cycles fixed in the surrounding commits. Comment only; no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/triggers.sql | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/db/triggers.sql b/db/triggers.sql index b6e64ee11..774ead5d1 100644 --- a/db/triggers.sql +++ b/db/triggers.sql @@ -116,9 +116,21 @@ FOR EACH ROW drop procedure if exists update_storage_stats// +/* ============================================================================ + * Lock-acquisition order for the Events update/delete triggers (and for the + * scripts that touch the same tables). InnoDB X-locks the matched Events row + * during WHERE evaluation, before either BEFORE or AFTER trigger bodies fire, + * so the order is the same regardless of trigger timing: + * Events[Id] -> Events_Hour/Day/Week/Month[EventId] -> Event_Summaries[MonitorId] + * zmstats.pl prune+resync follows the matching prefix (bucket DELETEs then + * UPDATE Event_Summaries) and crucially does NOT pre-lock Event_Summaries — + * pre-locking ES would invert against the trigger body order and reintroduce + * deadlocks against filter / zma writers. The bucket update/delete triggers + * also propagate into Event_Summaries[MonitorId] in the same direction. + * ============================================================================ */ drop trigger if exists event_update_trigger// -CREATE TRIGGER event_update_trigger AFTER UPDATE ON Events +CREATE TRIGGER event_update_trigger AFTER UPDATE ON Events FOR EACH ROW BEGIN declare diff BIGINT default 0; From 19c5bcbbde7bd73abeb885acb7e0afdec2ec7944 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 7 May 2026 09:39:08 -0400 Subject: [PATCH 021/168] docs: correct Event::delete lock-order comment to match triggers.sql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous comment block claimed event_update_trigger fires BEFORE UPDATE and that the lock order was buckets -> Event_Summaries -> Events. Neither matches the code: triggers.sql defines event_update_trigger as AFTER UPDATE, and InnoDB X-locks the matched Events row during WHERE evaluation before either BEFORE or AFTER trigger bodies fire — so the canonical chain is Events[Id] -> buckets[EventId] -> Event_Summaries[MonitorId] which is what triggers.sql already documents. Comment-only change. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/ZoneMinder/lib/ZoneMinder/Event.pm | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm index c24d9f435..90ae0dc81 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm @@ -395,15 +395,17 @@ sub delete { my $in_transaction = $ZoneMinder::Database::dbh->{AutoCommit} ? 0 : 1; - # event_delete_trigger fires BEFORE DELETE on Events; after the - # accompanying triggers.sql change, event_update_trigger now also fires - # BEFORE UPDATE. That gives every Events writer the same lock acquisition - # order: buckets[Id] -> Event_Summaries[MonitorId] -> Events[Id] (the - # outer DML row last). zmstats.pl runs at the same RC isolation and takes - # bucket-row X-locks first, then UPDATE Event_Summaries, which is the - # same prefix order — so no cycle is possible across filter / zma / - # zmstats. Do NOT pre-lock Event_Summaries here: that puts ES before - # buckets and re-introduces the inversion against zma's UPDATE path. + # InnoDB X-locks the matched Events row during WHERE evaluation, before + # either BEFORE or AFTER trigger bodies fire, so the lock acquisition + # order is the same regardless of trigger timing: + # Events[Id] -> Events_Hour/Day/Week/Month[EventId] -> Event_Summaries[MonitorId] + # event_delete_trigger (BEFORE DELETE on Events) and event_update_trigger + # (AFTER UPDATE on Events) both propagate into the bucket tables, whose + # own triggers then UPDATE Event_Summaries — that's the canonical chain. + # zmstats.pl prune+resync follows the matching prefix (bucket DELETEs + # then UPDATE Event_Summaries) and crucially does NOT pre-lock + # Event_Summaries: that would put ES before buckets and re-introduce the + # inversion against zma's UPDATE path. # # READ COMMITTED drops the next-key/gap locks that two concurrent filter # workers deleting adjacent EventIds in the bucket tables would otherwise From 943114da992321d93381035cdd2615e4c3cdfe0f Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 7 May 2026 09:39:08 -0400 Subject: [PATCH 022/168] fix: don't zero Event_Summaries counters on aggregate SELECT failure in zmaudit Previously, if any of the five aggregate SELECTs (Events, Events_Hour/Day/ Week/Month) failed transiently, the per-monitor UPDATE phase still ran and wrote `// 0` for every column from those failed groups, destroying valid counters across all monitors. Track per-aggregate success and build the UPDATE SET clause from only the column groups whose SELECT succeeded. zmaudit re-runs on its normal interval, so a missed group is corrected on the next pass instead of being overwritten with zeros now. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/zmaudit.pl.in | 59 +++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index 1f7813ffe..349630030 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -942,6 +942,11 @@ FROM `Frames` WHERE `EventId`=?'; # the trigger writers. { my %agg; + # All-or-nothing per column group: a transient SELECT failure must not + # cause the per-monitor UPDATE phase to write 0 over valid counters. + # Track which column groups are safe to write; skip the others entirely + # — zmaudit will resync them on its next pass. + my %ok = (events => 0, h => 0, d => 0, w => 0, m => 0); my $events_rows = $dbh->selectall_arrayref(q{ SELECT MonitorId, @@ -955,6 +960,7 @@ FROM `Frames` WHERE `EventId`=?'; if ($dbh->err()) { Error("zmaudit Events aggregate failed: ".$dbh->errstr()); } else { + $ok{events} = 1; for my $r (@$events_rows) { $agg{$r->[0]}{total_c} = $r->[1]; $agg{$r->[0]}{total_s} = $r->[2]; @@ -977,14 +983,16 @@ FROM `Frames` WHERE `EventId`=?'; Error("zmaudit $table aggregate failed: ".$dbh->errstr()); next; } + $ok{$key} = 1; for my $r (@$rows) { $agg{$r->[0]}{$key.'_c'} = $r->[1]; $agg{$r->[0]}{$key.'_s'} = $r->[2]; } } - # Include monitors that have an Event_Summaries row but no Events rows so - # we still zero them out instead of leaving stale counters. + # Include monitors that have an Event_Summaries row but no rows in any + # successfully-aggregated source — those need to be zeroed for the + # column groups we did read. my $existing = $dbh->selectcol_arrayref('SELECT MonitorId FROM Event_Summaries'); if ($dbh->err()) { Error("zmaudit Event_Summaries enumerate failed: ".$dbh->errstr()); @@ -992,27 +1000,34 @@ FROM `Frames` WHERE `EventId`=?'; $agg{$_} ||= {} for @$existing; } - for my $mid (sort { $a <=> $b } keys %agg) { - my $a = $agg{$mid}; - ZoneMinder::Database::zmDbDo( - 'UPDATE Event_Summaries SET '. - 'TotalEvents=?, TotalEventDiskSpace=?, '. - 'ArchivedEvents=?, ArchivedEventDiskSpace=?, '. - 'HourEvents=?, HourEventDiskSpace=?, '. - 'DayEvents=?, DayEventDiskSpace=?, '. - 'WeekEvents=?, WeekEventDiskSpace=?, '. - 'MonthEvents=?, MonthEventDiskSpace=? '. - 'WHERE MonitorId=?', - $a->{total_c} // 0, $a->{total_s} // 0, - $a->{archived_c} // 0, $a->{archived_s} // 0, - $a->{h_c} // 0, $a->{h_s} // 0, - $a->{d_c} // 0, $a->{d_s} // 0, - $a->{w_c} // 0, $a->{w_s} // 0, - $a->{m_c} // 0, $a->{m_s} // 0, - $mid - ); + # Build the SET clause from only the column groups we successfully read. + my @set; + my @bind_template; + if ($ok{events}) { + push @set, 'TotalEvents=?, TotalEventDiskSpace=?, ArchivedEvents=?, ArchivedEventDiskSpace=?'; + push @bind_template, qw(total_c total_s archived_c archived_s); + } + for my $bucket (['h','Hour'], ['d','Day'], ['w','Week'], ['m','Month']) { + my ($key, $col) = @$bucket; + next unless $ok{$key}; + push @set, "${col}Events=?, ${col}EventDiskSpace=?"; + push @bind_template, $key.'_c', $key.'_s'; + } + + if (@set) { + my $sql = 'UPDATE Event_Summaries SET '.join(', ', @set).' WHERE MonitorId=?'; + for my $mid (sort { $a <=> $b } keys %agg) { + my $a = $agg{$mid}; + ZoneMinder::Database::zmDbDo( + $sql, + (map { $a->{$_} // 0 } @bind_template), + $mid + ); + } + aud_print('Finished resyncing Event_Summaries from Events + bucket tables'); + } else { + Error('zmaudit: every Event_Summaries aggregate SELECT failed; skipping resync'); } - aud_print('Finished resyncing Event_Summaries from Events + bucket tables'); } # Storage DiskSpace resync: same trap. Snapshot per-Storage sums via plain From 0dcd500b7b521f5abe844ef330360c609e5e71a8 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 7 May 2026 09:52:08 -0400 Subject: [PATCH 023/168] fix: check SELECT errors in zmstats bucket-prune loop selectcol_arrayref returns undef on a DB error. The previous code only checked truthiness of the result before deciding to DELETE, so a transient SELECT failure would silently skip the prune for that bucket and let the transaction continue to commit an incomplete state. Capture \$dbh->err() after the SELECT and bail out the same way the DELETE error path does. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/zmstats.pl.in | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index c717c13f5..e5ed39e5c 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -122,6 +122,8 @@ while (!$zm_terminate) { my $event_ids = $dbh->selectcol_arrayref( "SELECT EventId FROM $table WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL $interval)" ); + $err = $dbh->err() // 0; + last if $err; if ($event_ids and @$event_ids) { zmDbDo( "DELETE FROM $table WHERE EventId IN (".join(',', map { '?' } @$event_ids).')', From f5e8eae51927871bbe0637a439865077ee7e3438 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 7 May 2026 15:17:33 -0400 Subject: [PATCH 024/168] docs: identify the 1213 magic number as MariaDB ER_LOCK_DEADLOCK Inline comments at every occurrence so future readers don't have to look up the errno. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/ZoneMinder/lib/ZoneMinder/Database.pm | 7 ++++--- scripts/ZoneMinder/lib/ZoneMinder/Event.pm | 6 +++--- scripts/zmstats.pl.in | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm index 4e61daad4..fe3093e73 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm @@ -263,8 +263,9 @@ sub _sql_with_bind_values { } # Basic execution of $dbh->do but with some pretty logging of the sql on error. -# Auto-retries on deadlock (errno 1213) only when AutoCommit is on, since -# inside a caller-managed transaction the caller has to rebuild the whole TX. +# Auto-retries on deadlock (MariaDB ER_LOCK_DEADLOCK = 1213) only when +# AutoCommit is on, since inside a caller-managed transaction the caller has +# to rebuild the whole TX. sub zmDbDo { my $sql = shift; my @params = @_; @@ -273,7 +274,7 @@ sub zmDbDo { for ( my $attempt = 1; $attempt <= $max_attempts; $attempt++ ) { $rows = $dbh->do($sql, undef, @params); last if defined $rows; - if ( ($dbh->err() // 0) == 1213 and $attempt < $max_attempts ) { + if ( ($dbh->err() // 0) == 1213 and $attempt < $max_attempts ) { # 1213 = ER_LOCK_DEADLOCK Debug("Deadlock on '"._sql_with_bind_values($sql, @params)."' attempt $attempt/$max_attempts, retrying"); select(undef, undef, undef, 0.05 * (1 << $attempt) + rand(0.05)); next; diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm index 90ae0dc81..e336340d4 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm @@ -413,8 +413,8 @@ sub delete { # to be re-issued before each begin_work (and is skipped when the caller # is managing the TX). # - # Retry on errno 1213 only when we own the TX; if the caller is managing - # one, bail and let them decide. + # Retry on deadlock (MariaDB ER_LOCK_DEADLOCK = 1213) only when we own + # the TX; if the caller is managing one, bail and let them decide. my $attempt = 0; my $max_attempts = 5; while (1) { @@ -444,7 +444,7 @@ sub delete { } $ZoneMinder::Database::dbh->rollback() if !$in_transaction; - if ($in_transaction or $err != 1213 or $attempt >= $max_attempts) { + if ($in_transaction or $err != 1213 or $attempt >= $max_attempts) { # 1213 = ER_LOCK_DEADLOCK return; } Debug("Deadlock deleting event $$event{Id} attempt $attempt/$max_attempts, retrying"); diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index e5ed39e5c..db2ac9248 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -200,7 +200,7 @@ while (!$zm_terminate) { } $dbh->rollback(); - if ($err != 1213 or $attempt >= $max_attempts) { + if ($err != 1213 or $attempt >= $max_attempts) { # 1213 = ER_LOCK_DEADLOCK Error("Event_Summaries prune+resync gave up after $attempt attempt(s): ".$dbh->errstr()); last; } From 7f7d7600301bf97c8e3e53d38fd3e826cf598a0d Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sat, 9 May 2026 15:37:23 +0300 Subject: [PATCH 025/168] Fix: Correct end timestamp of events when exporting MP4 for multiple events (download_functions.php) Closed: #4767 Also, an error when downloading an incomplete event has been fixed. This error occurred when $Event->DefaultVideo() started storing the value 'index.m3u8' instead of the incomplete event filename. --- web/includes/download_functions.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/includes/download_functions.php b/web/includes/download_functions.php index d369a92a3..fbf627476 100644 --- a/web/includes/download_functions.php +++ b/web/includes/download_functions.php @@ -96,7 +96,7 @@ function downloadEvents( } usort($events_by_monitor_id[$mid], function($a, $b) { - return strtotime($a->StartDateTime) <=> strtotime($b->StartDateTime); + return strtotime($a->StartDateTime) <=> strtotime($b->StartDateTime); }); $eventFileList = ''; @@ -109,13 +109,16 @@ function downloadEvents( $minTimeSecs = $event->StartDateTimeSecs(); $minTime = $event->StartDateTime(); } - if ($maxTimeSecs == -1 or $maxTimeSecs < $event->StartDateTimeSecs()) { - $maxTimeSecs = $event->EndDateTimeSecs(); + + $endSecs = $event->EndDateTimeSecs(); + if ($endSecs and ($maxTimeSecs == -1 or $maxTimeSecs < $endSecs)) { + $maxTimeSecs = $endSecs; $maxTime = $event->EndDateTime(); } - $eventFileList .= 'file \''.$event->Path().'/'.$event->DefaultVideo().'\''.PHP_EOL; + $eventFileList .= 'file \''.$event->Path().'/'.basename(findVideoEventFile($event)).'\''.PHP_EOL; } + $maxTime = ($maxTime !== '') ?: date('Y-m-d H:i:s'); # Probably incomplete event. $mergedFileName = $monitor->Name().' '.$minTime.' to '.$maxTime.'.mp4'; if (($fp = fopen('event_files.txt', 'w'))) { fwrite($fp, $eventFileList); From 27b744e941396b82192c77c42b1d746ca0d308dd Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sat, 9 May 2026 16:18:12 +0300 Subject: [PATCH 026/168] Code optimization (download_functions.php) --- web/includes/download_functions.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/includes/download_functions.php b/web/includes/download_functions.php index fbf627476..6fc5b4f54 100644 --- a/web/includes/download_functions.php +++ b/web/includes/download_functions.php @@ -115,10 +115,11 @@ function downloadEvents( $maxTimeSecs = $endSecs; $maxTime = $event->EndDateTime(); } - $eventFileList .= 'file \''.$event->Path().'/'.basename(findVideoEventFile($event)).'\''.PHP_EOL; + $fileName = basename(findVideoEventFile($event)); + if (strpos($fileName, 'incomplete') !== -1) $maxTime = date('Y-m-d H:i:s'); # Probably incomplete event. + $eventFileList .= 'file \''.$event->Path().'/'.$fileName.'\''.PHP_EOL; } - $maxTime = ($maxTime !== '') ?: date('Y-m-d H:i:s'); # Probably incomplete event. $mergedFileName = $monitor->Name().' '.$minTime.' to '.$maxTime.'.mp4'; if (($fp = fopen('event_files.txt', 'w'))) { fwrite($fp, $eventFileList); From a7c8fbe1aabdf2851cd914525a3047b27f445820 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sat, 9 May 2026 16:25:34 +0300 Subject: [PATCH 027/168] Added findVideoEventFile ($Event) function (functions.php) Added to the global file, as this function is intended to be used on Events and Event pages. --- web/includes/functions.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/web/includes/functions.php b/web/includes/functions.php index 4fa640bcc..0f5ecd5d3 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -2483,4 +2483,20 @@ function to_string($thing) { if (is_array($thing)) return implode(', ', $thing); return strval($thing); } + +function findVideoEventFile ($Event) { + $dir = $Event->Path(); + $eventDefaultVideo = $Event->DefaultVideo(); + $path = (!str_ends_with($eventDefaultVideo, '.m3u8')) ? $dir.'/'.$eventDefaultVideo : ''; + + if ($path === '') { + // Look for the final renamed mp4 first, then incomplete + $candidates = glob($dir.'/'.$Event->Id().'-video.*.mp4'); + if (!$candidates) $candidates = glob($dir.'/incomplete.*.mp4'); + if ($candidates) { + $path = $candidates[0]; + } + } + return $path; +} ?> From e0d43523308c61c3d54e8794d100d04998a5373a Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sat, 9 May 2026 18:29:59 +0300 Subject: [PATCH 028/168] Optimizing the menu style on the Monitor pagemonitor.css --- web/skins/classic/css/base/views/monitor.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/skins/classic/css/base/views/monitor.css b/web/skins/classic/css/base/views/monitor.css index 20a951392..4e40346d3 100644 --- a/web/skins/classic/css/base/views/monitor.css +++ b/web/skins/classic/css/base/views/monitor.css @@ -78,6 +78,7 @@ body.sticky #content { overflow-y: auto; height: 100%; } + nav ul.nav { height: 100%; flex-direction: column; @@ -89,11 +90,22 @@ ul.form > li { padding-left: 300px; margin: 10px 0; } + ul.form > li > label:first-child { width: 290px; margin-left: -300px; text-align: right; } + +nav a.nav-link { + margin-top: -0.5em; + margin-bottom: -0.5em; +} + +nav .nav-item.form-control-sm { + height: auto; +} + .EncoderParameters label { vertical-align: top; } From 3719f83c035fdc61df7f746f2d85300912ad3233 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 11:37:09 +0300 Subject: [PATCH 029/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/includes/download_functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/includes/download_functions.php b/web/includes/download_functions.php index 6fc5b4f54..a4d3fa9b3 100644 --- a/web/includes/download_functions.php +++ b/web/includes/download_functions.php @@ -116,7 +116,7 @@ function downloadEvents( $maxTime = $event->EndDateTime(); } $fileName = basename(findVideoEventFile($event)); - if (strpos($fileName, 'incomplete') !== -1) $maxTime = date('Y-m-d H:i:s'); # Probably incomplete event. + if (strpos($fileName, 'incomplete') !== false) $maxTime = date('Y-m-d H:i:s'); # Probably incomplete event. $eventFileList .= 'file \''.$event->Path().'/'.$fileName.'\''.PHP_EOL; } From 1d59afd1d2572c3b23dce79f8f89cd49fec2cfe0 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 15:48:25 +0300 Subject: [PATCH 030/168] Added checks (download_functions.php) --- web/includes/download_functions.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/web/includes/download_functions.php b/web/includes/download_functions.php index a4d3fa9b3..daba2782f 100644 --- a/web/includes/download_functions.php +++ b/web/includes/download_functions.php @@ -105,6 +105,11 @@ function downloadEvents( $maxTimeSecs = -1; $maxTime = ''; foreach ($events_by_monitor_id[$mid] as $event) { + $filePath = findVideoEventFile($event); + if ($filePath ==='') { + ZM\Warning('The file path for event '.$event->Id().' was not found.'); + continue; + } if ($minTimeSecs == -1 or $minTimeSecs > $event->StartDateTimeSecs()) { $minTimeSecs = $event->StartDateTimeSecs(); $minTime = $event->StartDateTime(); @@ -115,9 +120,14 @@ function downloadEvents( $maxTimeSecs = $endSecs; $maxTime = $event->EndDateTime(); } - $fileName = basename(findVideoEventFile($event)); + + $fileName = basename($filePath); if (strpos($fileName, 'incomplete') !== false) $maxTime = date('Y-m-d H:i:s'); # Probably incomplete event. $eventFileList .= 'file \''.$event->Path().'/'.$fileName.'\''.PHP_EOL; + + if ($eventFileList === '') { + ZM\Warning('No event files were found for exporting monitor events with ID='.$event->MonitorId()); + continue; } $mergedFileName = $monitor->Name().' '.$minTime.' to '.$maxTime.'.mp4'; @@ -156,6 +166,11 @@ function downloadEvents( } } # end foreach monitor + if (count($exportFileList) === 0) { + ZM\Warning('No events were found for export.'); + return ""; + } + generateFileList($exportFormat, $exportStructure, $archive_path, $exportCompressed, $export_dir, $export_root, $exportFileList); chdir(DIR_EXPORTS_DOWNLOAD); From 253d8c4a4942fd31429496691d97f09ab70f7bdd Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 15:54:19 +0300 Subject: [PATCH 031/168] Always treat $Event->DefaultVideo() as a string (functions.php) This might be an unnecessary check, but so be it... --- web/includes/functions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/includes/functions.php b/web/includes/functions.php index 0f5ecd5d3..a35419cf7 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -2486,8 +2486,8 @@ function to_string($thing) { function findVideoEventFile ($Event) { $dir = $Event->Path(); - $eventDefaultVideo = $Event->DefaultVideo(); - $path = (!str_ends_with($eventDefaultVideo, '.m3u8')) ? $dir.'/'.$eventDefaultVideo : ''; + $eventDefaultVideo = to_string($Event->DefaultVideo()); + $path = ($eventDefaultVideo !== '' && !str_ends_with($eventDefaultVideo, '.m3u8')) ? $dir.'/'.$eventDefaultVideo : ''; if ($path === '') { // Look for the final renamed mp4 first, then incomplete From f057ba2d41c8d5d36a76800b164840b8698d001a Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 16:17:57 +0300 Subject: [PATCH 032/168] Additional file existence check (functions.php) --- web/includes/functions.php | 1 + 1 file changed, 1 insertion(+) diff --git a/web/includes/functions.php b/web/includes/functions.php index a35419cf7..3b78bbdeb 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -2488,6 +2488,7 @@ function findVideoEventFile ($Event) { $dir = $Event->Path(); $eventDefaultVideo = to_string($Event->DefaultVideo()); $path = ($eventDefaultVideo !== '' && !str_ends_with($eventDefaultVideo, '.m3u8')) ? $dir.'/'.$eventDefaultVideo : ''; + if (!is_file($path)) $path = ''; # So we don't return a reference to a non-existent file. if ($path === '') { // Look for the final renamed mp4 first, then incomplete From a7fa4b6a342352e639ddea95f905dd81b907e851 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 16:48:46 +0300 Subject: [PATCH 033/168] The template has been changed to cover files with any extension, since it could be mp4, mkv, or webm (functions.php) --- web/includes/functions.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/includes/functions.php b/web/includes/functions.php index 3b78bbdeb..ecc411124 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -2491,9 +2491,9 @@ function findVideoEventFile ($Event) { if (!is_file($path)) $path = ''; # So we don't return a reference to a non-existent file. if ($path === '') { - // Look for the final renamed mp4 first, then incomplete - $candidates = glob($dir.'/'.$Event->Id().'-video.*.mp4'); - if (!$candidates) $candidates = glob($dir.'/incomplete.*.mp4'); + // Look for the final renamed mp4 or mkv or webm first, then incomplete + $candidates = glob($dir.'/'.$Event->Id().'-video.*.*'); + if (!$candidates) $candidates = glob($dir.'/incomplete.*.*'); if ($candidates) { $path = $candidates[0]; } From 54df5c38dbcea8342614d92c6c2f92d75c55f3c0 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 17:31:41 +0300 Subject: [PATCH 034/168] Don't send AJAX table update requests if the page is hidden (log.js) This will significantly reduce the server load. Because right now, if you open multiple Log pages and collapse them, the server is constantly receiving requests! --- web/skins/classic/views/js/log.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index 3a118ddbd..7c6047669 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -28,7 +28,8 @@ var params = // Called by bootstrap-table to retrieve zm log data function ajaxRequest(params) { - if ($j('#filterServerId').val()) { + if(document.visibilityState == 'hidden') return; + if ($j('#filterServerId').val()) { params.data.ServerId = $j('#filterServerId').val(); } if ($j('#filterLevel').val()) { From 6c8c90f20b30d9933ae47ca82242bb12223bdab4 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 17:33:40 +0300 Subject: [PATCH 035/168] Fix: space (log.js) --- web/skins/classic/views/js/log.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index 7c6047669..cf759eea4 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -29,7 +29,7 @@ var params = // Called by bootstrap-table to retrieve zm log data function ajaxRequest(params) { if(document.visibilityState == 'hidden') return; - if ($j('#filterServerId').val()) { + if ($j('#filterServerId').val()) { params.data.ServerId = $j('#filterServerId').val(); } if ($j('#filterLevel').val()) { From 91aa946888441897c3dabbe7bc184e7b76ea9570 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 19:29:28 +0300 Subject: [PATCH 036/168] Feat: Optimizing slow SQL queries for large tables (log.php) Using a JOIN subquery can speed up the main query by 10-20 times. Also, you should select by the 'Level' column, not 'Code', since 'Level' is indexed. --- web/ajax/log.php | 51 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index 117cc82d9..f6a7f6954 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -90,6 +90,9 @@ function queryRequest() { // The table we want our data from $table = 'Logs'; + $nameMainQuery = 't1'; # To optimize queries using a subquery + $nameSubQuery = 't2'; + // The names of the dB columns in the log table we are interested in $columns = array('Id', 'TimeKey', 'Component', 'ServerId', 'Pid', 'Code', 'Message', 'File', 'Line'); // The names of columns shown in the log view that are NOT dB columns in the database @@ -108,6 +111,12 @@ function queryRequest() { // Order specifies the sort direction, either asc or desc $order = (isset($_REQUEST['order']) and (strtolower($_REQUEST['order']) == 'asc')) ? 'ASC' : 'DESC'; + if ($nameMainQuery !== '' && $nameSubQuery !== '') { + array_walk($columns, function(&$value, $key, $nameMainQuery) { + $value = $nameMainQuery . '.' . $value; + }, $nameMainQuery); + } + $col_str = implode(', ', $columns); $data = array(); $query = array(); @@ -158,11 +167,37 @@ function queryRequest() { $where .= 'ServerId = ?'; $query['values'][] = $_REQUEST['ServerId']; } +/* We have an indexed 'Level', not 'Code'. if (!empty($_REQUEST['level'])) { if ($where) $where .= ' AND '; $where .= 'Code = ?'; $query['values'][] = $_REQUEST['level']; } +*/ + $L = $_REQUEST['level']; + $LL = ''; + if (!empty($L)) { + if ($L == 'DBG') { + $LL = 1; + } elseif ($L == 'INF') { + $LL = 0; + } elseif ($L == 'WAR') { + $LL = -1; + } elseif ($L == 'ERR') { + $LL = -2; + } elseif ($L == 'FAT') { + $LL = -3; + } elseif ($L == 'PNC') { + $LL = -4; + } elseif ($L == 'AUD') { + $LL = -5; + } elseif ($L == 'OFF') { + $LL = -6; + } + if ($where) $where .= ' AND '; + $where .= ' Level = ?'; + $query['values'][] = $LL; + } if (!empty($_REQUEST['StartDateTime'])) { $start_time = strtotime($_REQUEST['StartDateTime']); if ($start_time) { @@ -192,7 +227,21 @@ function queryRequest() { $data['total'] = $data['totalNotFiltered']; } - $query['sql'] = 'SELECT ' .$col_str. ' FROM `' .$table. '` ' .$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ?'; + if ($nameMainQuery !== '' && $nameSubQuery !== '') { # Optimized query + $query['sql'] = ' + SELECT ' .$col_str. ' + FROM `' .$table. '` ' .$nameMainQuery. ' + JOIN ( + SELECT id + FROM `'.$table.'` '.$where. ' + ORDER BY ' .$sort. ' ' .$order. ' + LIMIT ?, ? + ) AS ' .$nameSubQuery. ' + ON ' .$nameMainQuery. '.id=' .$nameSubQuery. '.id'; + } else { + $query['sql'] = 'SELECT ' .$col_str. ' FROM `' .$table. '` ' .$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ?'; + } + array_push($query['values'], $offset, $limit); $rows = array(); From d1556ea4cc1b50d741486b2a19e1622088ce88c4 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 20:05:08 +0300 Subject: [PATCH 037/168] Add Component, TimeKey, and Level to the primary index to increase query processing speed. zm_update-1.39.10.sql --- db/zm_update-1.39.10.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 db/zm_update-1.39.10.sql diff --git a/db/zm_update-1.39.10.sql b/db/zm_update-1.39.10.sql new file mode 100644 index 000000000..f03bb2cd1 --- /dev/null +++ b/db/zm_update-1.39.10.sql @@ -0,0 +1,6 @@ +-- +-- This updates a 1.39.9 database to 1.39.10 +-- +-- Add Component, TimeKey, and Level to the primary index to increase query processing speed. +-- +ALTER TABLE `Logs` DROP PRIMARY KEY, ADD PRIMARY KEY (`Id`, `Component`, `TimeKey`, `Level`) USING BTREE; From 36955257d49c71a35d512b88afd3eaa08eeaa723 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 20:08:34 +0300 Subject: [PATCH 038/168] Add Component, TimeKey, and Level to the primary index to increase query processing speed. (zm_create.sql.in) --- db/zm_create.sql.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/zm_create.sql.in b/db/zm_create.sql.in index 52e5c4710..edc9c4c53 100644 --- a/db/zm_create.sql.in +++ b/db/zm_create.sql.in @@ -523,7 +523,7 @@ CREATE TABLE `Logs` ( `Message` text NOT NULL, `File` varchar(255) DEFAULT NULL, `Line` smallint(5) unsigned DEFAULT NULL, - PRIMARY KEY (`Id`), + PRIMARY KEY (`Id`, `Component`, `TimeKey`, `Level`), KEY `TimeKey` (`TimeKey`) ) ENGINE=@ZM_MYSQL_ENGINE@; From 9d3a86f7ac0caed39b580c9fe51e93f4c8e8378e Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 22:17:07 +0300 Subject: [PATCH 039/168] Fix: Search (log.php) --- web/ajax/log.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index f6a7f6954..0d03a458b 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -95,6 +95,7 @@ function queryRequest() { // The names of the dB columns in the log table we are interested in $columns = array('Id', 'TimeKey', 'Component', 'ServerId', 'Pid', 'Code', 'Message', 'File', 'Line'); + $columnsContext = $columns; // The names of columns shown in the log view that are NOT dB columns in the database $col_alt = array('DateTime', 'Server'); @@ -112,12 +113,13 @@ function queryRequest() { $order = (isset($_REQUEST['order']) and (strtolower($_REQUEST['order']) == 'asc')) ? 'ASC' : 'DESC'; if ($nameMainQuery !== '' && $nameSubQuery !== '') { - array_walk($columns, function(&$value, $key, $nameMainQuery) { + array_walk($columnsContext, function(&$value, $key, $nameMainQuery) { $value = $nameMainQuery . '.' . $value; }, $nameMainQuery); } $col_str = implode(', ', $columns); + $col_str_context = implode(', ', $columnsContext); $data = array(); $query = array(); $query['values'] = array(); @@ -229,7 +231,7 @@ function queryRequest() { if ($nameMainQuery !== '' && $nameSubQuery !== '') { # Optimized query $query['sql'] = ' - SELECT ' .$col_str. ' + SELECT ' .$col_str_context. ' FROM `' .$table. '` ' .$nameMainQuery. ' JOIN ( SELECT id From 414c49e42574ada32c75f7cd8f4de99f266ddb81 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 22:39:08 +0300 Subject: [PATCH 040/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- db/zm_update-1.39.10.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/zm_update-1.39.10.sql b/db/zm_update-1.39.10.sql index f03bb2cd1..e5299e4cc 100644 --- a/db/zm_update-1.39.10.sql +++ b/db/zm_update-1.39.10.sql @@ -3,4 +3,4 @@ -- -- Add Component, TimeKey, and Level to the primary index to increase query processing speed. -- -ALTER TABLE `Logs` DROP PRIMARY KEY, ADD PRIMARY KEY (`Id`, `Component`, `TimeKey`, `Level`) USING BTREE; +ALTER TABLE `Logs` DROP PRIMARY KEY, ADD PRIMARY KEY (`Id`, `Component`, `TimeKey`, `Level`); From 8a396dec39a64711e9341d84817c5884651beafa Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 22:43:06 +0300 Subject: [PATCH 041/168] Update log.php --- web/ajax/log.php | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index 0d03a458b..e19272965 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -176,29 +176,12 @@ function queryRequest() { $query['values'][] = $_REQUEST['level']; } */ - $L = $_REQUEST['level']; - $LL = ''; - if (!empty($L)) { - if ($L == 'DBG') { - $LL = 1; - } elseif ($L == 'INF') { - $LL = 0; - } elseif ($L == 'WAR') { - $LL = -1; - } elseif ($L == 'ERR') { - $LL = -2; - } elseif ($L == 'FAT') { - $LL = -3; - } elseif ($L == 'PNC') { - $LL = -4; - } elseif ($L == 'AUD') { - $LL = -5; - } elseif ($L == 'OFF') { - $LL = -6; - } + $L = $_REQUEST['level'] ?? ''; + $level_codes = array_flip(ZM\Logger::$codes); + if (!empty($L) && isset($level_codes[$L])) { if ($where) $where .= ' AND '; $where .= ' Level = ?'; - $query['values'][] = $LL; + $query['values'][] = $level_codes[$L]; } if (!empty($_REQUEST['StartDateTime'])) { $start_time = strtotime($_REQUEST['StartDateTime']); @@ -234,12 +217,13 @@ function queryRequest() { SELECT ' .$col_str_context. ' FROM `' .$table. '` ' .$nameMainQuery. ' JOIN ( - SELECT id + SELECT Id FROM `'.$table.'` '.$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ? ) AS ' .$nameSubQuery. ' - ON ' .$nameMainQuery. '.id=' .$nameSubQuery. '.id'; + ON ' .$nameMainQuery. '.Id=' .$nameSubQuery. '.Id + ORDER BY ' .$nameMainQuery. '.' .$sort. ' ' .$order; } else { $query['sql'] = 'SELECT ' .$col_str. ' FROM `' .$table. '` ' .$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ?'; } From 40411cdef85c88d542030897d06354376eef3276 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 23:20:34 +0300 Subject: [PATCH 042/168] Fix: Missing space (log.js) --- web/skins/classic/views/js/log.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index cf759eea4..a35d315a1 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -28,7 +28,7 @@ var params = // Called by bootstrap-table to retrieve zm log data function ajaxRequest(params) { - if(document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') return; if ($j('#filterServerId').val()) { params.data.ServerId = $j('#filterServerId').val(); } From 28989ff6888712cdc90e353ef53891ad6187ede9 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 10 May 2026 23:39:34 +0300 Subject: [PATCH 043/168] Apply style only to "#pills-tab" (monitor.css) --- web/skins/classic/css/base/views/monitor.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/skins/classic/css/base/views/monitor.css b/web/skins/classic/css/base/views/monitor.css index 4e40346d3..b9e809372 100644 --- a/web/skins/classic/css/base/views/monitor.css +++ b/web/skins/classic/css/base/views/monitor.css @@ -97,12 +97,12 @@ ul.form > li > label:first-child { text-align: right; } -nav a.nav-link { +nav #pills-tab a.nav-link { margin-top: -0.5em; margin-bottom: -0.5em; } -nav .nav-item.form-control-sm { +nav #pills-tab .nav-item.form-control-sm { height: auto; } From 5a8a88faab4ebf05cf50fb19b40348b34023385a Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 00:04:13 +0300 Subject: [PATCH 044/168] Gjntdownload_functions.php --- web/includes/download_functions.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/includes/download_functions.php b/web/includes/download_functions.php index daba2782f..80e01116d 100644 --- a/web/includes/download_functions.php +++ b/web/includes/download_functions.php @@ -124,9 +124,10 @@ function downloadEvents( $fileName = basename($filePath); if (strpos($fileName, 'incomplete') !== false) $maxTime = date('Y-m-d H:i:s'); # Probably incomplete event. $eventFileList .= 'file \''.$event->Path().'/'.$fileName.'\''.PHP_EOL; + } if ($eventFileList === '') { - ZM\Warning('No event files were found for exporting monitor events with ID='.$event->MonitorId()); + ZM\Warning('No event files were found for exporting monitor events with ID='.$mid); continue; } From b5542e437ebc65e914f85ad4b6304541f0e984b8 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 00:39:09 +0300 Subject: [PATCH 045/168] The "findVideoEventFile()" function takes a file extension as an argument (functions.php) --- web/includes/functions.php | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/web/includes/functions.php b/web/includes/functions.php index ecc411124..3220d58d7 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -2484,16 +2484,44 @@ function to_string($thing) { return strval($thing); } -function findVideoEventFile ($Event) { +if (!function_exists('mb_ucfirst')) { // Available in PHP >= 8.4 + function mb_ucfirst($str, $encoding='UTF-8') { + if (extension_loaded('mbstring')) { + $result = mb_strtoupper(mb_substr($str, 0, 1, $encoding), $encoding) . mb_substr($str, 1, null, $encoding); + } else { + $result = (ucfirst($str)); + } + return $result; + } +} + +if (!function_exists('mb_lcfirst')) { // Available in PHP >= 8.4 + function mb_lcfirst($str, $encoding='UTF-8') { + if (extension_loaded('mbstring')) { + $result = mb_strtolower(mb_substr($str, 0, 1, $encoding), $encoding) . mb_substr($str, 1, null, $encoding); + } else { + $result = (lcfirst($str)); + } + return $result; + } +} + +function findVideoEventFile ($Event, $ext="*") { $dir = $Event->Path(); $eventDefaultVideo = to_string($Event->DefaultVideo()); - $path = ($eventDefaultVideo !== '' && !str_ends_with($eventDefaultVideo, '.m3u8')) ? $dir.'/'.$eventDefaultVideo : ''; + $path = ''; + if ($eventDefaultVideo !== '' && + !str_ends_with($eventDefaultVideo, '.m3u8') && + ($ext === "*" || str_ends_with(strtolower($eventDefaultVideo), '.' . $ext))) { + $path = $dir.'/'.$eventDefaultVideo; + } if (!is_file($path)) $path = ''; # So we don't return a reference to a non-existent file. if ($path === '') { - // Look for the final renamed mp4 or mkv or webm first, then incomplete - $candidates = glob($dir.'/'.$Event->Id().'-video.*.*'); - if (!$candidates) $candidates = glob($dir.'/incomplete.*.*'); + # By default, we search for files with any extension, such as mp4, mkv, or webm. + # Look for the final renamed first, then incomplete + $candidates = glob($dir.'/'.$Event->Id().'-video.*.'.$ext); + if (!$candidates) $candidates = glob($dir.'/incomplete.*.'.$ext); if ($candidates) { $path = $candidates[0]; } From bbcd07634d0f6027b266a05a3dc3feba92acaad0 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 00:46:20 +0300 Subject: [PATCH 046/168] For now, we'll only search for "mp4" files (download_functions.php) In the future, we'll need to add analysis of all files for merging and use that to decide on the output file format. --- web/includes/download_functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/includes/download_functions.php b/web/includes/download_functions.php index 80e01116d..acb4b16be 100644 --- a/web/includes/download_functions.php +++ b/web/includes/download_functions.php @@ -105,7 +105,7 @@ function downloadEvents( $maxTimeSecs = -1; $maxTime = ''; foreach ($events_by_monitor_id[$mid] as $event) { - $filePath = findVideoEventFile($event); + $filePath = findVideoEventFile($event, "mp4"); if ($filePath ==='') { ZM\Warning('The file path for event '.$event->Id().' was not found.'); continue; From 02210240a8ccaf2f4ab8e6ad11efdc32fd648a0b Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 12:32:38 +0300 Subject: [PATCH 047/168] Fix: Correct calculation of the total number of pages for pagination with filters (log.php) We don't need an additional check to include the WHERE clause in the query string, since $where will only be populated if filters are specified. Previously, there was an issue with counting the total number of pages when using a filter by components or levels. --- web/ajax/log.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index 117cc82d9..9a2de8351 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -185,12 +185,7 @@ function queryRequest() { } if ($where) $where = ' WHERE '.$where; - $data['totalNotFiltered'] = dbFetchOne('SELECT count(*) AS Total FROM ' .$table, 'Total'); - if ( $search != '' || count($advsearch) ) { - $data['total'] = dbFetchOne('SELECT count(*) AS Total FROM ' .$table.$where , 'Total', $query['values']); - } else { - $data['total'] = $data['totalNotFiltered']; - } + $data['total'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'` '.$where , 'Total', $query['values']); $query['sql'] = 'SELECT ' .$col_str. ' FROM `' .$table. '` ' .$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ?'; array_push($query['values'], $offset, $limit); From 0b26363e09bfa6b30d7825a87732e69853cfd9d8 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 18:07:08 +0300 Subject: [PATCH 048/168] Feat: Added a setting for refreshing the Log page window (ConfigData.pm.in) --- .../lib/ZoneMinder/ConfigData.pm.in | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in index f7e7a64a8..73a417ed6 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in +++ b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in @@ -3137,6 +3137,17 @@ our @options = ( type => $types{integer}, category => 'highband', }, + { + name => 'ZM_WEB_H_REFRESH_LOGS', + default => '30', + description => 'How often (in seconds) the event listing is refreshed in the log window', + help => q` + This option determines how often the list of events in the log window is refreshed. + 0 - completely disables refreshing. + `, + type => $types{integer}, + category => 'highband', + }, { name => 'ZM_WEB_H_CAN_STREAM', default => 'auto', @@ -3420,6 +3431,17 @@ our @options = ( type => $types{integer}, category => 'medband', }, + { + name => 'ZM_WEB_M_REFRESH_LOGS', + default => '60', + description => 'How often (in seconds) the event listing is refreshed in the log window', + help => q` + This option determines how often the list of events in the log window is refreshed. + 0 - completely disables refreshing. + `, + type => $types{integer}, + category => 'medband', + }, { name => 'ZM_WEB_M_CAN_STREAM', default => 'auto', @@ -3703,6 +3725,17 @@ our @options = ( type => $types{integer}, category => 'lowband', }, + { + name => 'ZM_WEB_L_REFRESH_LOGS', + default => '120', + description => 'How often (in seconds) the event listing is refreshed in the log window', + help => q` + This option determines how often the list of events in the log window is refreshed. + 0 - completely disables refreshing. + `, + type => $types{integer}, + category => 'lowband', + }, { name => 'ZM_WEB_L_CAN_STREAM', default => 'auto', From ba9c0a3fa16dc2fbb15309478a4786b69809b6f9 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 18:17:35 +0300 Subject: [PATCH 049/168] Added the ZM_WEB_REFRESH_LOGS constant (config.php) --- web/skins/classic/includes/config.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/skins/classic/includes/config.php b/web/skins/classic/includes/config.php index 43bf6a632..84753bc28 100644 --- a/web/skins/classic/includes/config.php +++ b/web/skins/classic/includes/config.php @@ -103,6 +103,7 @@ switch ( $_COOKIE['zmBandwidth'] ) { define( 'ZM_WEB_REFRESH_IMAGE', ZM_WEB_H_REFRESH_IMAGE ); // How often the watched image is refreshed (if not streaming) define( 'ZM_WEB_REFRESH_STATUS', ZM_WEB_H_REFRESH_STATUS ); // How often the little status frame refreshes itself in the watch window define( 'ZM_WEB_REFRESH_EVENTS', ZM_WEB_H_REFRESH_EVENTS ); // How often the event listing is refreshed in the watch window, only for recent events + define( 'ZM_WEB_REFRESH_LOGS', ZM_WEB_H_REFRESH_LOGS ); // How often (in seconds) the listing is refreshed in the log window define( 'ZM_WEB_CAN_STREAM', ZM_WEB_H_CAN_STREAM ); // Override the automatic detection of browser streaming capability define( 'ZM_WEB_STREAM_METHOD', ZM_WEB_H_STREAM_METHOD ); // Which method should be used to send video streams to your browser define( 'ZM_WEB_DEFAULT_SCALE', ZM_WEB_H_DEFAULT_SCALE ); // What the default scaling factor applied to 'live' or 'event' views is (%) @@ -122,6 +123,7 @@ switch ( $_COOKIE['zmBandwidth'] ) { define( 'ZM_WEB_REFRESH_IMAGE', ZM_WEB_M_REFRESH_IMAGE ); // How often the watched image is refreshed (if not streaming) define( 'ZM_WEB_REFRESH_STATUS', ZM_WEB_M_REFRESH_STATUS ); // How often the little status frame refreshes itself in the watch window define( 'ZM_WEB_REFRESH_EVENTS', ZM_WEB_M_REFRESH_EVENTS ); // How often the event listing is refreshed in the watch window, only for recent events + define( 'ZM_WEB_REFRESH_LOGS', ZM_WEB_M_REFRESH_LOGS ); // How often (in seconds) the listing is refreshed in the log window define( 'ZM_WEB_CAN_STREAM', ZM_WEB_M_CAN_STREAM ); // Override the automatic detection of browser streaming capability define( 'ZM_WEB_STREAM_METHOD', ZM_WEB_M_STREAM_METHOD ); // Which method should be used to send video streams to your browser define( 'ZM_WEB_DEFAULT_SCALE', ZM_WEB_M_DEFAULT_SCALE ); // What the default scaling factor applied to 'live' or 'event' views is (%) @@ -141,6 +143,7 @@ switch ( $_COOKIE['zmBandwidth'] ) { define( 'ZM_WEB_REFRESH_IMAGE', ZM_WEB_L_REFRESH_IMAGE ); // How often the watched image is refreshed (if not streaming) define( 'ZM_WEB_REFRESH_STATUS', ZM_WEB_L_REFRESH_STATUS ); // How often the little status frame refreshes itself in the watch window define( 'ZM_WEB_REFRESH_EVENTS', ZM_WEB_L_REFRESH_EVENTS ); // How often the event listing is refreshed in the watch window, only for recent events + define( 'ZM_WEB_REFRESH_LOGS', ZM_WEB_L_REFRESH_LOGS ); // How often (in seconds) the listing is refreshed in the log window define( 'ZM_WEB_CAN_STREAM', ZM_WEB_L_CAN_STREAM ); // Override the automatic detection of browser streaming capability define( 'ZM_WEB_STREAM_METHOD', ZM_WEB_L_STREAM_METHOD ); // Which method should be used to send video streams to your browser define( 'ZM_WEB_DEFAULT_SCALE', ZM_WEB_L_DEFAULT_SCALE ); // What the default scaling factor applied to 'live' or 'event' views is (%) From 5fbf600095ab4b5cf7e8d8cfd6a680ad88a13a23 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 18:21:52 +0300 Subject: [PATCH 050/168] Updated description (ConfigData.pm.in) --- scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in index 73a417ed6..492601db9 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in +++ b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in @@ -3140,9 +3140,9 @@ our @options = ( { name => 'ZM_WEB_H_REFRESH_LOGS', default => '30', - description => 'How often (in seconds) the event listing is refreshed in the log window', + description => 'How often (in seconds) the listing is refreshed in the log window', help => q` - This option determines how often the list of events in the log window is refreshed. + This option determines how often the list in the log window is refreshed. 0 - completely disables refreshing. `, type => $types{integer}, @@ -3434,9 +3434,9 @@ our @options = ( { name => 'ZM_WEB_M_REFRESH_LOGS', default => '60', - description => 'How often (in seconds) the event listing is refreshed in the log window', + description => 'How often (in seconds) the listing is refreshed in the log window', help => q` - This option determines how often the list of events in the log window is refreshed. + This option determines how often the list in the log window is refreshed. 0 - completely disables refreshing. `, type => $types{integer}, @@ -3728,9 +3728,9 @@ our @options = ( { name => 'ZM_WEB_L_REFRESH_LOGS', default => '120', - description => 'How often (in seconds) the event listing is refreshed in the log window', + description => 'How often (in seconds) the listing is refreshed in the log window', help => q` - This option determines how often the list of events in the log window is refreshed. + This option determines how often the list in the log window is refreshed. 0 - completely disables refreshing. `, type => $types{integer}, From 2b138a57bba6c5b97f5aa7da8feec4c4eda536c4 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 18:49:54 +0300 Subject: [PATCH 051/168] Added support for the ZM_WEB_REFRESH_LOGS constant (log.php) --- web/skins/classic/views/log.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/skins/classic/views/log.php b/web/skins/classic/views/log.php index 72743762e..141e88b2d 100644 --- a/web/skins/classic/views/log.php +++ b/web/skins/classic/views/log.php @@ -145,10 +145,10 @@ echo ''; data-maintain-meta-data="true" data-buttons-class="btn btn-normal" data-show-jump-to="true" - data-auto-refresh="true" + data-auto-refresh="" data-auto-refresh-silent="true" data-show-refresh="true" - data-auto-refresh-interval="30" + data-auto-refresh-interval="" data-click-to-select="true" From ed516b876257544e9ef30338d7e31988decc3c9c Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 20:42:20 +0300 Subject: [PATCH 052/168] Don't execute AJAX requests from bootstrap-table if the page is hidden. --- web/skins/classic/views/js/console.js | 1 + web/skins/classic/views/js/events.js | 1 + web/skins/classic/views/js/frames.js | 1 + web/skins/classic/views/js/reports.js | 1 + web/skins/classic/views/js/snapshots.js | 1 + web/skins/classic/views/js/watch.js | 1 + 6 files changed, 6 insertions(+) diff --git a/web/skins/classic/views/js/console.js b/web/skins/classic/views/js/console.js index cafb90f9b..94fb675a5 100644 --- a/web/skins/classic/views/js/console.js +++ b/web/skins/classic/views/js/console.js @@ -74,6 +74,7 @@ function updateFooter(footer) { // Called by bootstrap-table to retrieve monitor data function ajaxRequest(params) { + if(document.visibilityState == 'hidden') return; if (ajax) ajax.abort(); // Get filter selections from the form and add to params.data diff --git a/web/skins/classic/views/js/events.js b/web/skins/classic/views/js/events.js index 055df91d3..bc262afce 100644 --- a/web/skins/classic/views/js/events.js +++ b/web/skins/classic/views/js/events.js @@ -38,6 +38,7 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { + if(document.visibilityState == 'hidden') return; if (params.data && params.data.filter) { params.data.advsearch = params.data.filter; delete params.data.filter; diff --git a/web/skins/classic/views/js/frames.js b/web/skins/classic/views/js/frames.js index 3e8517fd9..9732ef1cd 100644 --- a/web/skins/classic/views/js/frames.js +++ b/web/skins/classic/views/js/frames.js @@ -3,6 +3,7 @@ var table = $j('#framesTable'); // Called by bootstrap-table to retrieve zm frame data function ajaxRequest(params) { + if(document.visibilityState == 'hidden') return; if ( params.data && params.data.filter ) { params.data.advsearch = params.data.filter; delete params.data.filter; diff --git a/web/skins/classic/views/js/reports.js b/web/skins/classic/views/js/reports.js index 900f059a7..debb25b21 100644 --- a/web/skins/classic/views/js/reports.js +++ b/web/skins/classic/views/js/reports.js @@ -31,6 +31,7 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { + if(document.visibilityState == 'hidden') return; if (params.data && params.data.filter) { params.data.advsearch = params.data.filter; delete params.data.filter; diff --git a/web/skins/classic/views/js/snapshots.js b/web/skins/classic/views/js/snapshots.js index bdba87db6..4913e31dc 100644 --- a/web/skins/classic/views/js/snapshots.js +++ b/web/skins/classic/views/js/snapshots.js @@ -31,6 +31,7 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { + if(document.visibilityState == 'hidden') return; if (ajax) ajax.abort(); if ( params.data && params.data.filter ) { diff --git a/web/skins/classic/views/js/watch.js b/web/skins/classic/views/js/watch.js index c6262237c..c9c15bf1a 100644 --- a/web/skins/classic/views/js/watch.js +++ b/web/skins/classic/views/js/watch.js @@ -58,6 +58,7 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { + if(document.visibilityState == 'hidden') return; // Maintain legacy behavior by statically setting these parameters const data = params.data; data.order = 'desc'; From ad47f667fe50666f407f242bbb5d87bc01cfde5a Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 20:53:07 +0300 Subject: [PATCH 053/168] Added a missing space. --- web/skins/classic/views/js/console.js | 2 +- web/skins/classic/views/js/events.js | 2 +- web/skins/classic/views/js/frames.js | 2 +- web/skins/classic/views/js/reports.js | 2 +- web/skins/classic/views/js/snapshots.js | 2 +- web/skins/classic/views/js/watch.js | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/skins/classic/views/js/console.js b/web/skins/classic/views/js/console.js index 94fb675a5..32333cd01 100644 --- a/web/skins/classic/views/js/console.js +++ b/web/skins/classic/views/js/console.js @@ -74,7 +74,7 @@ function updateFooter(footer) { // Called by bootstrap-table to retrieve monitor data function ajaxRequest(params) { - if(document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') return; if (ajax) ajax.abort(); // Get filter selections from the form and add to params.data diff --git a/web/skins/classic/views/js/events.js b/web/skins/classic/views/js/events.js index bc262afce..c7fb9dc26 100644 --- a/web/skins/classic/views/js/events.js +++ b/web/skins/classic/views/js/events.js @@ -38,7 +38,7 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { - if(document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') return; if (params.data && params.data.filter) { params.data.advsearch = params.data.filter; delete params.data.filter; diff --git a/web/skins/classic/views/js/frames.js b/web/skins/classic/views/js/frames.js index 9732ef1cd..cad0cd9e5 100644 --- a/web/skins/classic/views/js/frames.js +++ b/web/skins/classic/views/js/frames.js @@ -3,7 +3,7 @@ var table = $j('#framesTable'); // Called by bootstrap-table to retrieve zm frame data function ajaxRequest(params) { - if(document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') return; if ( params.data && params.data.filter ) { params.data.advsearch = params.data.filter; delete params.data.filter; diff --git a/web/skins/classic/views/js/reports.js b/web/skins/classic/views/js/reports.js index debb25b21..6b7c4ed37 100644 --- a/web/skins/classic/views/js/reports.js +++ b/web/skins/classic/views/js/reports.js @@ -31,7 +31,7 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { - if(document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') return; if (params.data && params.data.filter) { params.data.advsearch = params.data.filter; delete params.data.filter; diff --git a/web/skins/classic/views/js/snapshots.js b/web/skins/classic/views/js/snapshots.js index 4913e31dc..4312152a1 100644 --- a/web/skins/classic/views/js/snapshots.js +++ b/web/skins/classic/views/js/snapshots.js @@ -31,7 +31,7 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { - if(document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') return; if (ajax) ajax.abort(); if ( params.data && params.data.filter ) { diff --git a/web/skins/classic/views/js/watch.js b/web/skins/classic/views/js/watch.js index c9c15bf1a..28d0880a8 100644 --- a/web/skins/classic/views/js/watch.js +++ b/web/skins/classic/views/js/watch.js @@ -58,7 +58,7 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { - if(document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') return; // Maintain legacy behavior by statically setting these parameters const data = params.data; data.order = 'desc'; From dcda3c952dfa4c53011093838075912f86f5dd96 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 23:05:12 +0300 Subject: [PATCH 054/168] Preventative loop protection ZM_WEB_REFRESH_LOGS=0 (log.php) --- web/skins/classic/views/log.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/skins/classic/views/log.php b/web/skins/classic/views/log.php index 141e88b2d..9e9c93e55 100644 --- a/web/skins/classic/views/log.php +++ b/web/skins/classic/views/log.php @@ -145,10 +145,10 @@ echo ''; data-maintain-meta-data="true" data-buttons-class="btn btn-normal" data-show-jump-to="true" - data-auto-refresh="" + data-auto-refresh="" data-auto-refresh-silent="true" data-show-refresh="true" - data-auto-refresh-interval="" + data-click-to-select="true" From 8dcb424f9f9e01386915153b61e87170e8d99446 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 11 May 2026 23:33:27 +0300 Subject: [PATCH 055/168] Fix: Save the selected "All" value in the "zmLogComponent" session (log.php) Previously, there was an issue where selecting "All" didn't change the session value because "All"="" --- web/ajax/log.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/ajax/log.php b/web/ajax/log.php index 117cc82d9..46cb1eeec 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -152,6 +152,10 @@ function queryRequest() { zm_session_start(); $_SESSION['zmLogComponent'] = $_REQUEST['Component']; session_write_close(); + } else { + zm_session_start(); + $_SESSION['zmLogComponent'] = ''; + session_write_close(); } if (!empty($_REQUEST['ServerId'])) { if ($where) $where .= ' AND '; From 9543fb23a59e4969b39068665c7da2655fe98ba7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 00:52:46 +0000 Subject: [PATCH 056/168] build(deps): bump actions/dependency-review-action from 4 to 5 Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4 to 5. - [Release notes](https://github.com/actions/dependency-review-action/releases) - [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/dependency-review-action dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/depsreview.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/depsreview.yaml b/.github/workflows/depsreview.yaml index ae5ae5989..02467c39a 100644 --- a/.github/workflows/depsreview.yaml +++ b/.github/workflows/depsreview.yaml @@ -11,4 +11,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@v6 - name: 'Dependency Review' - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@v5 From 06f1178a99f6985c9b9c625c3812a576e993de0e Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Tue, 12 May 2026 11:51:01 +0300 Subject: [PATCH 057/168] Cast ZM_WEB_REFRESH_LOGS to INT before comparison (log.php) --- web/skins/classic/views/log.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/skins/classic/views/log.php b/web/skins/classic/views/log.php index 9e9c93e55..079925a33 100644 --- a/web/skins/classic/views/log.php +++ b/web/skins/classic/views/log.php @@ -145,10 +145,10 @@ echo ''; data-maintain-meta-data="true" data-buttons-class="btn btn-normal" data-show-jump-to="true" - data-auto-refresh="" + data-auto-refresh="" data-auto-refresh-silent="true" data-show-refresh="true" - + data-click-to-select="true" From fe18b762fcf19db5ab4cb26a02531da89b61660a Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Tue, 12 May 2026 12:11:44 +0300 Subject: [PATCH 058/168] =?UTF-8?q?Fix:=20Yesli=20v=20sessii=20byl=20sokhr?= =?UTF-8?q?aneno=20znacheniye=20"Components",=20yavlyayushcheyesya=20pervy?= =?UTF-8?q?m=20elementom=20massiva=20"$components",=20to=20ono=20ignorirov?= =?UTF-8?q?alos'=20pri=20formirovanii=20"$selected=5Fcomponent"=20(log.php?= =?UTF-8?q?)=20Privesti=20ZM=5FWEB=5FREFRESH=5FLOGS=20k=20tipu=20INT=20per?= =?UTF-8?q?ed=20sravneniyem=20(log.php)=20YA=20ne=20ponimayu,=20pochemu=20?= =?UTF-8?q?tip=20znacheniya=20`ZM=5FWEB=5FREFRESH=5FLOGS`=20stanovitsya=20?= =?UTF-8?q?str,=20a=20ne=20int.=20Ved'=20`ZM=5FWEB=5FREFRESH=5FLOGS`=20pol?= =?UTF-8?q?uchayetsya=20iz=20`ZM=5FWEB=5F*=5FREFRESH=5FLOGS`,=20kotoryye?= =?UTF-8?q?=20v=20baze=20dannykh=20khranyatsya=20kak=20integer.=20YA=20vne?= =?UTF-8?q?s=20ispravleniya=20v=20kod=20https://github.com/ZoneMinder/zone?= =?UTF-8?q?minder/pull/4819/changes/06f1178a99f6985c9b9c625c3812a576e993de?= =?UTF-8?q?0e,=20no=20ya=20khochu=20razobrat'sya,=20pochemu=20i=20gde=20pr?= =?UTF-8?q?oiskhodit=20zamena=20tipov.=20Vot=20zapros=20i=20rezul'taty=20z?= =?UTF-8?q?amera.=20Pervyy=20zamer=20-=20obychnyy=20indeks.=20Vtoroy=20zam?= =?UTF-8?q?er=20pokryvayushchiy=20indeks=20Raznitsa=20v=20skorosti=20vypol?= =?UTF-8?q?neniya=20boleye=2010=20raz=20i=20eto=20pri=20smeshchenii=20vseg?= =?UTF-8?q?o=20250000=20Dobavil=20podderzhku=20konstanty=20ZM=5FWEB=5FREFR?= =?UTF-8?q?ESH=5FLOGS=20(log.php)=20Feat:=20Dobavil=20nastroyku=20dlya=20o?= =?UTF-8?q?bnovleniya=20okna=20Log=20page=20(ConfigData.pm.in)=20Dobavil?= =?UTF-8?q?=20konstantu=20ZM=5FWEB=5FREFRESH=5FLOGS=20(config.php)=20Isaac?= =?UTF-8?q?,=20tebya=20chto-to=20ne=20ustraivayet=3F=20Ozhidayu=20tvoi=20k?= =?UTF-8?q?ommentarii,=20zamechaniya.=20Schitayu=20dannyy=20PR=20zakonchen?= =?UTF-8?q?nym=20Poprosi=20pozhaluysta=20Copilot=20proverit'=20dannyy=20PR?= =?UTF-8?q?=20Dobavit'=20v=20pervichnyy=20indeks=20Component,=20TimeKey,?= =?UTF-8?q?=20Level=20dlya=20uvelicheniya=20skorosti=20obrabotki=20zaproso?= =?UTF-8?q?v=20Ispol'zovaniye=20JOIN=20podzaprosa=20pozvolyayet=20uskorit'?= =?UTF-8?q?=20vypolneniye=20osnovnogo=20zaprosa=20v=2010-20=20raz=20bystre?= =?UTF-8?q?ye.=20Tak-zhe=20sleduyet=20proizvodit'=20otbor=20po=20stolbtsu?= =?UTF-8?q?=20'Level',=20a=20ne=20'Code',=20t.k.=20imenno=20'Level'=20u=20?= =?UTF-8?q?nas=20indeksiruyetsya=20Optimizatsiya=20medlennykh=20SQL=20zapr?= =?UTF-8?q?osov=20pri=20bol'shoy=20tablitse=20Optimizirovannyy=20zapros=20?= =?UTF-8?q?Dlya=20optimizitsii=20zaprosov=20s=20ispol'zovaniye=20podzapros?= =?UTF-8?q?a=20Ne=20posylat'=20AJAX=20zaprosy=20obnovleniya=20tablitsy,=20?= =?UTF-8?q?yesli=20stranitsa=20skryta=20(log.js)=20Eto=20znachitel'no=20sn?= =?UTF-8?q?izit=20nagruzku=20na=20server.=20T.k.=20seychas,=20yesli=20otkr?= =?UTF-8?q?yt'=20mnogo=20Log=20page=20i=20svernut'=20ikh,=20to=20zaprosy?= =?UTF-8?q?=20postoyanno=20postupayut=20na=20server!=20Da,=20kak=20ya=20i?= =?UTF-8?q?=20dumal,=20rasshireniye=20mozhet=20byt'=20mp4=20or=20mkv=20or?= =?UTF-8?q?=20webm=20(kak=20dlya=20zakonchennogo=20fayla,=20tak=20i=20dlya?= =?UTF-8?q?=20incomplete),=20po=20etomu=20ya=20izmenil=20shablon=20dlya=20?= =?UTF-8?q?poiska.=20YA=20ne=20stal=20perechislyat'=20vse=20vozmozhnyye=20?= =?UTF-8?q?rasshireniya,=20t.k.=20v=20budushchem=20oni=20mogut=20izmenit's?= =?UTF-8?q?ya.=20Izmenen=20shablon=20okhvatyvayushchiy=20fayly=20s=20lyuby?= =?UTF-8?q?m=20rasshireniyem,=20t.k.=20ono=20mozhet=20byt'=20mp4=20or=20mk?= =?UTF-8?q?v=20or=20webm=20(functions.php)=20Dopolnitel'naya=20proveka=20s?= =?UTF-8?q?ushchestvovaniya=20fayla=20Chto=20by=20my=20ne=20vernuli=20ssyl?= =?UTF-8?q?ku=20na=20nesushchestvuyushchiy=20fayl=20Khotya....=20Fayl=20ve?= =?UTF-8?q?d'=20imeyet=20takoye=20imya:=20`incomplete.h264.mp4`,=20t.ye.?= =?UTF-8?q?=20shablon=20`/incomplete.*.mp4`=20yavlyayetsya=20korrektnym.?= =?UTF-8?q?=20Vozmozhno=20sleduyet=20ispol'zovat'=20shablon=20`/incomplete?= =?UTF-8?q?*.*`=3F=20@connortechnology=20Chto=20ty=20dumayesh'=20naschet?= =?UTF-8?q?=20etogo=3F=20Eto=20ne=20moya=20oshibka,=20ona=20poyavilas'=20v?= =?UTF-8?q?=20kode=20iz=20za=20kommita=20https://github.com/ZoneMinder/zon?= =?UTF-8?q?eminder/commit/3f8bc81760071f49bdce894c664cd507820dcfaa=20Pri?= =?UTF-8?q?=20kopirovanii=20koda=20ya=20ne=20proveril=20i=20eto=20plokho?= =?UTF-8?q?=20s=20moyey=20storony.=20Zdes'=20ya=20ispravlyu,=20no=20tak-zh?= =?UTF-8?q?e=20neobkhodimo=20ispravit'=20i=20v=20`web/views/view=5Fvideo.p?= =?UTF-8?q?hp=E2=80=8E=20`=20Obrabatyvat'=20$Event->DefaultVideo()=20vsegd?= =?UTF-8?q?a=20kak=20stroku=20Vozmozhno=20eto=20izlishnyaya=20proverka,=20?= =?UTF-8?q?no=20pust'=20budet=20tak...=20YA=20reshil=20proanalizirovat',?= =?UTF-8?q?=20iz=20za=20chego=20poyavlyayutsya=20tormoza=20v=20SQL=20i=20u?= =?UTF-8?q?zhasnoye=20potrebleniye=20pamyati.=20YA=20vklyuchil=20logirovan?= =?UTF-8?q?iye=20medlennykh=20zaprosov=20i=20uzhasnulsya....=20YA=20nakhod?= =?UTF-8?q?ilsya=20na=203y=20stranitse=20prosmotra=20logov=20(po=2025=20st?= =?UTF-8?q?rok=20na=20stranitsu).=20YA=20pereshel=20na=2075823=20stranitsu?= =?UTF-8?q?=20(da,=20u=20menya=201674290=20strok=20)=20Pochemu-to=20ne=20v?= =?UTF-8?q?erno=20rasschityvayetsya=20kol-vo=20stranits,=20no=20s=20etim?= =?UTF-8?q?=20pozzhe=20mozhno=20razobrat'sya....=20I=20vot=20chto=20ya=20u?= =?UTF-8?q?videl=20v=20medlennykh=20zaprosakh:=20U=20menya=20yest'=20mysl'?= =?UTF-8?q?,=20kak=20uskorit'=20rabotu=20SQL=20s=20bol'shimi=20tablitsami.?= =?UTF-8?q?=20YA=20poprobuyu.=20Put'=20k=20faylu=20dlya=20sobytiya=20KHKHK?= =?UTF-8?q?H=20ne=20nayden=20Ne=20naydeno=20ni=20odnogo=20fayla=20sobytiya?= =?UTF-8?q?=20dlya=20eksporta=20sobytiy=20monitora=20s=20ID=3D18.=20Dobavi?= =?UTF-8?q?l=20proverki=203=20462=20Fix:=20If=20the=20"Components"=20value?= =?UTF-8?q?,=20which=20is=20the=20first=20element=20of=20the=20"$component?= =?UTF-8?q?s"=20array,=20was=20stored=20in=20the=20session,=20it=20was=20i?= =?UTF-8?q?gnored=20when=20generating=20"$selected=5Fcomponent"=20(log.php?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/skins/classic/views/log.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/log.php b/web/skins/classic/views/log.php index 72743762e..052a04366 100644 --- a/web/skins/classic/views/log.php +++ b/web/skins/classic/views/log.php @@ -70,7 +70,7 @@ $options = [''=>translate('All')] + array_combine($components, $components); ZM\Debug(print_r($options, true)); $selected_component = ''; if (isset($_SESSION['zmLogComponent'])) { - if (array_search($_SESSION['zmLogComponent'], $components)) { + if (array_search($_SESSION['zmLogComponent'], $components) !== -1) { $selected_component = $_SESSION['zmLogComponent']; } else { unset($_SESSION['zmLogComponent']); From 3c94068f04b29b591ca981d2b4bc9c3f3fb1f0dd Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Tue, 12 May 2026 12:17:28 +0300 Subject: [PATCH 059/168] Update log.php --- web/ajax/log.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index 46cb1eeec..fe0f7f3f0 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -149,14 +149,11 @@ function queryRequest() { if ($where) $where .= ' AND '; $where .= 'Component = ?'; $query['values'][] = $_REQUEST['Component']; - zm_session_start(); - $_SESSION['zmLogComponent'] = $_REQUEST['Component']; - session_write_close(); - } else { - zm_session_start(); - $_SESSION['zmLogComponent'] = ''; - session_write_close(); } + zm_session_start(); + $_SESSION['zmLogComponent'] = !empty($_REQUEST['Component']) ? $_REQUEST['Component'] : ''; + session_write_close(); + if (!empty($_REQUEST['ServerId'])) { if ($where) $where .= ' AND '; $where .= 'ServerId = ?'; From 0506103aaa48fbf2048f0f0d394c75b955b46d8d Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Tue, 12 May 2026 08:49:01 -0400 Subject: [PATCH 060/168] fix: QP-encode plain-text email body so URLs survive transit The non-HTML branch of sendTheEmail() declared Content-Transfer-Encoding as quoted-printable but passed the body to MIME::Lite unencoded. Mail clients then QP-decoded literal '=NN' digit pairs in URLs, eating characters from substitution tags like %EP%, %EPS%, %EPI%. For example &eid=1947908 was decoded as &eid<0x19>47908 and rendered as eid47908. Match the HTML branch's pattern by encoding the body via MIME::QuotedPrint::encode_qp before attaching. fixes #4822 --- scripts/zmfilter.pl.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/zmfilter.pl.in b/scripts/zmfilter.pl.in index 39e5e29dd..ad6eb072e 100644 --- a/scripts/zmfilter.pl.in +++ b/scripts/zmfilter.pl.in @@ -996,7 +996,7 @@ sub sendTheEmail { } else { my $text = MIME::Lite->new( 'Content-Transfer-Encoding' => 'quoted-printable', - Type => 'TEXT', Data => $body + Type => 'TEXT', Data => MIME::QuotedPrint::encode_qp($body) ); $related->attach($text); } # end if html or not From 94a97dcd993ac176fbc3d021bef4d0988fad06e7 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Tue, 12 May 2026 19:51:50 +0300 Subject: [PATCH 061/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/ajax/log.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index 9a2de8351..d8a4234f2 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -185,7 +185,8 @@ function queryRequest() { } if ($where) $where = ' WHERE '.$where; - $data['total'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'` '.$where , 'Total', $query['values']); + $data['total'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'` '.$where, 'Total', $query['values']); + $data['totalNotFiltered'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'`', 'Total'); $query['sql'] = 'SELECT ' .$col_str. ' FROM `' .$table. '` ' .$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ?'; array_push($query['values'], $offset, $limit); From e29e41664ad8ac533b2556cfa5eb1af86c4764fb Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Tue, 12 May 2026 20:12:18 +0300 Subject: [PATCH 062/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- db/zm_update-1.39.10.sql | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/db/zm_update-1.39.10.sql b/db/zm_update-1.39.10.sql index e5299e4cc..a1081c54c 100644 --- a/db/zm_update-1.39.10.sql +++ b/db/zm_update-1.39.10.sql @@ -1,6 +1,7 @@ -- -- This updates a 1.39.9 database to 1.39.10 -- --- Add Component, TimeKey, and Level to the primary index to increase query processing speed. +-- Add a composite secondary index to increase query processing speed +-- without rebuilding the table by changing the primary key. -- -ALTER TABLE `Logs` DROP PRIMARY KEY, ADD PRIMARY KEY (`Id`, `Component`, `TimeKey`, `Level`); +ALTER TABLE `Logs` ADD INDEX `idx_logs_id_component_timekey_level` (`Id`, `Component`, `TimeKey`, `Level`); From 148313c3895497cbcefd53be5526353c79b04652 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Tue, 12 May 2026 20:27:39 +0300 Subject: [PATCH 063/168] Added composite secondary index (zm_create.sql.in) --- db/zm_create.sql.in | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/db/zm_create.sql.in b/db/zm_create.sql.in index edc9c4c53..b40d7da76 100644 --- a/db/zm_create.sql.in +++ b/db/zm_create.sql.in @@ -523,13 +523,14 @@ CREATE TABLE `Logs` ( `Message` text NOT NULL, `File` varchar(255) DEFAULT NULL, `Line` smallint(5) unsigned DEFAULT NULL, - PRIMARY KEY (`Id`, `Component`, `TimeKey`, `Level`), + PRIMARY KEY (`Id`), KEY `TimeKey` (`TimeKey`) ) ENGINE=@ZM_MYSQL_ENGINE@; CREATE INDEX `Logs_TimeKey_idx` ON `Logs` (`TimeKey`); CREATE INDEX `Logs_Level_idx` ON `Logs` (`Level`); CREATE INDEX `Logs_Component_idx` ON `Logs` (`Component`); +CREATE INDEX `idx_logs_id_component_timekey_level` ON `Logs` (`Id`, `Component`, `TimeKey`, `Level`); -- -- Table structure for table `Manufacturers` From 0333a8635922061498ef9b2377ea80a4db868bce Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Tue, 12 May 2026 20:39:05 +0300 Subject: [PATCH 064/168] Server -> ServerId mapping for sorting in a SQL table (log.php) --- web/ajax/log.php | 1 + 1 file changed, 1 insertion(+) diff --git a/web/ajax/log.php b/web/ajax/log.php index e19272965..58aeb3ce7 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -103,6 +103,7 @@ function queryRequest() { if (isset($_REQUEST['sort'])) { $sort = $_REQUEST['sort']; if ($sort == 'DateTime') $sort = 'TimeKey'; + if ($sort == 'Server') $sort = 'ServerId'; } if (!in_array($sort, array_merge($columns, $col_alt))) { ZM\Error('Invalid sort field: ' . $sort); From ce0a12814658fe9e4dfde805610fc951133201f7 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 13 May 2026 10:06:30 +0300 Subject: [PATCH 065/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/ajax/log.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index 7ca5418fd..fae2fb42f 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -186,8 +186,12 @@ function queryRequest() { } if ($where) $where = ' WHERE '.$where; - $data['total'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'` '.$where, 'Total', $query['values']); $data['totalNotFiltered'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'`', 'Total'); + if ($where) { + $data['total'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'` '.$where, 'Total', $query['values']); + } else { + $data['total'] = $data['totalNotFiltered']; + } $query['sql'] = 'SELECT ' .$col_str. ' FROM `' .$table. '` ' .$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ?'; array_push($query['values'], $offset, $limit); From 8d3615a4afa1c61d47e95ed83a9db1b5168cb0fa Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 13 May 2026 10:22:33 +0300 Subject: [PATCH 066/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/includes/functions.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/includes/functions.php b/web/includes/functions.php index 3220d58d7..ec3b0d4bc 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -2519,8 +2519,10 @@ function findVideoEventFile ($Event, $ext="*") { if ($path === '') { # By default, we search for files with any extension, such as mp4, mkv, or webm. - # Look for the final renamed first, then incomplete + # Look for the final renamed first, then incomplete. + # Incomplete files may exist as either incomplete. or incomplete... $candidates = glob($dir.'/'.$Event->Id().'-video.*.'.$ext); + if (!$candidates) $candidates = glob($dir.'/incomplete.'.$ext); if (!$candidates) $candidates = glob($dir.'/incomplete.*.'.$ext); if ($candidates) { $path = $candidates[0]; From f7b21d392344eaf6233dc5ae6012228d4e14c6e7 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 13 May 2026 10:29:50 +0300 Subject: [PATCH 067/168] Return false instead of return "" (download_functions.php) --- web/includes/download_functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/includes/download_functions.php b/web/includes/download_functions.php index acb4b16be..f5b8ff6b6 100644 --- a/web/includes/download_functions.php +++ b/web/includes/download_functions.php @@ -169,7 +169,7 @@ function downloadEvents( if (count($exportFileList) === 0) { ZM\Warning('No events were found for export.'); - return ""; + return false; } generateFileList($exportFormat, $exportStructure, $archive_path, $exportCompressed, $export_dir, $export_root, $exportFileList); From ee655c2e64682704d1fe3a5c567d7035a6a3eee0 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 13 May 2026 11:52:34 +0300 Subject: [PATCH 068/168] Set $maxTime to the current time only if $endSecs is missing in the incomplete event or if $maxTime is empty after looping through all events. (download_functions.php) --- web/includes/download_functions.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/includes/download_functions.php b/web/includes/download_functions.php index f5b8ff6b6..c02174846 100644 --- a/web/includes/download_functions.php +++ b/web/includes/download_functions.php @@ -122,7 +122,7 @@ function downloadEvents( } $fileName = basename($filePath); - if (strpos($fileName, 'incomplete') !== false) $maxTime = date('Y-m-d H:i:s'); # Probably incomplete event. + if (strpos($fileName, 'incomplete') !== false && !$endSecs) $maxTime = date('Y-m-d H:i:s'); # Probably incomplete event. $eventFileList .= 'file \''.$event->Path().'/'.$fileName.'\''.PHP_EOL; } @@ -131,6 +131,7 @@ function downloadEvents( continue; } + if (!$maxTime) $maxTime = date('Y-m-d H:i:s'); # For example, we download a single non-incomlete event, but it's missing EndDateTimeSecs() due to a crash $mergedFileName = $monitor->Name().' '.$minTime.' to '.$maxTime.'.mp4'; if (($fp = fopen('event_files.txt', 'w'))) { fwrite($fp, $eventFileList); From c0d55a63bb34ab0749831effb65f14ca7ecf41ad Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 13 May 2026 12:16:27 +0300 Subject: [PATCH 069/168] When the page is hidden, execute hideLoading() for the bootstrap table, then execute an empty return instead of executing the AJAX request. --- web/skins/classic/views/js/console.js | 5 ++++- web/skins/classic/views/js/events.js | 5 ++++- web/skins/classic/views/js/frames.js | 5 ++++- web/skins/classic/views/js/log.js | 5 ++++- web/skins/classic/views/js/reports.js | 5 ++++- web/skins/classic/views/js/snapshots.js | 5 ++++- web/skins/classic/views/js/watch.js | 5 ++++- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/web/skins/classic/views/js/console.js b/web/skins/classic/views/js/console.js index 32333cd01..8f21cc31f 100644 --- a/web/skins/classic/views/js/console.js +++ b/web/skins/classic/views/js/console.js @@ -74,7 +74,10 @@ function updateFooter(footer) { // Called by bootstrap-table to retrieve monitor data function ajaxRequest(params) { - if (document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') { + table.bootstrapTable('hideLoading'); + return; + } if (ajax) ajax.abort(); // Get filter selections from the form and add to params.data diff --git a/web/skins/classic/views/js/events.js b/web/skins/classic/views/js/events.js index c7fb9dc26..2ba102f8e 100644 --- a/web/skins/classic/views/js/events.js +++ b/web/skins/classic/views/js/events.js @@ -38,7 +38,10 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { - if (document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') { + table.bootstrapTable('hideLoading'); + return; + } if (params.data && params.data.filter) { params.data.advsearch = params.data.filter; delete params.data.filter; diff --git a/web/skins/classic/views/js/frames.js b/web/skins/classic/views/js/frames.js index cad0cd9e5..914984e07 100644 --- a/web/skins/classic/views/js/frames.js +++ b/web/skins/classic/views/js/frames.js @@ -3,7 +3,10 @@ var table = $j('#framesTable'); // Called by bootstrap-table to retrieve zm frame data function ajaxRequest(params) { - if (document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') { + table.bootstrapTable('hideLoading'); + return; + } if ( params.data && params.data.filter ) { params.data.advsearch = params.data.filter; delete params.data.filter; diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index a35d315a1..7f3880ed5 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -28,7 +28,10 @@ var params = // Called by bootstrap-table to retrieve zm log data function ajaxRequest(params) { - if (document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') { + table.bootstrapTable('hideLoading'); + return; + } if ($j('#filterServerId').val()) { params.data.ServerId = $j('#filterServerId').val(); } diff --git a/web/skins/classic/views/js/reports.js b/web/skins/classic/views/js/reports.js index 6b7c4ed37..56c0b74da 100644 --- a/web/skins/classic/views/js/reports.js +++ b/web/skins/classic/views/js/reports.js @@ -31,7 +31,10 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { - if (document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') { + table.bootstrapTable('hideLoading'); + return; + } if (params.data && params.data.filter) { params.data.advsearch = params.data.filter; delete params.data.filter; diff --git a/web/skins/classic/views/js/snapshots.js b/web/skins/classic/views/js/snapshots.js index 4312152a1..f729f64c5 100644 --- a/web/skins/classic/views/js/snapshots.js +++ b/web/skins/classic/views/js/snapshots.js @@ -31,7 +31,10 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { - if (document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') { + table.bootstrapTable('hideLoading'); + return; + } if (ajax) ajax.abort(); if ( params.data && params.data.filter ) { diff --git a/web/skins/classic/views/js/watch.js b/web/skins/classic/views/js/watch.js index 28d0880a8..5c10aece6 100644 --- a/web/skins/classic/views/js/watch.js +++ b/web/skins/classic/views/js/watch.js @@ -58,7 +58,10 @@ var params = // Called by bootstrap-table to retrieve zm event data function ajaxRequest(params) { - if (document.visibilityState == 'hidden') return; + if (document.visibilityState == 'hidden') { + eventListTable.bootstrapTable('hideLoading'); + return; + } // Maintain legacy behavior by statically setting these parameters const data = params.data; data.order = 'desc'; From a5114da21bc504aa9d6146c20c51da4b2f0adf7f Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 13 May 2026 12:46:42 +0300 Subject: [PATCH 070/168] The column order in the component index has been changed, and Logs_Component_idx has been removed, as it's now redundant. --- db/zm_create.sql.in | 3 +-- db/zm_update-1.39.10.sql | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/db/zm_create.sql.in b/db/zm_create.sql.in index b40d7da76..d3721d8b0 100644 --- a/db/zm_create.sql.in +++ b/db/zm_create.sql.in @@ -529,8 +529,7 @@ CREATE TABLE `Logs` ( CREATE INDEX `Logs_TimeKey_idx` ON `Logs` (`TimeKey`); CREATE INDEX `Logs_Level_idx` ON `Logs` (`Level`); -CREATE INDEX `Logs_Component_idx` ON `Logs` (`Component`); -CREATE INDEX `idx_logs_id_component_timekey_level` ON `Logs` (`Id`, `Component`, `TimeKey`, `Level`); +CREATE INDEX `Logs_Component_Level_TimeKey_Id_idx` ON `Logs` (`Component`, `Level`, `TimeKey`, `Id`); -- -- Table structure for table `Manufacturers` diff --git a/db/zm_update-1.39.10.sql b/db/zm_update-1.39.10.sql index a1081c54c..12d54abb7 100644 --- a/db/zm_update-1.39.10.sql +++ b/db/zm_update-1.39.10.sql @@ -3,5 +3,7 @@ -- -- Add a composite secondary index to increase query processing speed -- without rebuilding the table by changing the primary key. +-- Removing Logs_Component_idx because it's now redundant. -- -ALTER TABLE `Logs` ADD INDEX `idx_logs_id_component_timekey_level` (`Id`, `Component`, `TimeKey`, `Level`); +ALTER TABLE `Logs` ADD INDEX `Logs_Component_Level_TimeKey_Id_idx` (`Component`, `Level`, `TimeKey`, `Id`); +ALTER TABLE `Logs` DROP INDEX `Logs_Component_idx`; From e1eb7876c601f2ebd720efc65b80c8b13c801ba3 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 11:38:42 -0400 Subject: [PATCH 071/168] fix: clamp percentage polygon to width-1/height-1 ParsePercentagePolygon clamped to [0, width] / [0, height], so a new zone created at 100% produced pixel coords equal to the monitor dimensions. The rasterizer requires hi_x < width and hi_y < height, so the runtime then logged warnings like "polygon hi_x (1920) >= image width (1920), clamping". Clamp to width-1 / height-1 instead. Updates existing tests that encoded the previous behavior. --- src/zm_zone.cpp | 7 ++++--- tests/zm_zone.cpp | 40 ++++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/zm_zone.cpp b/src/zm_zone.cpp index 47e390030..ca810b75e 100644 --- a/src/zm_zone.cpp +++ b/src/zm_zone.cpp @@ -862,9 +862,10 @@ bool Zone::ParsePercentagePolygon(const char *poly_string, unsigned int width, u int32 px_x = static_cast(std::lround(pct_x * mon_w / 100.0)); int32 px_y = static_cast(std::lround(pct_y * mon_h / 100.0)); - // Clamp to monitor bounds - px_x = std::clamp(px_x, static_cast(0), static_cast(width)); - px_y = std::clamp(px_y, static_cast(0), static_cast(height)); + // Clamp to monitor bounds. Max valid pixel index is width-1/height-1; + // values equal to width/height cause out-of-bounds warnings in the rasterizer. + px_x = std::clamp(px_x, static_cast(0), static_cast(width) - 1); + px_y = std::clamp(px_y, static_cast(0), static_cast(height) - 1); Debug(3, "Percentage coord %.2f,%.2f -> pixel %d,%d", pct_x, pct_y, px_x, px_y); vertices.emplace_back(px_x, px_y); diff --git a/tests/zm_zone.cpp b/tests/zm_zone.cpp index a56d0cd45..42d6375e1 100644 --- a/tests/zm_zone.cpp +++ b/tests/zm_zone.cpp @@ -28,9 +28,9 @@ TEST_CASE("Zone::ParsePercentagePolygon: full-frame zone at 1920x1080", "[Zone]" REQUIRE(result == true); REQUIRE(polygon.GetVertices().size() == 4); REQUIRE(polygon.GetVertices()[0] == Vector2(0, 0)); - REQUIRE(polygon.GetVertices()[1] == Vector2(1920, 0)); - REQUIRE(polygon.GetVertices()[2] == Vector2(1920, 1080)); - REQUIRE(polygon.GetVertices()[3] == Vector2(0, 1080)); + REQUIRE(polygon.GetVertices()[1] == Vector2(1919, 0)); + REQUIRE(polygon.GetVertices()[2] == Vector2(1919, 1079)); + REQUIRE(polygon.GetVertices()[3] == Vector2(0, 1079)); } TEST_CASE("Zone::ParsePercentagePolygon: center 50% zone", "[Zone]") { @@ -68,8 +68,8 @@ TEST_CASE("Zone::ParsePercentagePolygon: different resolution", "[Zone]") { 640, 480, polygon); REQUIRE(result == true); - REQUIRE(polygon.GetVertices()[1] == Vector2(640, 0)); - REQUIRE(polygon.GetVertices()[2] == Vector2(640, 480)); + REQUIRE(polygon.GetVertices()[1] == Vector2(639, 0)); + REQUIRE(polygon.GetVertices()[2] == Vector2(639, 479)); } TEST_CASE("Zone::ParsePercentagePolygon: clamping beyond 100%", "[Zone]") { @@ -79,8 +79,8 @@ TEST_CASE("Zone::ParsePercentagePolygon: clamping beyond 100%", "[Zone]") { 1920, 1080, polygon); REQUIRE(result == true); - // 110% should be clamped to monitor width - REQUIRE(polygon.GetVertices()[1].x_ == 1920); + // 110% should be clamped to max valid pixel index (width-1) + REQUIRE(polygon.GetVertices()[1].x_ == 1919); } TEST_CASE("Zone::ParsePercentagePolygon: triangle", "[Zone]") { @@ -114,9 +114,9 @@ TEST_CASE("Zone::ParsePercentagePolygon: integer coords still work", "[Zone]") { REQUIRE(result == true); REQUIRE(polygon.GetVertices()[0] == Vector2(0, 0)); - REQUIRE(polygon.GetVertices()[1] == Vector2(1920, 0)); - REQUIRE(polygon.GetVertices()[2] == Vector2(1920, 1080)); - REQUIRE(polygon.GetVertices()[3] == Vector2(0, 1080)); + REQUIRE(polygon.GetVertices()[1] == Vector2(1919, 0)); + REQUIRE(polygon.GetVertices()[2] == Vector2(1919, 1079)); + REQUIRE(polygon.GetVertices()[3] == Vector2(0, 1079)); } TEST_CASE("Zone::ParsePolygonString: basic pixel parsing", "[Zone]") { @@ -166,9 +166,9 @@ TEST_CASE("Zone::ParsePercentagePolygon: percentage to pixel conversion", "[Zone auto const &verts = polygon.GetVertices(); REQUIRE(verts.size() == 4); REQUIRE(verts[0] == Vector2(0, 0)); - REQUIRE(verts[1] == Vector2(1920, 0)); - REQUIRE(verts[2] == Vector2(1920, 1080)); - REQUIRE(verts[3] == Vector2(0, 1080)); + REQUIRE(verts[1] == Vector2(1919, 0)); + REQUIRE(verts[2] == Vector2(1919, 1079)); + REQUIRE(verts[3] == Vector2(0, 1079)); } SECTION("50% rectangle converts to half-resolution pixels") { @@ -184,16 +184,16 @@ TEST_CASE("Zone::ParsePercentagePolygon: percentage to pixel conversion", "[Zone } SECTION("values are clamped to monitor bounds") { - // 100% should clamp to exact monitor dimensions + // 100% should clamp to max valid pixel index (width-1, height-1) bool ok = Zone::ParsePercentagePolygon("0,0 100,0 100,100 0,100", width, height, polygon); REQUIRE(ok); auto const &verts = polygon.GetVertices(); for (auto const &v : verts) { REQUIRE(v.x_ >= 0); - REQUIRE(v.x_ <= static_cast(width)); + REQUIRE(v.x_ < static_cast(width)); REQUIRE(v.y_ >= 0); - REQUIRE(v.y_ <= static_cast(height)); + REQUIRE(v.y_ < static_cast(height)); } } } @@ -213,12 +213,12 @@ TEST_CASE("Zone: pixel values through ParsePercentagePolygon produce wrong resul auto const &verts = polygon.GetVertices(); REQUIRE(verts.size() == 4); - // 639% of 1920 = 12268.8 -> clamped to 1920 - // 479% of 1080 = 5173.2 -> clamped to 1080 + // 639% of 1920 = 12268.8 -> clamped to width-1 + // 479% of 1080 = 5173.2 -> clamped to height-1 // All non-zero coords get clamped to monitor bounds — the zone is // degenerate (covers the full monitor instead of a sub-region) - REQUIRE(verts[1] == Vector2(static_cast(width), 0)); - REQUIRE(verts[2] == Vector2(static_cast(width), static_cast(height))); + REQUIRE(verts[1] == Vector2(static_cast(width) - 1, 0)); + REQUIRE(verts[2] == Vector2(static_cast(width) - 1, static_cast(height) - 1)); } // --- Auto-detect format tests --- From 027ed7e1c2fd34062f6cb131aecd14060c45e625 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 15:18:34 -0400 Subject: [PATCH 072/168] feat: recalibrate ZonePresets for modern HD resolutions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stock preset MinAlarm/MinFilter/MinBlob values were authored for ~320x240 cameras, where 5% of zone area was a 62x62 motion blob. At 1080p, the same 5% requires a 322x322 blob to trigger — so even the "Fast, high sensitivity" preset rarely fires on a person walking. New scale (% of zone area): low=3, medium~0.5, high=0.1. MaxPixelThreshold (per-pixel grayscale delta, 0-255) is unchanged. Updates zm_create.sql.in for fresh installs and adds a migration that rewrites Ids 1-7 on existing installs. Custom presets (Id > 7) are left alone. Bumps version to 1.39.10. --- db/zm_create.sql.in | 14 +++++++------- db/zm_update-1.39.10.sql | 18 ++++++++++++++++++ version.txt | 2 +- 3 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 db/zm_update-1.39.10.sql diff --git a/db/zm_create.sql.in b/db/zm_create.sql.in index 52e5c4710..eb4ee9d9e 100644 --- a/db/zm_create.sql.in +++ b/db/zm_create.sql.in @@ -1296,13 +1296,13 @@ INSERT INTO MonitorPresets VALUES (NULL,NULL,'Qihan IP, 1920x1080, RTP/RTSP','Ff -- -- Add some zone preset values -- -INSERT INTO ZonePresets VALUES (1,'Default','Active','Percent','Blobs',25,NULL,3,75,3,3,3,75,2,NULL,1,NULL,0,0); -INSERT INTO ZonePresets VALUES (2,'Fast, low sensitivity','Active','Percent','AlarmedPixels',60,NULL,20,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,0); -INSERT INTO ZonePresets VALUES (3,'Fast, medium sensitivity','Active','Percent','AlarmedPixels',40,NULL,10,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,0); -INSERT INTO ZonePresets VALUES (4,'Fast, high sensitivity','Active','Percent','AlarmedPixels',20,NULL,5,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,0); -INSERT INTO ZonePresets VALUES (5,'Best, low sensitivity','Active','Percent','Blobs',60,NULL,36,NULL,7,7,24,NULL,20,NULL,1,NULL,0,0); -INSERT INTO ZonePresets VALUES (6,'Best, medium sensitivity','Active','Percent','Blobs',40,NULL,16,NULL,5,5,12,NULL,10,NULL,1,NULL,0,0); -INSERT INTO ZonePresets VALUES (7,'Best, high sensitivity','Active','Percent','Blobs',20,NULL,8,NULL,3,3,6,NULL,5,NULL,1,NULL,0,0); +INSERT INTO ZonePresets VALUES (1,'Default','Active','Percent','Blobs',25,NULL,0.5,75,3,3,0.35,75,0.3,NULL,1,NULL,0,0); +INSERT INTO ZonePresets VALUES (2,'Fast, low sensitivity','Active','Percent','AlarmedPixels',60,NULL,3,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,0); +INSERT INTO ZonePresets VALUES (3,'Fast, medium sensitivity','Active','Percent','AlarmedPixels',40,NULL,0.5,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,0); +INSERT INTO ZonePresets VALUES (4,'Fast, high sensitivity','Active','Percent','AlarmedPixels',20,NULL,0.1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0,0); +INSERT INTO ZonePresets VALUES (5,'Best, low sensitivity','Active','Percent','Blobs',60,NULL,5,NULL,7,7,3.5,NULL,3,NULL,1,NULL,0,0); +INSERT INTO ZonePresets VALUES (6,'Best, medium sensitivity','Active','Percent','Blobs',40,NULL,1,NULL,5,5,0.7,NULL,0.6,NULL,1,NULL,0,0); +INSERT INTO ZonePresets VALUES (7,'Best, high sensitivity','Active','Percent','Blobs',20,NULL,0.2,NULL,3,3,0.14,NULL,0.12,NULL,1,NULL,0,0); DROP TABLE IF EXISTS Maps; diff --git a/db/zm_update-1.39.10.sql b/db/zm_update-1.39.10.sql new file mode 100644 index 000000000..c86c16d1e --- /dev/null +++ b/db/zm_update-1.39.10.sql @@ -0,0 +1,18 @@ +-- +-- Recalibrate stock ZonePresets for modern HD resolutions. +-- +-- The legacy values (MinAlarmPixels 3-36% of zone area) were authored for +-- ~320x240 analog cameras. At 1080p they require a 322x322-px motion blob +-- to trigger "Fast, high sensitivity" — far too coarse. New scale: low=3%, +-- medium~0.5%, high=0.1%. MaxPixelThreshold (grayscale 0-255) is unchanged. +-- +-- Only updates the 7 stock preset Ids; any user-added presets (Id > 7) +-- are left untouched. + +UPDATE ZonePresets SET MinAlarmPixels=0.5, MaxAlarmPixels=75, MinFilterPixels=0.35, MaxFilterPixels=75, MinBlobPixels=0.3 WHERE Id=1; +UPDATE ZonePresets SET MinAlarmPixels=3 WHERE Id=2; +UPDATE ZonePresets SET MinAlarmPixels=0.5 WHERE Id=3; +UPDATE ZonePresets SET MinAlarmPixels=0.1 WHERE Id=4; +UPDATE ZonePresets SET MinAlarmPixels=5, MinFilterPixels=3.5, MinBlobPixels=3 WHERE Id=5; +UPDATE ZonePresets SET MinAlarmPixels=1, MinFilterPixels=0.7, MinBlobPixels=0.6 WHERE Id=6; +UPDATE ZonePresets SET MinAlarmPixels=0.2, MinFilterPixels=0.14, MinBlobPixels=0.12 WHERE Id=7; diff --git a/version.txt b/version.txt index 2a293729c..104191edb 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.39.9 +1.39.10 From b2100fb4db792adf3bd7f45f256070abe4d02c27 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 15:55:15 -0400 Subject: [PATCH 073/168] fix: remove reflected user input from add_monitors XSS sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal ajaxError() concatenated $_REQUEST['action'] and the session username into the error body. ajaxError() exits via exit(jsonEncode(...)) — Snyk flags any tainted data reaching exit() even when the Content-Type is JSON, since a misconfigured response could render it as HTML. Drop the reflected tokens from the user-facing message; the action is still logged server-side via ZM\\Warning and ajaxError's debug backtrace. --- web/ajax/add_monitors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/ajax/add_monitors.php b/web/ajax/add_monitors.php index 07365e70f..cc5cf8e12 100644 --- a/web/ajax/add_monitors.php +++ b/web/ajax/add_monitors.php @@ -197,5 +197,5 @@ if (canEdit('Monitors')) { } else { ZM\Warning('Cannot edit monitors'); } -ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissions for user ' . $user->Username()); +ajaxError('Unrecognised action or insufficient permissions'); ?> From 0451fdadf7a151758234ba8f7c7e3eabd892b952 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 15:56:05 -0400 Subject: [PATCH 074/168] fix: remove reflected user input from device ajax XSS sinks Both ajaxError() calls in device.php concatenated user-controlled data ($_REQUEST['action'], $user->Username()) into the response body. exit(jsonEncode(...)) is flagged by Snyk regardless of Content-Type. Drop the reflected tokens; still log the action server-side via ZM\\Warning. --- web/ajax/device.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/ajax/device.php b/web/ajax/device.php index 35ca410ec..26fa8e1de 100644 --- a/web/ajax/device.php +++ b/web/ajax/device.php @@ -20,7 +20,7 @@ // Device view actions if ( !canEdit('Devices') ) { - ajaxError('Insufficient permissions for user '.$user->Username()); + ajaxError('Insufficient permissions'); return; } @@ -41,7 +41,8 @@ if ( $action == 'device' ) { } ajaxResponse(); } else { - ajaxError('Unrecognised action '.$_REQUEST['action']); + ZM\Warning('unknown action '.$_REQUEST['action']); + ajaxError('Unrecognised action'); } // end if action ?> From 4163feb93951e593123df122288cb5601e3bd31e Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 15:56:49 -0400 Subject: [PATCH 075/168] fix: remove reflected user input from event ajax XSS sink The catch-all ajaxError() at the bottom of event.php reflected both $_REQUEST['action'] and the session username. Same Snyk pattern as add_monitors.php and device.php. Action is now logged server-side via ZM\\Warning; user-facing message is static. --- web/ajax/event.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/ajax/event.php b/web/ajax/event.php index 6b449f70c..fafca3be9 100644 --- a/web/ajax/event.php +++ b/web/ajax/event.php @@ -227,5 +227,6 @@ if ( canEdit('Events') ) { } // end switch action } // end if canEdit('Events') -ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissions for user '.$user->Username()); +ZM\Warning('unknown action '.$_REQUEST['action']); +ajaxError('Unrecognised action or insufficient permissions'); ?> From 0d197c90350a42d26f3c62930755ce7fff7fc3b7 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 15:59:33 -0400 Subject: [PATCH 076/168] fix: sanitize export filename and connkey at event ajax boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $_REQUEST['exportFile'] and $_REQUEST['connkey'] flowed through exportEvents() into ajaxResponse() (i.e. exit(jsonEncode())). exportEvents() already sanitizes both internally via the same preg_replace, but Snyk can't trace through the function — sanitize at the call site so the taint is closed at the boundary. --- web/ajax/event.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/ajax/event.php b/web/ajax/event.php index fafca3be9..02ace5f67 100644 --- a/web/ajax/event.php +++ b/web/ajax/event.php @@ -89,9 +89,13 @@ if ( canView('Events') or canView('Snapshots') ) { $exportIds = [validCardinal($_REQUEST['id'])]; } + $exportRoot = !empty($_REQUEST['exportFile']) ? preg_replace('/[^\w\-.]/', '', $_REQUEST['exportFile']) : ''; + if (empty($exportRoot)) $exportRoot = 'zmExport'; + $exportConnkey = preg_replace('/[^\w\-.]/', '', isset($_REQUEST['connkey']) ? $_REQUEST['connkey'] : ''); + if ($exportFile = exportEvents( $exportIds, - (isset($_REQUEST['connkey'])?$_REQUEST['connkey']:''), + $exportConnkey, $exportDetail, $exportFrames, $exportImages, @@ -100,7 +104,7 @@ if ( canView('Events') or canView('Snapshots') ) { $exportFormat, $exportCompress, $exportStructure, - (!empty($_REQUEST['exportFile'])?$_REQUEST['exportFile']:'zmExport') + $exportRoot )) { ajaxResponse(array('exportFile'=>$exportFile)); } else { From a272af5539fe7b28d2518b25bc5bd5bf6d05d1c7 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 13 May 2026 23:01:35 +0300 Subject: [PATCH 077/168] Minor optimization of the sidebar footer box (sidebar.css) --- web/skins/classic/css/base/sidebar.css | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/skins/classic/css/base/sidebar.css b/web/skins/classic/css/base/sidebar.css index f65c7e4b0..da216f3f3 100644 --- a/web/skins/classic/css/base/sidebar.css +++ b/web/skins/classic/css/base/sidebar.css @@ -381,17 +381,28 @@ div:not(.chosen-container-active) > .chosen-drop { overflow: hidden; } -.layout-main .sidebar-main .footer-box ul.account-info{ +.layout-main .sidebar-main .footer-box ul.account-info { display: flex; justify-content: center; overflow: hidden; margin-bottom: 5px; } -.layout-main .sidebar-main .footer-box #getAccountCircleHTML{ +.layout-main .sidebar-main .footer-box #getAccountCircleHTML { margin: 0 !important; } #statusSidebar { display: flex; + align-items: center; +} + +#statusSidebar #shutdownButton { + margin-right: 0; +} + +#statusSidebar #stateModalBtn { + white-space: pre-wrap; + word-wrap: break-word; + word-break: break-all; } From 2c50a9936898732539c61502b08c8c289998cae5 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 16:06:36 -0400 Subject: [PATCH 078/168] fix: sanitize download params at event ajax boundary The download action passed $_REQUEST['exportFormat'] and connkey through downloadEvents() into ajaxResponse() unsanitized. Constrain exportFormat to the allowlist ('tar'/'zip') and strip non-[\w.-] chars from connkey at the call site. --- web/ajax/event.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/ajax/event.php b/web/ajax/event.php index 02ace5f67..b27853525 100644 --- a/web/ajax/event.php +++ b/web/ajax/event.php @@ -113,10 +113,11 @@ if ( canView('Events') or canView('Snapshots') ) { break; case 'download' : require_once('includes/download_functions.php'); - $exportFormat = isset($_REQUEST['exportFormat']) ? $_REQUEST['exportFormat'] : 'zip'; + $exportFormat = (isset($_REQUEST['exportFormat']) and ($_REQUEST['exportFormat'] === 'tar' or $_REQUEST['exportFormat'] === 'zip')) ? $_REQUEST['exportFormat'] : 'zip'; + $exportConnkey = preg_replace('/[^\w\-.]/', '', isset($_REQUEST['connkey']) ? $_REQUEST['connkey'] : ''); $exportFileName = isset($_REQUEST['exportFileName']) ? $_REQUEST['exportFileName'] : ''; - if (!$exportFileName) $exportFileName = 'Export'.(isset($_REQUEST['connkey'])?$_REQUEST['connkey']:''); + if (!$exportFileName) $exportFileName = 'Export'.$exportConnkey; $exportFileName = preg_replace('/[^\p{L}\p{N}\-\.\(\)]/u', '', $exportFileName); $exportIds = []; @@ -148,7 +149,7 @@ if ( canView('Events') or canView('Snapshots') ) { ajaxResponse(array( 'exportFile'=>$exportFile, 'exportFormat'=>$exportFormat, - 'connkey'=>(isset($_REQUEST['connkey'])?$_REQUEST['connkey']:'') + 'connkey'=>$exportConnkey )); } else { From e6cf18f0a990ec90d9fbee1a7fbdb0bcee027671 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 13 May 2026 23:08:48 +0300 Subject: [PATCH 079/168] Update sidebar.css --- web/skins/classic/css/base/sidebar.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/css/base/sidebar.css b/web/skins/classic/css/base/sidebar.css index da216f3f3..48773938d 100644 --- a/web/skins/classic/css/base/sidebar.css +++ b/web/skins/classic/css/base/sidebar.css @@ -404,5 +404,5 @@ div:not(.chosen-container-active) > .chosen-drop { #statusSidebar #stateModalBtn { white-space: pre-wrap; word-wrap: break-word; - word-break: break-all; + word-break: break-word; } From 7cac112c696d5b2796843474f0a02a5952de63ff Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 16:11:16 -0400 Subject: [PATCH 080/168] fix: remove reflected user input from events ajax XSS sinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four sites in events.php reflected user-controlled data into ajaxError() / ZM\\Fatal() — three "Insufficient permissions for user " messages and the default-task handler echoing $_REQUEST['task']. Replace with static messages; the task value is still logged server-side via ZM\\Warning. --- web/ajax/events.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/web/ajax/events.php b/web/ajax/events.php index efd681999..15f754b8b 100644 --- a/web/ajax/events.php +++ b/web/ajax/events.php @@ -8,7 +8,7 @@ $data = array(); // if (!canView('Events')) - $message = 'Insufficient permissions for user '.$user->Username().'
'; + $message = 'Insufficient permissions
'; if (empty($_REQUEST['task'])) { $message = 'Must specify a task
'; @@ -106,14 +106,14 @@ switch ($task) { case 'unarchive' : # The idea is that anyone can archive, but only people with Event Edit permission can unarchive.. if (!canEdit('Events')) { - ajaxError('Insufficient permissions for user '.$user->Username()); + ajaxError('Insufficient permissions'); return; } foreach ($eids as $eid) archiveRequest($task, $eid); break; case 'delete' : if (!canEdit('Events')) { - ajaxError('Insufficient permissions for user '.$user->Username()); + ajaxError('Insufficient permissions'); return; } foreach ($eids as $eid) { @@ -128,7 +128,8 @@ switch ($task) { $data = queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $limit); break; default : - ZM\Fatal("Unrecognised task '$task'"); + ZM\Warning("Unrecognised task '$task'"); + ajaxError('Unrecognised task'); } // end switch task ajaxResponse($data); From 5ee5ba17c037c4c84da7bc1dfbb0b131b5d47e05 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 16:51:38 -0400 Subject: [PATCH 081/168] fix: do not return SQL error text to client in events ajax ajaxError(dbError(\$sql)) leaked the failing SQL statement (which contains user-controlled filter/search terms) to the response body. Log the SQL error server-side, return a generic message to the client. --- web/ajax/events.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/ajax/events.php b/web/ajax/events.php index 15f754b8b..8a4f15ee4 100644 --- a/web/ajax/events.php +++ b/web/ajax/events.php @@ -243,7 +243,8 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim ZM\Debug('Calling the following sql query: ' .$sql); $query = dbQuery($sql, $values); if (!$query) { - ajaxError(dbError($sql)); + ZM\Error(dbError($sql)); + ajaxError('Database query failed'); return; } while ($row = dbFetchNext($query)) { From 65ea580e59fbfe7720e57ef96e07cdfeb0af6989 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 16:57:24 -0400 Subject: [PATCH 082/168] fix: remove reflected user input from log ajax XSS sinks Four sites in log.php concatenated user-controlled data into the $message string that flows to ajaxError() / exit(). Three permission messages reflected \$user->Username(); the catch-all task message reflected \$_REQUEST['task']. Drop the reflected tokens; still log the unrecognised task server-side via ZM\\Warning. --- web/ajax/log.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index fe0f7f3f0..2b26b328e 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -11,21 +11,21 @@ if (!isset($_REQUEST['task'])) { $message = 'This request requires a task to be set'; } else if ($_REQUEST['task'] == 'query') { if (!canView('System')) { - $message = 'Insufficient permissions to view log entries for user '.$user->Username(); + $message = 'Insufficient permissions to view log entries'; } else { $data = queryRequest(); } } else if ($_REQUEST['task'] == 'create' ) { global $user; if (!$user or (!canEdit('System') and !ZM_LOG_INJECT)) { - $message = 'Insufficient permissions to create log entries for user '.$user->Username(); + $message = 'Insufficient permissions to create log entries'; } else { createRequest(); } } else if ($_REQUEST['task'] == 'delete') { global $user; if (!canEdit('System')) { - $message = 'Insufficient permissions to delete log entries for user '.$user->Username(); + $message = 'Insufficient permissions to delete log entries'; } else { if (!empty($_REQUEST['ids'])) { $ids = array_map('intval', (array)$_REQUEST['ids']); @@ -35,7 +35,8 @@ if (!isset($_REQUEST['task'])) { } } else { // Only the query and create tasks are supported at the moment - $message = 'Unrecognised task '.$_REQUEST['task']; + ZM\Warning('Unrecognised task '.$_REQUEST['task']); + $message = 'Unrecognised task'; } if ($message) { From 644716b4ea0c51a347a8a2a2b8fec5199d1a7920 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 13 May 2026 22:54:02 -0400 Subject: [PATCH 083/168] fix: sanitize reflected user input in ajax error messages Earlier commits in this branch dropped reflected user input from ajaxError() messages entirely to close Snyk XSS findings, but that lost useful diagnostic information ("Unrecognised action ", "Insufficient permissions for user "). Use validHtmlStr() (htmlspecialchars with ENT_QUOTES) so the values still appear in the message and Snyk recognises the sanitization. Affects add_monitors.php, device.php, event.php, events.php, log.php. --- web/ajax/add_monitors.php | 2 +- web/ajax/device.php | 5 ++--- web/ajax/event.php | 3 +-- web/ajax/events.php | 9 ++++----- web/ajax/log.php | 9 ++++----- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/web/ajax/add_monitors.php b/web/ajax/add_monitors.php index cc5cf8e12..0e466bcca 100644 --- a/web/ajax/add_monitors.php +++ b/web/ajax/add_monitors.php @@ -197,5 +197,5 @@ if (canEdit('Monitors')) { } else { ZM\Warning('Cannot edit monitors'); } -ajaxError('Unrecognised action or insufficient permissions'); +ajaxError('Unrecognised action '.validHtmlStr($_REQUEST['action']).' or insufficient permissions for user '.validHtmlStr($user->Username())); ?> diff --git a/web/ajax/device.php b/web/ajax/device.php index 26fa8e1de..0277f77ae 100644 --- a/web/ajax/device.php +++ b/web/ajax/device.php @@ -20,7 +20,7 @@ // Device view actions if ( !canEdit('Devices') ) { - ajaxError('Insufficient permissions'); + ajaxError('Insufficient permissions for user '.validHtmlStr($user->Username())); return; } @@ -41,8 +41,7 @@ if ( $action == 'device' ) { } ajaxResponse(); } else { - ZM\Warning('unknown action '.$_REQUEST['action']); - ajaxError('Unrecognised action'); + ajaxError('Unrecognised action '.validHtmlStr($_REQUEST['action'])); } // end if action ?> diff --git a/web/ajax/event.php b/web/ajax/event.php index b27853525..c74ef90dc 100644 --- a/web/ajax/event.php +++ b/web/ajax/event.php @@ -232,6 +232,5 @@ if ( canEdit('Events') ) { } // end switch action } // end if canEdit('Events') -ZM\Warning('unknown action '.$_REQUEST['action']); -ajaxError('Unrecognised action or insufficient permissions'); +ajaxError('Unrecognised action '.validHtmlStr($_REQUEST['action']).' or insufficient permissions for user '.validHtmlStr($user->Username())); ?> diff --git a/web/ajax/events.php b/web/ajax/events.php index 8a4f15ee4..47cb4725d 100644 --- a/web/ajax/events.php +++ b/web/ajax/events.php @@ -8,7 +8,7 @@ $data = array(); // if (!canView('Events')) - $message = 'Insufficient permissions
'; + $message = 'Insufficient permissions for user '.validHtmlStr($user->Username()).'
'; if (empty($_REQUEST['task'])) { $message = 'Must specify a task
'; @@ -106,14 +106,14 @@ switch ($task) { case 'unarchive' : # The idea is that anyone can archive, but only people with Event Edit permission can unarchive.. if (!canEdit('Events')) { - ajaxError('Insufficient permissions'); + ajaxError('Insufficient permissions for user '.validHtmlStr($user->Username())); return; } foreach ($eids as $eid) archiveRequest($task, $eid); break; case 'delete' : if (!canEdit('Events')) { - ajaxError('Insufficient permissions'); + ajaxError('Insufficient permissions for user '.validHtmlStr($user->Username())); return; } foreach ($eids as $eid) { @@ -128,8 +128,7 @@ switch ($task) { $data = queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $limit); break; default : - ZM\Warning("Unrecognised task '$task'"); - ajaxError('Unrecognised task'); + ajaxError("Unrecognised task '".validHtmlStr($task)."'"); } // end switch task ajaxResponse($data); diff --git a/web/ajax/log.php b/web/ajax/log.php index 2b26b328e..2b62aa29d 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -11,21 +11,21 @@ if (!isset($_REQUEST['task'])) { $message = 'This request requires a task to be set'; } else if ($_REQUEST['task'] == 'query') { if (!canView('System')) { - $message = 'Insufficient permissions to view log entries'; + $message = 'Insufficient permissions to view log entries for user '.validHtmlStr($user->Username()); } else { $data = queryRequest(); } } else if ($_REQUEST['task'] == 'create' ) { global $user; if (!$user or (!canEdit('System') and !ZM_LOG_INJECT)) { - $message = 'Insufficient permissions to create log entries'; + $message = 'Insufficient permissions to create log entries for user '.validHtmlStr($user->Username()); } else { createRequest(); } } else if ($_REQUEST['task'] == 'delete') { global $user; if (!canEdit('System')) { - $message = 'Insufficient permissions to delete log entries'; + $message = 'Insufficient permissions to delete log entries for user '.validHtmlStr($user->Username()); } else { if (!empty($_REQUEST['ids'])) { $ids = array_map('intval', (array)$_REQUEST['ids']); @@ -35,8 +35,7 @@ if (!isset($_REQUEST['task'])) { } } else { // Only the query and create tasks are supported at the moment - ZM\Warning('Unrecognised task '.$_REQUEST['task']); - $message = 'Unrecognised task'; + $message = 'Unrecognised task '.validHtmlStr($_REQUEST['task']); } if ($message) { From fbf73de262471b5721f65c34cbd54981d88120a8 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 14 May 2026 06:45:02 -0400 Subject: [PATCH 084/168] fix: align auth hash validation with generation and warn on user mismatch - AppController.php: stop overwriting $_SESSION['remoteAddr'] with bare REMOTE_ADDR right after zm_session_start() already populated it from HTTP_X_FORWARDED_FOR. The clobber bound generated hashes to the proxy IP, but getAuthUser() validates against XFF, so any hash produced inside the legacy stateful API path was DOA behind a reverse proxy. - getAuthUser(): prefer the URL user= parameter over \$_SESSION['username'] for filtering, matching what zms's zmLoadAuthUser does, and honor ZM_CASE_INSENSITIVE_USERNAMES on the primary filter. Warn when the URL user= disagrees with the session username (stale hash, cross-tab contamination, or tampered request). - Add a Debug input dump on entry and an Info-level failure line that reports filterUser, XFF, REMOTE_ADDR, rowsTried and the TTL window so the next 401 surfaces which input is wrong. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/api/app/Controller/AppController.php | 6 ++- web/includes/auth.php | 60 ++++++++++++++++++------ 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/web/api/app/Controller/AppController.php b/web/api/app/Controller/AppController.php index a36d22b96..81d1d1b39 100644 --- a/web/api/app/Controller/AppController.php +++ b/web/api/app/Controller/AppController.php @@ -90,8 +90,12 @@ class AppController extends Controller { require_once __DIR__ .'/../../../includes/session.php'; $stateful = $this->request->query('stateful') ? $this->request->query('stateful') : $this->request->data('stateful'); if ( $stateful ) { + // zm_session_start() already populates $_SESSION['remoteAddr'] from + // HTTP_X_FORWARDED_FOR (falling back to REMOTE_ADDR), matching what + // getAuthUser() uses for validation. Don't overwrite it with bare + // REMOTE_ADDR here — that bound the hash to the proxy IP and broke + // validation behind a reverse proxy. zm_session_start(); - $_SESSION['remoteAddr'] = $_SERVER['REMOTE_ADDR']; // To help prevent session hijacking if ($user) { $_SESSION['username'] = $user->Username(); if ( ZM_AUTH_RELAY == 'plain' ) { diff --git a/web/includes/auth.php b/web/includes/auth.php index 8bdbd4f08..022825407 100644 --- a/web/includes/auth.php +++ b/web/includes/auth.php @@ -171,29 +171,55 @@ function validateToken($token, $allowed_token_type='access') { function getAuthUser($auth) { if (ZM_OPT_USE_AUTH && (ZM_AUTH_RELAY == 'hashed') && !empty($auth)) { $remoteAddr = ''; + $xff = !empty($_SERVER['HTTP_X_FORWARDED_FOR']) + ? trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]) + : ''; + $directAddr = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : ''; if (ZM_AUTH_HASH_IPS) { // Use HTTP_X_FORWARDED_FOR if available (consistent with session.php which uses it for hash generation) // taking only the first IP to guard against spoofed multi-value headers. // This ensures validation matches generation when behind a reverse proxy. - $remoteAddr = !empty($_SERVER['HTTP_X_FORWARDED_FOR']) - ? trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]) - : $_SERVER['REMOTE_ADDR']; + $remoteAddr = $xff !== '' ? $xff : $directAddr; if ( !$remoteAddr ) { ZM\Error("Can't determine remote address for authentication, using empty string"); $remoteAddr = ''; } } - $sql = 'SELECT * FROM Users WHERE Enabled = 1'; - $values = array(); - if (isset($_SESSION['username'])) { - # Most of the time we will be logged in already and the session will have our username, so we can significantly speed up our hash testing by only looking at our user. - # Only really important if you have a lot of users. - $sql .= ' AND Username=?'; - array_push($values, $_SESSION['username']); + // Prefer the username from the URL (matches what zms uses) so PHP and the + // C++ side query the same row. Fall back to the session username for + // page-internal calls that don't carry user= on the URL. + $requestedUser = !empty($_REQUEST['user']) ? $_REQUEST['user'] : null; + $sessionUser = isset($_SESSION['username']) ? $_SESSION['username'] : null; + $filterUser = $requestedUser !== null ? $requestedUser : $sessionUser; + + if ($requestedUser !== null && $sessionUser !== null) { + $usersMatch = ZM_CASE_INSENSITIVE_USERNAMES + ? (strcasecmp($requestedUser, $sessionUser) === 0) + : ($requestedUser === $sessionUser); + if (!$usersMatch) { + ZM\Warning("Auth user mismatch: URL user='$requestedUser' but session username='$sessionUser'. This may indicate a stale auth hash from a previous login, cross-tab session contamination, or a tampered request."); + } } - foreach (dbFetchAll($sql, NULL, $values) as $user) { + ZM\Debug("getAuthUser: validating auth='$auth' filterUser='".($filterUser ?? '')."' xff='$xff' directAddr='$directAddr' usingRemoteAddr='$remoteAddr' session_username='".($sessionUser ?? '')."'"); + + $sql = 'SELECT * FROM Users WHERE Enabled = 1'; + $values = array(); + if ($filterUser !== null) { + # Most of the time we will be logged in already and the session will have our username, so we can significantly speed up our hash testing by only looking at our user. + # Only really important if you have a lot of users. + if (ZM_CASE_INSENSITIVE_USERNAMES) { + $sql .= ' AND LOWER(Username)=LOWER(?)'; + } else { + $sql .= ' AND Username=?'; + } + array_push($values, $filterUser); + } + + $rows = dbFetchAll($sql, NULL, $values); + $rowsTried = count($rows); + foreach ($rows as $user) { $now = time(); for ($i = 0; $i < ZM_AUTH_HASH_TTL; $i++, $now -= 3600) { // Try for last TTL hours $time = localtime($now); @@ -206,7 +232,7 @@ function getAuthUser($auth) { } // end foreach hour } // end foreach user - if (isset($_SESSION['username'])) { + if ($filterUser !== null) { # In a multi-server case, we might be logged in as another user and so the auth hash didn't work if (ZM_CASE_INSENSITIVE_USERNAMES) { $sql = 'SELECT * FROM Users WHERE Enabled = 1 AND LOWER(Username) != LOWER(?)'; @@ -214,7 +240,9 @@ function getAuthUser($auth) { $sql = 'SELECT * FROM Users WHERE Enabled = 1 AND Username != ?'; } - foreach (dbFetchAll($sql, NULL, $values) as $user) { + $altRows = dbFetchAll($sql, NULL, array($filterUser)); + $rowsTried += count($altRows); + foreach ($altRows as $user) { $now = time(); for ($i = 0; $i < ZM_AUTH_HASH_TTL; $i++, $now -= 3600) { // Try for last TTL hours $time = localtime($now); @@ -222,11 +250,15 @@ function getAuthUser($auth) { $authHash = md5($authKey); if ($auth == $authHash) { + ZM\Debug("getAuthUser: matched user '".$user['Username']."' from fallback (filter was '$filterUser')"); return new ZM\User($user); } // end if $auth == $authHash } // end foreach hour } // end foreach user - } // end if + } // end if + + ZM\Info("Unable to authenticate user from auth hash '$auth' (filterUser='".($filterUser ?? '')."' xff='$xff' directAddr='$directAddr' rowsTried=$rowsTried ttl=".ZM_AUTH_HASH_TTL.'h)'); + return null; } // end if using auth hash ZM\Info("Unable to authenticate user from auth hash '$auth'"); From 7b9c1ee1d26cd2a6ee926c0fccde27d284a15eaf Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 14 May 2026 06:45:10 -0400 Subject: [PATCH 085/168] fix: derive event end time from Length when EndDateTime is missing When zmc is killed or crashes without writing EndDateTime, three code paths invent a fake end of NOW(), so an event from hours ago appears to extend across the entire down-time. Montage review then paints a bar that makes it look like recorded video exists where it doesn't. Length is flushed to the DB every few seconds during recording, so even crashed events have an accurate last-known duration. Fall back to StartDateTime + Length when EndDateTime IS NULL, and only fall back to NOW() when Length is also 0 (event has no recorded data yet). - web/api/app/Model/Event.php: EndTimeSecs and EndTime virtual fields, which is what the montagereview JS actually reads via the API. - web/ajax/events.php: same fix in the AJAX events list SQL. - web/skins/classic/views/montagereview.php: \$eventsSql kept in sync even though it is no longer executed directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/ajax/events.php | 21 +++++++++++++++++---- web/api/app/Model/Event.php | 10 ++++++++-- web/skins/classic/views/montagereview.php | 19 ++++++++++++++++--- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/web/ajax/events.php b/web/ajax/events.php index efd681999..70581d20d 100644 --- a/web/ajax/events.php +++ b/web/ajax/events.php @@ -210,11 +210,24 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim $has_post_sql_conditions = count($filter->post_sql_conditions()); + // For events that never wrote EndDateTime (zmc killed/crashed mid-event), + // fall back to StartDateTime + Length. Length is flushed to the DB every + // few seconds during recording, so it reflects the actual recorded + // duration even when zmc died without closing the event. Falling back to + // NOW() would otherwise extend the event across all the down-time. $col_str = ' - E.*, - UNIX_TIMESTAMP(E.StartDateTime) AS StartTimeSecs, - CASE WHEN E.EndDateTime IS NULL THEN (SELECT NOW()) ELSE E.EndDateTime END AS EndDateTime, - CASE WHEN E.EndDateTime IS NULL THEN (SELECT UNIX_TIMESTAMP(NOW())) ELSE UNIX_TIMESTAMP(EndDateTime) END AS EndTimeSecs, + E.*, + UNIX_TIMESTAMP(E.StartDateTime) AS StartTimeSecs, + CASE + WHEN E.EndDateTime IS NOT NULL THEN E.EndDateTime + WHEN E.Length > 0 THEN DATE_ADD(E.StartDateTime, INTERVAL FLOOR(E.Length) SECOND) + ELSE NOW() + END AS EndDateTime, + CASE + WHEN E.EndDateTime IS NOT NULL THEN UNIX_TIMESTAMP(E.EndDateTime) + WHEN E.Length > 0 THEN UNIX_TIMESTAMP(E.StartDateTime) + E.Length + ELSE UNIX_TIMESTAMP(NOW()) + END AS EndTimeSecs, M.Name AS Monitor, GROUP_CONCAT(T.Name SEPARATOR ", ") AS Tags'; diff --git a/web/api/app/Model/Event.php b/web/api/app/Model/Event.php index 4949ac9d8..ee62acb7d 100644 --- a/web/api/app/Model/Event.php +++ b/web/api/app/Model/Event.php @@ -31,11 +31,17 @@ class Event extends AppModel { */ public $displayField = 'Name'; + // For events that never wrote EndDateTime (zmc killed/crashed mid-event), + // fall back to StartDateTime + Length (Length is flushed to the DB every few + // seconds during recording, so it reflects the actual recorded duration). + // Only fall back to NOW() if Length is also 0 (event has no recorded data + // yet, e.g. just started). This prevents montagereview and other consumers + // from painting an event bar across hours/days of no real recording. public $virtualFields = array( 'StartTimeSecs' => 'UNIX_TIMESTAMP(StartDateTime)', - 'EndTimeSecs' => 'UNIX_TIMESTAMP(EndDateTime)', + 'EndTimeSecs' => '(CASE WHEN Event.EndDateTime IS NOT NULL THEN UNIX_TIMESTAMP(Event.EndDateTime) WHEN Event.Length > 0 THEN UNIX_TIMESTAMP(Event.StartDateTime) + Event.Length ELSE UNIX_TIMESTAMP(NOW()) END)', 'StartTime' => 'StartDateTime', - 'EndTime' => 'EndDateTime' + 'EndTime' => '(CASE WHEN Event.EndDateTime IS NOT NULL THEN Event.EndDateTime WHEN Event.Length > 0 THEN DATE_ADD(Event.StartDateTime, INTERVAL FLOOR(Event.Length) SECOND) ELSE NOW() END)' ); //The Associations below have been created with all possible keys, those that are not needed can be removed diff --git a/web/skins/classic/views/montagereview.php b/web/skins/classic/views/montagereview.php index 0aacb5c41..20d0eee23 100644 --- a/web/skins/classic/views/montagereview.php +++ b/web/skins/classic/views/montagereview.php @@ -195,12 +195,25 @@ if (count($filter->terms()) ) { // if the bulk record has not been written - to be able to include more current frames reduce bulk frame sizes (event size can be large) // Note we round up just a bit on the end time as otherwise you get gaps, like 59.78 to 00 in the next second, which can give blank frames when moved through slowly. +// For events that never wrote EndDateTime (zmc killed/crashed mid-event), +// fall back to StartDateTime + Length. Length is flushed to the DB every few +// seconds during recording, so it reflects the actual recorded duration even +// when zmc died. Otherwise the event would appear to extend across all the +// down-time, suggesting recorded video that doesn't exist. $eventsSql = 'SELECT E.*, E.StartDateTime AS StartDateTime,UNIX_TIMESTAMP(E.StartDateTime) AS StartTimeSecs, - CASE WHEN E.EndDateTime IS NULL THEN (SELECT NOW()) ELSE E.EndDateTime END AS EndDateTime, - CASE WHEN E.EndDateTime IS NULL THEN (SELECT UNIX_TIMESTAMP(NOW())) ELSE UNIX_TIMESTAMP(EndDateTime) END AS EndTimeSecs, + CASE + WHEN E.EndDateTime IS NOT NULL THEN E.EndDateTime + WHEN E.Length > 0 THEN DATE_ADD(E.StartDateTime, INTERVAL FLOOR(E.Length) SECOND) + ELSE NOW() + END AS EndDateTime, + CASE + WHEN E.EndDateTime IS NOT NULL THEN UNIX_TIMESTAMP(E.EndDateTime) + WHEN E.Length > 0 THEN UNIX_TIMESTAMP(E.StartDateTime) + E.Length + ELSE UNIX_TIMESTAMP(NOW()) + END AS EndTimeSecs, M.Name AS MonitorName,M.DefaultScale FROM Monitors AS M INNER JOIN Events AS E on (M.Id = E.MonitorId) - WHERE 1 > 0 + WHERE 1 > 0 '; // This program only calls itself with the time range involved -- it does all monitors (the user can see, in the called group) all the time From 8fd17a4b915f3e631a0e227e332cfc6fcf5c328d Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 14 May 2026 07:38:18 -0400 Subject: [PATCH 086/168] perf: index Sessions.access and rework session gc to two-phase delete The session garbage collector ran DELETE FROM Sessions WHERE access < ? against an unindexed column, forcing a full table scan and taking gap locks across the access range. With REPLACE INTO Sessions happening on every authenticated request, this is a deadlock hotspot. - Add Sessions_access_idx on Sessions(access) in both fresh-install schema (zm_create.sql.in) and a migration (zm_update-1.39.10.sql). - Rewrite ZMSessionHandler::gc to a two-phase delete: SELECT up to 100 expired ids via the new index (consistent read, no locks), then DELETE WHERE id IN (...) by primary key. InnoDB takes record locks only on the matched rows, not gap locks on the access range. - Bump version to 1.39.10 so zmupdate.pl picks up the new migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/zm_create.sql.in | 3 ++- db/zm_update-1.39.10.sql | 17 +++++++++++++++++ version.txt | 2 +- web/includes/session.php | 17 ++++++++++++++--- 4 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 db/zm_update-1.39.10.sql diff --git a/db/zm_create.sql.in b/db/zm_create.sql.in index 52e5c4710..281c56b1c 100644 --- a/db/zm_create.sql.in +++ b/db/zm_create.sql.in @@ -1343,7 +1343,8 @@ CREATE TABLE Sessions ( id char(32) not null, access INT(10) UNSIGNED DEFAULT NULL, data text, - PRIMARY KEY(id) + PRIMARY KEY(id), + KEY `Sessions_access_idx` (`access`) ) ENGINE=@ZM_MYSQL_ENGINE@; CREATE TABLE Snapshots ( diff --git a/db/zm_update-1.39.10.sql b/db/zm_update-1.39.10.sql new file mode 100644 index 000000000..72c32aa7e --- /dev/null +++ b/db/zm_update-1.39.10.sql @@ -0,0 +1,17 @@ +-- +-- Add an index on Sessions.access to support session garbage collection. +-- + +SET @s = (SELECT IF( + (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Sessions' + AND table_schema = DATABASE() + AND index_name = 'Sessions_access_idx' + ) > 0, + "SELECT 'access Index already exists on Sessions table'", + "CREATE INDEX Sessions_access_idx ON Sessions (`access`)" +)); + +PREPARE stmt FROM @s; +EXECUTE stmt; diff --git a/version.txt b/version.txt index 2a293729c..104191edb 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.39.9 +1.39.10 diff --git a/web/includes/session.php b/web/includes/session.php index 996ae8d20..3139336af 100644 --- a/web/includes/session.php +++ b/web/includes/session.php @@ -188,9 +188,20 @@ class ZMSessionHandler implements SessionHandlerInterface { $now = time(); $old = $now - $max; ZM\Debug('doing session gc ' . $now . '-' . $max. '='.$old); - $sth = $this->db->prepare('DELETE FROM Sessions WHERE access < :old'); - $sth->bindParam(':old', $old, PDO::PARAM_INT); - return $sth->execute() ? true : false; + + // Two-phase delete: find expired ids via the access index (consistent read, no locks), + // then delete by primary key so InnoDB only takes record locks on the matched rows + // and not gap locks across the access range — avoids deadlocks with concurrent + // REPLACE INTO Sessions on every authenticated request. + $sel = $this->db->prepare('SELECT id FROM Sessions WHERE access < :old LIMIT 100'); + $sel->bindParam(':old', $old, PDO::PARAM_INT); + if (!$sel->execute()) return false; + $ids = $sel->fetchAll(PDO::FETCH_COLUMN); + if (!$ids) return true; + + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $del = $this->db->prepare("DELETE FROM Sessions WHERE id IN ($placeholders)"); + return $del->execute($ids) ? true : false; } public function validateId($key) : bool {return true;} } # end class Session From 5153dc68ee32dad018676c3ad2452d914a3a6f99 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 14 May 2026 07:38:58 -0400 Subject: [PATCH 087/168] fix: flush decoder_queue on decoder thread exit to avoid stale latency offset across reconnect The decoder thread holds a raw AVCodecContext* obtained from camera->getVideoCodecContext() and pushes packet locks into Monitor's decoder_queue for each send_packet() that hasn't yet been matched by a receive_frame(). Monitor::PrimeCapture() used to call camera->PrimeCapture() (which Close()s the camera and frees the codec context) without first stopping the decoder thread. Two problems followed: 1. Use-after-free race between the decoder thread and the camera teardown. 2. The stale decoder_queue entries survived the reconnect. The new codec context produced frames in send-order, so we popped the oldest stale entries to attribute frames that actually came from packets sent later. The net effect was a permanent N-packet offset between capture and decode (~92 packets observed in the field). Analysis blocks on !packet->decoded for those packets, so the packetqueue saturates at max_video_packet_count and stays there, spamming the "max video packets in the queue" warning forever. Fix: - Stop+Join the decoder in Monitor::PrimeCapture() before tearing down the codec context. - Add Monitor::flushDecoderQueue() which marks in-flight packets decoded, notifies waiters, and clears the queue. - Call it at the end of DecoderThread::Run() so any Stop()+Join() (including the existing one in Pause()) naturally releases stale entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_decoder_thread.cpp | 6 ++++++ src/zm_monitor.cpp | 32 ++++++++++++++++++++++++++++++++ src/zm_monitor.h | 7 +++++++ 3 files changed, 45 insertions(+) diff --git a/src/zm_decoder_thread.cpp b/src/zm_decoder_thread.cpp index 6b08efd39..e5b6e61d9 100644 --- a/src/zm_decoder_thread.cpp +++ b/src/zm_decoder_thread.cpp @@ -42,4 +42,10 @@ void DecoderThread::Run() { } } } + + // Release any packets we sent to the codec but never received frames for. + // The codec context is about to be (or has been) torn down for Pause / + // reconnect; leaving stale entries would create a permanent latency + // offset against the next codec context on resume. + monitor_->flushDecoderQueue(); } diff --git a/src/zm_monitor.cpp b/src/zm_monitor.cpp index 8bb33f4e2..89cc72b64 100644 --- a/src/zm_monitor.cpp +++ b/src/zm_monitor.cpp @@ -2892,6 +2892,21 @@ bool Monitor::applyDeinterlacing(std::shared_ptr &packet, Image *captu return true; } +void Monitor::flushDecoderQueue() { + // Called from DecoderThread::Run() as the decoder thread exits, so no + // concurrent access to decoder_queue: the thread that mutates it is us. + if (decoder_queue.empty()) return; + Debug(1, "Flushing %zu in-flight entries from decoder_queue", decoder_queue.size()); + for (auto &lock : decoder_queue) { + if (lock.packet_) { + lock.packet_->decoded = true; + lock.packet_->notify_all(); + } + } + decoder_queue.clear(); + packetqueue.notify_all(); // wake the analysis thread if it's waiting +} + bool Monitor::Decode() { AVCodecContext *context = camera->getVideoCodecContext(); ZMPacketLock packet_lock; @@ -3606,6 +3621,23 @@ unsigned int Monitor::Colours() const { return camera ? camera->Colours() : colo unsigned int Monitor::SubpixelOrder() const { return camera ? camera->SubpixelOrder() : 0; } int Monitor::PrimeCapture() { + // Stop the decoder before tearing the codec context down. The decoder + // thread holds a raw AVCodecContext* it got from + // camera->getVideoCodecContext(); camera->PrimeCapture() will Close() the + // camera (freeing that context) and OpenFfmpeg() a new one. Running the + // decoder against the dying context is unsafe; equally important, on + // exit the decoder thread releases the in-flight packet locks in + // decoder_queue (see DecoderThread::Run). Without that, stale entries + // survive the reconnect and create a permanent latency offset against + // the new codec context — the analysis thread blocks on + // !packet->decoded for those packets and the packetqueue fills to + // max_video_packet_count and stays there. + if (decoder) { + decoder->Stop(); + packetqueue.notify_all(); // wake the thread if it's blocked on wait_for + decoder->Join(); + } + int ret = camera->PrimeCapture(); if (ret <= 0) return ret; diff --git a/src/zm_monitor.h b/src/zm_monitor.h index d4daf6db2..4778d25e9 100644 --- a/src/zm_monitor.h +++ b/src/zm_monitor.h @@ -767,6 +767,13 @@ class Monitor : public std::enable_shared_from_this { RecordingOption Recording() const { return recording; } inline PacketQueue * GetPacketQueue() { return &packetqueue; } + + // Called by the decoder thread as it exits. Releases packet locks for + // anything it sent to the codec context but never received as a frame + // (codec context is about to be torn down for Pause/reconnect, so those + // packets will never produce output). Marks them decoded so the analysis + // thread can advance past them. + void flushDecoderQueue(); inline bool Enabled() const { return shared_data->capturing; } From 602edfe7810dc3d57a80ec62e785ad222d2d7a79 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 14 May 2026 17:43:52 -0400 Subject: [PATCH 088/168] fix: avoid HierarchyRequestError in submitThisForm on console view submitThisForm() did form.prepend(filter) unconditionally when in left sidebar mode. On the console view, #monitorFiltersForm is nested inside #fbpanel, so prepending the filter into its own descendant threw HierarchyRequestError: The new child is an ancestor of the parent. Triggered by pressing Enter inside the sidebar filter, which routes through handleKeydownGeneral -> submitThisForm(). Guard the prepend with filter.contains(form); the montagereview/watch flows the prepend was written for are unaffected. --- web/skins/classic/js/skin.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index b979bf263..58b337d3d 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -615,7 +615,10 @@ function submitThisForm(param = null) { // Let's hide the old filter so that it doesn't appear during the transfer... filter.style.display = 'none'; // We return the filter to its place in the form, since in the left side menu the filter should always be inside the form. - form.prepend(filter); + // Skip if filter is already an ancestor of form (e.g. console: #fbpanel > #monitorFiltersForm), which would cause HierarchyRequestError. + if (!filter.contains(form)) { + form.prepend(filter); + } } if (param && typeof param === 'string') { //ON WATCH PAGE WHEN SELECTING A MONITOR, the object is transferred as PARAM!!! var uri = "?" + $j(form).serialize() + param; From f8581da34b9b7cbfbefbb736032338d98a1f7794 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 23:05:26 +0000 Subject: [PATCH 089/168] Initial plan From cbee86f397193d55c0829ffde96f0d263efa35ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 23:37:14 +0000 Subject: [PATCH 090/168] fix: GroupId filter persists after browser refresh on Console view - Add getFilterFromRequestOrCookie() helper that reads filter values from $_REQUEST first, then falls back to zmFilter_* cookies, mirroring the getFilterSelection() behavior on the PHP page-render side - All monitor filters now use this helper so they persist after F5/navigation - Replace plain SQL subquery for GroupId with Group::get_group_sql() which correctly includes monitors in child groups, consistent with the PHP side - Add integer validation for ID-based filters (GroupId, ServerId, StorageId, MonitorId) to guard against tampered cookie values - Add require_once for Group.php in queryRequest() fixes #4745 Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/4f4372ca-129e-4845-93a2-75ee9c7ecede Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- web/ajax/console.php | 67 +++++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/web/ajax/console.php b/web/ajax/console.php index aca7d36e8..94178c685 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -55,9 +55,25 @@ ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissio // FUNCTION DEFINITIONS // +function getFilterFromRequestOrCookie($name) { + if (isset($_REQUEST[$name])) { + return $_REQUEST[$name]; + } + $cookieName = 'zmFilter_' . $name; + if (isset($_COOKIE[$cookieName])) { + $cookieValue = $_COOKIE[$cookieName]; + if ($cookieValue && $cookieValue !== '') { + $decoded = json_decode($cookieValue, true); + return ($decoded !== null) ? $decoded : $cookieValue; + } + } + return null; +} + function queryRequest() { global $user, $Servers; require_once('includes/Monitor.php'); + require_once('includes/Group.php'); require_once('includes/Group_Monitor.php'); $data = array( @@ -92,34 +108,42 @@ function queryRequest() { $sort = isset($_REQUEST['sort']) ? $_REQUEST['sort'] : 'Sequence'; $order = isset($_REQUEST['order']) ? strtoupper($_REQUEST['order']) : 'ASC'; - // Build monitor query with filters from request parameters (stateless) + // Build monitor query with filters from request parameters, falling back to cookies $conditions = array(); $values = array(); - // Get filter values directly from request + // Get filter values from request, falling back to cookies for persistence after page refresh $request_filters = array( - 'GroupId' => isset($_REQUEST['GroupId']) ? $_REQUEST['GroupId'] : null, - 'ServerId' => isset($_REQUEST['ServerId']) ? $_REQUEST['ServerId'] : null, - 'StorageId' => isset($_REQUEST['StorageId']) ? $_REQUEST['StorageId'] : null, - 'Capturing' => isset($_REQUEST['Capturing']) ? $_REQUEST['Capturing'] : null, - 'Analysing' => isset($_REQUEST['Analysing']) ? $_REQUEST['Analysing'] : null, - 'Recording' => isset($_REQUEST['Recording']) ? $_REQUEST['Recording'] : null, - 'Status' => isset($_REQUEST['Status']) ? $_REQUEST['Status'] : null, - 'MonitorId' => isset($_REQUEST['MonitorId']) ? $_REQUEST['MonitorId'] : null, - 'MonitorName' => isset($_REQUEST['MonitorName']) ? $_REQUEST['MonitorName'] : null, - 'Source' => isset($_REQUEST['Source']) ? $_REQUEST['Source'] : null + 'GroupId' => getFilterFromRequestOrCookie('GroupId'), + 'ServerId' => getFilterFromRequestOrCookie('ServerId'), + 'StorageId' => getFilterFromRequestOrCookie('StorageId'), + 'Capturing' => getFilterFromRequestOrCookie('Capturing'), + 'Analysing' => getFilterFromRequestOrCookie('Analysing'), + 'Recording' => getFilterFromRequestOrCookie('Recording'), + 'Status' => getFilterFromRequestOrCookie('Status'), + 'MonitorId' => getFilterFromRequestOrCookie('MonitorId'), + 'MonitorName' => getFilterFromRequestOrCookie('MonitorName'), + 'Source' => getFilterFromRequestOrCookie('Source') ); - // Apply request filters to SQL + // Apply GroupId filter using get_group_sql() to include child groups. + // Validate that group IDs are integers to guard against tampered cookie values. if ($request_filters['GroupId']) { - $GroupIds = is_array($request_filters['GroupId']) ? $request_filters['GroupId'] : array($request_filters['GroupId']); - $conditions[] = 'M.Id IN (SELECT MonitorId FROM Groups_Monitors WHERE GroupId IN (' . implode(',', array_fill(0, count($GroupIds), '?')) . '))'; - $values = array_merge($values, $GroupIds); + $groupIds = is_array($request_filters['GroupId']) ? $request_filters['GroupId'] : array($request_filters['GroupId']); + $groupIds = array_filter($groupIds, function($id) { return ctype_digit((string)$id); }); + if (count($groupIds)) { + $groupSql = ZM\Group::get_group_sql($groupIds); + if ($groupSql) { + $conditions[] = $groupSql; + } + } } foreach (array('ServerId','StorageId') as $filter) { if ($request_filters[$filter]) { $filter_values = is_array($request_filters[$filter]) ? $request_filters[$filter] : array($request_filters[$filter]); + // Validate that ID values are integers + $filter_values = array_filter($filter_values, function($id) { return ctype_digit((string)$id); }); if (count($filter_values)) { $conditions[] = 'M.'.$filter.' IN (' . implode(',', array_fill(0, count($filter_values), '?')) . ')'; $values = array_merge($values, $filter_values); @@ -203,12 +227,15 @@ function queryRequest() { }); } - // Apply MonitorId filter + // Apply MonitorId filter (validate IDs are integers) if ($request_filters['MonitorId']) { $monitor_ids = is_array($request_filters['MonitorId']) ? $request_filters['MonitorId'] : array($request_filters['MonitorId']); - $filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) { - return in_array($monitor['Id'], $monitor_ids); - }); + $monitor_ids = array_filter($monitor_ids, function($id) { return ctype_digit((string)$id); }); + if (count($monitor_ids)) { + $filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) { + return in_array($monitor['Id'], $monitor_ids); + }); + } } $data['total'] = count($filtered_monitors); From 4e64ea6b6b80bbcc17464d54e5d3c1c4b2e3a224 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 23:38:15 +0000 Subject: [PATCH 091/168] fix: use filter_var FILTER_VALIDATE_INT for stricter ID validation in queryRequest Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/4f4372ca-129e-4845-93a2-75ee9c7ecede Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- web/ajax/console.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/web/ajax/console.php b/web/ajax/console.php index 94178c685..bd11c9d7e 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -127,10 +127,12 @@ function queryRequest() { ); // Apply GroupId filter using get_group_sql() to include child groups. - // Validate that group IDs are integers to guard against tampered cookie values. + // Validate that group IDs are positive integers to guard against tampered cookie values. if ($request_filters['GroupId']) { $groupIds = is_array($request_filters['GroupId']) ? $request_filters['GroupId'] : array($request_filters['GroupId']); - $groupIds = array_filter($groupIds, function($id) { return ctype_digit((string)$id); }); + $groupIds = array_values(array_filter($groupIds, function($id) { + return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; + })); if (count($groupIds)) { $groupSql = ZM\Group::get_group_sql($groupIds); if ($groupSql) { @@ -142,8 +144,10 @@ function queryRequest() { foreach (array('ServerId','StorageId') as $filter) { if ($request_filters[$filter]) { $filter_values = is_array($request_filters[$filter]) ? $request_filters[$filter] : array($request_filters[$filter]); - // Validate that ID values are integers - $filter_values = array_filter($filter_values, function($id) { return ctype_digit((string)$id); }); + // Validate that ID values are positive integers + $filter_values = array_values(array_filter($filter_values, function($id) { + return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; + })); if (count($filter_values)) { $conditions[] = 'M.'.$filter.' IN (' . implode(',', array_fill(0, count($filter_values), '?')) . ')'; $values = array_merge($values, $filter_values); @@ -227,10 +231,12 @@ function queryRequest() { }); } - // Apply MonitorId filter (validate IDs are integers) + // Apply MonitorId filter (validate IDs are positive integers) if ($request_filters['MonitorId']) { $monitor_ids = is_array($request_filters['MonitorId']) ? $request_filters['MonitorId'] : array($request_filters['MonitorId']); - $monitor_ids = array_filter($monitor_ids, function($id) { return ctype_digit((string)$id); }); + $monitor_ids = array_values(array_filter($monitor_ids, function($id) { + return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; + })); if (count($monitor_ids)) { $filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) { return in_array($monitor['Id'], $monitor_ids); From e272cb88f3b2c183e2e3e6ec81fe1901bd73c85f Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 15 May 2026 13:28:40 +0300 Subject: [PATCH 092/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/ajax/devices.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/ajax/devices.php b/web/ajax/devices.php index 53ea02614..a6876b0fa 100644 --- a/web/ajax/devices.php +++ b/web/ajax/devices.php @@ -24,7 +24,7 @@ if ( !canEdit('Devices') ) { return; } -$action = validStr($_REQUEST['action']); +$action = validStr($_REQUEST['action'] ?? ''); if ( $action == 'delete' ) { if ( isset($_REQUEST['markDids']) ) { foreach( $_REQUEST['markDids'] as $markDid ) { From 06d4918223580cbb4128ab39b7a1f92750819a23 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 15 May 2026 13:31:13 +0300 Subject: [PATCH 093/168] Listen for the "click" event for #delConfirmBtn only once. (skin.js) --- web/skins/classic/js/skin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index bde6791a7..6502cc35c 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -689,7 +689,7 @@ function getDelConfirmModal(key, title, formName=null) { console.warn(`Form with name=${formName} not found.`); } } - }); + }, {once: true}); }) .fail(logAjaxFail); } From 0317415a8289515ac444ee92875b0aa7801a0937 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 15 May 2026 14:57:38 +0300 Subject: [PATCH 094/168] Use ConfirmDeleteGroups instead of ConfirmDelete --- web/lang/en_gb.php | 1 + web/lang/ru_ru.php | 1 + web/skins/classic/views/js/groups.js | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/lang/en_gb.php b/web/lang/en_gb.php index f285b489e..3d00ba7e8 100644 --- a/web/lang/en_gb.php +++ b/web/lang/en_gb.php @@ -233,6 +233,7 @@ $SLANG = array( 'ConfirmClearLogs' => 'Are you sure you wish to delete the selected log entries?', 'ConfirmClearLogsTitle' => 'Clear Logs Confirmation', 'ConfirmDeleteControl' => 'Warning, deleting a control will reset all monitors that use it to be uncontrollable.

Are you sure you wish to delete?', + 'ConfirmDeleteGroups' => 'Are you sure you wish to delete the selected groups?', 'ConfirmDeleteDevices' => 'Are you sure you wish to delete the selected devices?', 'ConfirmDeleteEvents' => 'Are you sure you wish to delete the selected events?', 'ConfirmDeleteTrainingData' => 'This will permanently delete ALL training data (images, labels, and class definitions). Type agree to confirm:', diff --git a/web/lang/ru_ru.php b/web/lang/ru_ru.php index a40b21482..1250c862a 100644 --- a/web/lang/ru_ru.php +++ b/web/lang/ru_ru.php @@ -253,6 +253,7 @@ $SLANG = array( 'ConfiguredFor' => 'настроен на', 'ConfirmAction' => 'Подтвердите действие', 'ConfirmDeleteControl' => 'Внимание! Удаление элемента управления приведет к тому, что все мониторы, которые его используют, станут неуправляемыми.

Вы уверены, что хотите удалить?', + 'ConfirmDeleteGroups' => 'Вы действительно хотите удалить выбранные группы?', 'ConfirmDeleteDevices' => 'Вы действительно хотите удалить выбранные устройства?', 'ConfirmDeleteEvents' => 'Вы действительно хотите удалить выбранные события?', 'ConfirmDeleteLayout' => 'Вы действительно хотите удалить текущий шаблон?', diff --git a/web/skins/classic/views/js/groups.js b/web/skins/classic/views/js/groups.js index 8b652543f..8119da01a 100644 --- a/web/skins/classic/views/js/groups.js +++ b/web/skins/classic/views/js/groups.js @@ -33,7 +33,7 @@ function editGroup( element ) { } function deleteGroup(element) { - getDelConfirmModal('ConfirmDelete', 'Delete', 'groupsForm'); + getDelConfirmModal('ConfirmDeleteGroups', 'Delete', 'groupsForm'); } function configureButtons(element) { From b4a2146afdc7423d57071094d7ee8b5c2fe0390f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 12:00:56 +0000 Subject: [PATCH 095/168] fix: use getFilterSelection() and validCardinal() per reviewer feedback - Remove getFilterFromRequestOrCookie(); require _monitor_filters.php and call getFilterSelection() directly instead (addresses duplicate logic) - Replace filter_var(FILTER_VALIDATE_INT) with validCardinal() for consistency with existing codebase utility functions - Guard MonitorName and Source against getFilterSelection() returning an array when the cookie value happens to be valid JSON refs #4745 Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/3d5e3926-51cc-4da8-8707-eb82c7d5db29 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- web/ajax/console.php | 60 ++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/web/ajax/console.php b/web/ajax/console.php index bd11c9d7e..5216ed7c6 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -55,26 +55,12 @@ ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissio // FUNCTION DEFINITIONS // -function getFilterFromRequestOrCookie($name) { - if (isset($_REQUEST[$name])) { - return $_REQUEST[$name]; - } - $cookieName = 'zmFilter_' . $name; - if (isset($_COOKIE[$cookieName])) { - $cookieValue = $_COOKIE[$cookieName]; - if ($cookieValue && $cookieValue !== '') { - $decoded = json_decode($cookieValue, true); - return ($decoded !== null) ? $decoded : $cookieValue; - } - } - return null; -} - function queryRequest() { global $user, $Servers; require_once('includes/Monitor.php'); require_once('includes/Group.php'); require_once('includes/Group_Monitor.php'); + require_once(__DIR__ . '/../views/_monitor_filters.php'); $data = array( 'total' => 0, @@ -112,27 +98,29 @@ function queryRequest() { $conditions = array(); $values = array(); - // Get filter values from request, falling back to cookies for persistence after page refresh + // Get filter values from request, falling back to cookies for persistence after page refresh. + // getFilterSelection() reads $_REQUEST first, then the zmFilter_* cookie. $request_filters = array( - 'GroupId' => getFilterFromRequestOrCookie('GroupId'), - 'ServerId' => getFilterFromRequestOrCookie('ServerId'), - 'StorageId' => getFilterFromRequestOrCookie('StorageId'), - 'Capturing' => getFilterFromRequestOrCookie('Capturing'), - 'Analysing' => getFilterFromRequestOrCookie('Analysing'), - 'Recording' => getFilterFromRequestOrCookie('Recording'), - 'Status' => getFilterFromRequestOrCookie('Status'), - 'MonitorId' => getFilterFromRequestOrCookie('MonitorId'), - 'MonitorName' => getFilterFromRequestOrCookie('MonitorName'), - 'Source' => getFilterFromRequestOrCookie('Source') + 'GroupId' => getFilterSelection('GroupId'), + 'ServerId' => getFilterSelection('ServerId'), + 'StorageId' => getFilterSelection('StorageId'), + 'Capturing' => getFilterSelection('Capturing'), + 'Analysing' => getFilterSelection('Analysing'), + 'Recording' => getFilterSelection('Recording'), + 'Status' => getFilterSelection('Status'), + 'MonitorId' => getFilterSelection('MonitorId'), + 'MonitorName' => getFilterSelection('MonitorName'), + 'Source' => getFilterSelection('Source') ); + // Text filters must be strings; guard against a cookie value that happens to be valid JSON. + if (is_array($request_filters['MonitorName'])) $request_filters['MonitorName'] = ''; + if (is_array($request_filters['Source'])) $request_filters['Source'] = ''; // Apply GroupId filter using get_group_sql() to include child groups. - // Validate that group IDs are positive integers to guard against tampered cookie values. + // Use validCardinal() to sanitize ID values before use. if ($request_filters['GroupId']) { $groupIds = is_array($request_filters['GroupId']) ? $request_filters['GroupId'] : array($request_filters['GroupId']); - $groupIds = array_values(array_filter($groupIds, function($id) { - return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; - })); + $groupIds = array_values(array_filter(array_map('validCardinal', $groupIds))); if (count($groupIds)) { $groupSql = ZM\Group::get_group_sql($groupIds); if ($groupSql) { @@ -144,10 +132,8 @@ function queryRequest() { foreach (array('ServerId','StorageId') as $filter) { if ($request_filters[$filter]) { $filter_values = is_array($request_filters[$filter]) ? $request_filters[$filter] : array($request_filters[$filter]); - // Validate that ID values are positive integers - $filter_values = array_values(array_filter($filter_values, function($id) { - return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; - })); + // Use validCardinal() to sanitize ID values + $filter_values = array_values(array_filter(array_map('validCardinal', $filter_values))); if (count($filter_values)) { $conditions[] = 'M.'.$filter.' IN (' . implode(',', array_fill(0, count($filter_values), '?')) . ')'; $values = array_merge($values, $filter_values); @@ -231,12 +217,10 @@ function queryRequest() { }); } - // Apply MonitorId filter (validate IDs are positive integers) + // Apply MonitorId filter (use validCardinal() to sanitize ID values) if ($request_filters['MonitorId']) { $monitor_ids = is_array($request_filters['MonitorId']) ? $request_filters['MonitorId'] : array($request_filters['MonitorId']); - $monitor_ids = array_values(array_filter($monitor_ids, function($id) { - return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; - })); + $monitor_ids = array_values(array_filter(array_map('validCardinal', $monitor_ids))); if (count($monitor_ids)) { $filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) { return in_array($monitor['Id'], $monitor_ids); From 173af3bc8d55650473b880a726685e20b2a5fe69 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 15 May 2026 18:56:13 +0300 Subject: [PATCH 096/168] Fix: Fixed overlapping ".eventStats" and dropdown tags (event.css) Closed issue: https://github.com/ZoneMinder/zoneminder/issues/4831#issuecomment-4460535978 --- web/skins/classic/css/base/views/event.css | 1 + 1 file changed, 1 insertion(+) diff --git a/web/skins/classic/css/base/views/event.css b/web/skins/classic/css/base/views/event.css index 70ee82eb5..9253d72ca 100644 --- a/web/skins/classic/css/base/views/event.css +++ b/web/skins/classic/css/base/views/event.css @@ -120,6 +120,7 @@ height: 100%; } .eventStats { padding-left: 0; + z-index: 1; /* margin-right: 5px; */ } From 43412fda8f460b156430e306b45dd1c15b50d248 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 15 May 2026 21:58:05 +0300 Subject: [PATCH 097/168] Fix: ".chosen-drop" should always be in the foreground (sidebar.css) --- web/skins/classic/css/base/sidebar.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/skins/classic/css/base/sidebar.css b/web/skins/classic/css/base/sidebar.css index 48773938d..3b1806528 100644 --- a/web/skins/classic/css/base/sidebar.css +++ b/web/skins/classic/css/base/sidebar.css @@ -312,6 +312,10 @@ body #sidebarMain .sub-menu-list { height: 27px !important; } +.extruder .extruder-content .chosen-container .chosen-drop { + z-index: 1100; +} + /* Clear Filter Button Select Multiple Selection */ .extruder .extruder-content .term-value-wrapper { position: relative; /* Enable absolute positioning for child */ From 6faa106af13bd4942612ffbe7f5f6f269f15965c Mon Sep 17 00:00:00 2001 From: abi Date: Thu, 14 May 2026 00:35:33 +0300 Subject: [PATCH 098/168] Fix FreeBSD arm builds --- src/zm_signal.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/zm_signal.cpp b/src/zm_signal.cpp index e4586b95b..9f2b5bbf5 100644 --- a/src/zm_signal.cpp +++ b/src/zm_signal.cpp @@ -137,9 +137,17 @@ RETSIGTYPE zm_die_handler(int signal) ip = (void *)(uc->uc_mcontext.gregs[REG_EIP]); #endif #elif defined(__aarch64__) +#if defined(__FreeBSD_kernel__) || defined(__FreeBSD__) + ip = (void *)(uc->uc_mcontext.mc_gpregs.gp_elr); +#elif ip = (void *)(uc->uc_mcontext.pc); +#endif #elif defined(__arm__) +#if defined(__FreeBSD_kernel__) || defined(__FreeBSD__) + ip = (void *)(uc->uc_mcontext.__gregs[_REG_PC]); +#elif ip = (void *)(uc->uc_mcontext.arm_pc); +#endif #endif // Print the fault address and instruction pointer From 878a9dab143b26dc4bcdb63bd9cd8e60c992f9e2 Mon Sep 17 00:00:00 2001 From: abi Date: Thu, 14 May 2026 20:27:36 +0300 Subject: [PATCH 099/168] Since kFreeBSD was amd64 and i386 archs only, remove unnecessary checks --- src/zm_signal.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zm_signal.cpp b/src/zm_signal.cpp index 9f2b5bbf5..67e4f6b50 100644 --- a/src/zm_signal.cpp +++ b/src/zm_signal.cpp @@ -137,13 +137,13 @@ RETSIGTYPE zm_die_handler(int signal) ip = (void *)(uc->uc_mcontext.gregs[REG_EIP]); #endif #elif defined(__aarch64__) -#if defined(__FreeBSD_kernel__) || defined(__FreeBSD__) +#if defined(__FreeBSD__) ip = (void *)(uc->uc_mcontext.mc_gpregs.gp_elr); #elif ip = (void *)(uc->uc_mcontext.pc); #endif #elif defined(__arm__) -#if defined(__FreeBSD_kernel__) || defined(__FreeBSD__) +#if defined(__FreeBSD__) ip = (void *)(uc->uc_mcontext.__gregs[_REG_PC]); #elif ip = (void *)(uc->uc_mcontext.arm_pc); From 5c0f9a1b14968862d8fdb34afff0dd4829d2aa86 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 14 May 2026 18:10:58 -0400 Subject: [PATCH 100/168] fix: guard ZM_WEB_REFRESH_LOGS against missing Config row The ZM_WEB_{H,M,L}_REFRESH_LOGS settings were added to ConfigData.pm.in recently and won't be present in the Config table until the user runs zmupdate.pl -f. Until then, referencing the constant directly throws an uncaught "Undefined constant" fatal that blocks the entire skin from loading. Use the defined()? ... :0 pattern already in place for ZM_WEB_VIEWING_TIMEOUT so the page renders (with log auto-refresh off) until the Config sync runs. --- web/skins/classic/includes/config.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/skins/classic/includes/config.php b/web/skins/classic/includes/config.php index 84753bc28..09b3cfdc6 100644 --- a/web/skins/classic/includes/config.php +++ b/web/skins/classic/includes/config.php @@ -103,7 +103,7 @@ switch ( $_COOKIE['zmBandwidth'] ) { define( 'ZM_WEB_REFRESH_IMAGE', ZM_WEB_H_REFRESH_IMAGE ); // How often the watched image is refreshed (if not streaming) define( 'ZM_WEB_REFRESH_STATUS', ZM_WEB_H_REFRESH_STATUS ); // How often the little status frame refreshes itself in the watch window define( 'ZM_WEB_REFRESH_EVENTS', ZM_WEB_H_REFRESH_EVENTS ); // How often the event listing is refreshed in the watch window, only for recent events - define( 'ZM_WEB_REFRESH_LOGS', ZM_WEB_H_REFRESH_LOGS ); // How often (in seconds) the listing is refreshed in the log window + define( 'ZM_WEB_REFRESH_LOGS', defined('ZM_WEB_H_REFRESH_LOGS') ? ZM_WEB_H_REFRESH_LOGS : 0 ); // How often (in seconds) the listing is refreshed in the log window define( 'ZM_WEB_CAN_STREAM', ZM_WEB_H_CAN_STREAM ); // Override the automatic detection of browser streaming capability define( 'ZM_WEB_STREAM_METHOD', ZM_WEB_H_STREAM_METHOD ); // Which method should be used to send video streams to your browser define( 'ZM_WEB_DEFAULT_SCALE', ZM_WEB_H_DEFAULT_SCALE ); // What the default scaling factor applied to 'live' or 'event' views is (%) @@ -123,7 +123,7 @@ switch ( $_COOKIE['zmBandwidth'] ) { define( 'ZM_WEB_REFRESH_IMAGE', ZM_WEB_M_REFRESH_IMAGE ); // How often the watched image is refreshed (if not streaming) define( 'ZM_WEB_REFRESH_STATUS', ZM_WEB_M_REFRESH_STATUS ); // How often the little status frame refreshes itself in the watch window define( 'ZM_WEB_REFRESH_EVENTS', ZM_WEB_M_REFRESH_EVENTS ); // How often the event listing is refreshed in the watch window, only for recent events - define( 'ZM_WEB_REFRESH_LOGS', ZM_WEB_M_REFRESH_LOGS ); // How often (in seconds) the listing is refreshed in the log window + define( 'ZM_WEB_REFRESH_LOGS', defined('ZM_WEB_M_REFRESH_LOGS') ? ZM_WEB_M_REFRESH_LOGS : 0 ); // How often (in seconds) the listing is refreshed in the log window define( 'ZM_WEB_CAN_STREAM', ZM_WEB_M_CAN_STREAM ); // Override the automatic detection of browser streaming capability define( 'ZM_WEB_STREAM_METHOD', ZM_WEB_M_STREAM_METHOD ); // Which method should be used to send video streams to your browser define( 'ZM_WEB_DEFAULT_SCALE', ZM_WEB_M_DEFAULT_SCALE ); // What the default scaling factor applied to 'live' or 'event' views is (%) @@ -143,7 +143,7 @@ switch ( $_COOKIE['zmBandwidth'] ) { define( 'ZM_WEB_REFRESH_IMAGE', ZM_WEB_L_REFRESH_IMAGE ); // How often the watched image is refreshed (if not streaming) define( 'ZM_WEB_REFRESH_STATUS', ZM_WEB_L_REFRESH_STATUS ); // How often the little status frame refreshes itself in the watch window define( 'ZM_WEB_REFRESH_EVENTS', ZM_WEB_L_REFRESH_EVENTS ); // How often the event listing is refreshed in the watch window, only for recent events - define( 'ZM_WEB_REFRESH_LOGS', ZM_WEB_L_REFRESH_LOGS ); // How often (in seconds) the listing is refreshed in the log window + define( 'ZM_WEB_REFRESH_LOGS', defined('ZM_WEB_L_REFRESH_LOGS') ? ZM_WEB_L_REFRESH_LOGS : 0 ); // How often (in seconds) the listing is refreshed in the log window define( 'ZM_WEB_CAN_STREAM', ZM_WEB_L_CAN_STREAM ); // Override the automatic detection of browser streaming capability define( 'ZM_WEB_STREAM_METHOD', ZM_WEB_L_STREAM_METHOD ); // Which method should be used to send video streams to your browser define( 'ZM_WEB_DEFAULT_SCALE', ZM_WEB_L_DEFAULT_SCALE ); // What the default scaling factor applied to 'live' or 'event' views is (%) From 52cfaff3fc234c012b3928a0500cea17b06b4943 Mon Sep 17 00:00:00 2001 From: abi Date: Sat, 16 May 2026 01:27:23 +0300 Subject: [PATCH 101/168] typo --- src/zm_signal.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zm_signal.cpp b/src/zm_signal.cpp index 67e4f6b50..a2926a260 100644 --- a/src/zm_signal.cpp +++ b/src/zm_signal.cpp @@ -139,13 +139,13 @@ RETSIGTYPE zm_die_handler(int signal) #elif defined(__aarch64__) #if defined(__FreeBSD__) ip = (void *)(uc->uc_mcontext.mc_gpregs.gp_elr); -#elif +#else ip = (void *)(uc->uc_mcontext.pc); #endif #elif defined(__arm__) #if defined(__FreeBSD__) ip = (void *)(uc->uc_mcontext.__gregs[_REG_PC]); -#elif +#else ip = (void *)(uc->uc_mcontext.arm_pc); #endif #endif From b2d7d6065cfe27da38a73302164c1cb0de8285fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 22:30:11 +0000 Subject: [PATCH 102/168] fix: use getSkinFile() for _monitor_filters.php include path in console.php Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/ec7e935d-1966-49f1-b782-669b625cd648 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- web/ajax/console.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/ajax/console.php b/web/ajax/console.php index 5216ed7c6..e79250cfc 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -60,7 +60,7 @@ function queryRequest() { require_once('includes/Monitor.php'); require_once('includes/Group.php'); require_once('includes/Group_Monitor.php'); - require_once(__DIR__ . '/../views/_monitor_filters.php'); + require_once getSkinFile('views/_monitor_filters.php'); $data = array( 'total' => 0, From 0e532cd77b3debd5a71bb79f4551aadaef53e428 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 19:28:00 -0400 Subject: [PATCH 103/168] fix: correct HLS fragment byte-range and duration tracking refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The m3u8 manifest written by VideoStore had two interlocking bugs that produced duplicate entries with bogus 0.040s durations and a missing or mis-pointed final fragment, which made video.js seek backwards. Root cause: with movflags=frag_keyframe the mov muxer flushes fragment N to disk *inside* av_interleaved_write_frame() when keyframe N+1 arrives. The previous code snapshotted avio_tell() *before* that call, so its last_fragment_offset_ described the next fragment's start while the fragment at that offset hadn't actually been flushed yet. writeM3U8 then push_back'd a tentative entry off that stale state on every live update, and the next keyframe push pushed a second entry at the same offset+size with the correct duration. Now we snapshot avio_tell() *after* av_interleaved_write_frame(), which gives the actual end of the just-flushed fragment, and record fragment N-1 there. writeM3U8 no longer mutates fragments_ — it just emits the list. The final fragment (no later keyframe to close it) is recorded by a new finalize() method on VideoStore that runs av_interleaved_write_frame flush + av_write_trailer, then parses the trailing mfro box to subtract the mfra trailer size from the file length. Event::~Event() calls finalize() before writeM3U8(true); the VideoStore destructor skips its own trailer write when finalize() has already run. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_event.cpp | 7 +- src/zm_videostore.cpp | 156 +++++++++++++++++++++++++++++------------- src/zm_videostore.h | 17 ++++- 3 files changed, 126 insertions(+), 54 deletions(-) diff --git a/src/zm_event.cpp b/src/zm_event.cpp index d941d5565..4bb68eb78 100644 --- a/src/zm_event.cpp +++ b/src/zm_event.cpp @@ -202,9 +202,12 @@ Event::~Event() { /* Close the video file */ // We close the videowriter first, because if we finish the event, we might try to view the file, but we aren't done writing it yet. if (videoStore != nullptr) { - // Finalize last fragment before closing the video store + // Flush the trailer + record the final fragment before writing the m3u8. + // finalize() must run before writeM3U8 so the manifest contains every + // fragment (including the one no later keyframe was around to close). + videoStore->finalize(); + std::string m3u8_path = path + "/index.m3u8"; - // Write temporary m3u8 with incomplete filename (writeM3U8 finalizes last fragment) std::string video_url_tmp = "index.php?view=view_video&eid=" + std::to_string(id) + "&file=" + video_incomplete_file; videoStore->writeM3U8(m3u8_path, video_url_tmp, true); diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index acdc9d231..617662359 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -76,7 +76,8 @@ VideoStore::VideoStore( reorder_queue_size(0), last_fragment_offset_(0), last_fragment_start_dts_(AV_NOPTS_VALUE), - init_segment_end_(0) { + init_segment_end_(0), + finalized_(false) { FFMPEGInit(); swscale.init(); opkt = av_packet_ptr{av_packet_alloc()}; @@ -706,7 +707,7 @@ VideoStore::~VideoStore() { } } - if (oc->pb) { + if (!finalized_ && oc->pb) { flush_codecs(); // Flush Queues @@ -1551,26 +1552,31 @@ int VideoStore::write_packet(AVPacket *pkt, AVStream *stream) { Debug(3, "next_dts for stream %d has become %" PRId64 " last_dts %" PRId64, stream->index, next_dts[stream->index], last_dts[stream->index]); - // HLS fragment tracking: with frag_keyframe movflag, FFmpeg creates a new - // moof+mdat at each video keyframe. We record the byte range of each fragment - // by checking the file position before and after the write call. - // - // Strategy: before writing a video keyframe, snapshot the file position. - // This marks the end of the previous fragment. We record that fragment and - // start tracking the new one. bool is_video_keyframe = (stream == video_out_stream) && (pkt->flags & AV_PKT_FLAG_KEY); + // Snapshot the keyframe's dts before the write call may modify the packet. + int64_t this_keyframe_dts = is_video_keyframe ? pkt->dts : AV_NOPTS_VALUE; + int ret = av_interleaved_write_frame(oc, pkt); + if (ret != 0) { + Error("Error writing packet: %s", av_make_error_string(ret).c_str()); + } else { + Debug(4, "Success writing packet"); + } + + // HLS fragment tracking: with movflags=frag_keyframe, the muxer flushes the + // previous fragment to disk inside av_interleaved_write_frame() when a new + // keyframe arrives. So the position *after* this call equals the end of the + // just-flushed fragment, and last_fragment_offset_/_dts_ describe that + // fragment. Record it, then move tracking to the new fragment. if (is_video_keyframe && oc && oc->pb) { - // Force flush any buffered data so the file position reflects all previous writes avio_flush(oc->pb); - int64_t pos_now = avio_tell(oc->pb); + int64_t pos_after = avio_tell(oc->pb); - if (last_fragment_start_dts_ != AV_NOPTS_VALUE && pos_now > last_fragment_offset_) { - // Record the completed fragment - int64_t frag_size = pos_now - last_fragment_offset_; + if (last_fragment_start_dts_ != AV_NOPTS_VALUE && pos_after > last_fragment_offset_) { + int64_t frag_size = pos_after - last_fragment_offset_; double duration = 0; if (video_out_stream->time_base.den > 0) { - duration = static_cast(pkt->dts - last_fragment_start_dts_) + duration = static_cast(this_keyframe_dts - last_fragment_start_dts_) * video_out_stream->time_base.num / video_out_stream->time_base.den; } @@ -1580,49 +1586,101 @@ int VideoStore::write_packet(AVPacket *pkt, AVStream *stream) { fragments_.size() - 1, last_fragment_offset_, frag_size, duration); } } - // New fragment starts here - last_fragment_offset_ = pos_now; - last_fragment_start_dts_ = pkt->dts; - } - - // Initialize tracking after init segment is written - if (last_fragment_start_dts_ == AV_NOPTS_VALUE && is_video_keyframe) { - if (oc && oc->pb) { - last_fragment_offset_ = avio_tell(oc->pb); - } - last_fragment_start_dts_ = pkt->dts; - } - - int ret = av_interleaved_write_frame(oc, pkt); - if (ret != 0) { - Error("Error writing packet: %s", av_make_error_string(ret).c_str()); - } else { - Debug(4, "Success writing packet"); + last_fragment_offset_ = pos_after; + last_fragment_start_dts_ = this_keyframe_dts; } return ret; } // end int VideoStore::write_packet(AVPacket *pkt, AVStream *stream) -void VideoStore::writeM3U8(const std::string &m3u8_path, const std::string &video_url, bool is_complete) { - // Finalize last fragment if there's data after the last recorded fragment - if (oc && oc->pb) { - int64_t file_end = avio_tell(oc->pb); - if (file_end > last_fragment_offset_ && last_fragment_start_dts_ != AV_NOPTS_VALUE) { - int64_t frag_size = file_end - last_fragment_offset_; - // Estimate duration from last known DTS - double duration = 0; - if (video_out_stream && video_out_stream->time_base.den > 0 && - last_dts.count(video_out_stream->index) && last_dts[video_out_stream->index] != AV_NOPTS_VALUE) { - duration = static_cast(last_dts[video_out_stream->index] + last_duration[video_out_stream->index] - last_fragment_start_dts_) - * video_out_stream->time_base.num - / video_out_stream->time_base.den; - } - if (duration > 0 && frag_size > 0) { - fragments_.push_back({last_fragment_offset_, frag_size, duration}); +void VideoStore::finalize() { + if (finalized_) return; + finalized_ = true; + + if (!oc || !oc->pb) return; + + flush_codecs(); + + Debug(4, "Flushing interleaved queues"); + av_interleaved_write_frame(oc, nullptr); + + Debug(1, "Writing trailer"); + int rc = av_write_trailer(oc); + if (rc < 0) { + Error("Error writing trailer %s", av_err2str(rc)); + } else { + Debug(3, "Success Writing trailer"); + } + + // After av_write_trailer, the file contains init+fragments_1..N + mfra trailer. + // Capture the on-disk length so we can size the final fragment. + avio_flush(oc->pb); + int64_t file_size = avio_tell(oc->pb); + + // Close the output file before reading it back to inspect the mfra box. + if (!(out_format->flags & AVFMT_NOFILE)) { + Debug(4, "Closing"); + if ((rc = avio_close(oc->pb)) < 0) { + Error("Error closing avio %s", av_err2str(rc)); + } + } + oc->pb = nullptr; + + // The MOV muxer writes an mfra (Movie Fragment Random Access) box at the end + // of the file when fragmentation is on. Its trailing mfro box is exactly 16 + // bytes and contains the mfra size, so we can subtract that to find where + // the final fragment's mdat actually ends. + int64_t fragment_n_end = file_size; + if (filename && file_size >= 16) { + FILE *fp = fopen(filename, "rb"); + if (fp) { + if (fseeko(fp, file_size - 16, SEEK_SET) == 0) { + uint8_t mfro[16]; + if (fread(mfro, 1, 16, fp) == 16) { + uint32_t box_size = (static_cast(mfro[0]) << 24) + | (static_cast(mfro[1]) << 16) + | (static_cast(mfro[2]) << 8) + | static_cast(mfro[3]); + if (box_size == 16 + && mfro[4] == 'm' && mfro[5] == 'f' && mfro[6] == 'r' && mfro[7] == 'o') { + uint32_t mfra_size = (static_cast(mfro[12]) << 24) + | (static_cast(mfro[13]) << 16) + | (static_cast(mfro[14]) << 8) + | static_cast(mfro[15]); + if (mfra_size > 0 && static_cast(mfra_size) <= file_size) { + fragment_n_end = file_size - mfra_size; + Debug(1, "mfra trailer is %u bytes; final fragment ends at %" PRId64, + mfra_size, fragment_n_end); + } + } + } } + fclose(fp); } } + // Record the final fragment that no subsequent keyframe was around to record. + if (last_fragment_start_dts_ != AV_NOPTS_VALUE + && fragment_n_end > last_fragment_offset_ + && video_out_stream && video_out_stream->time_base.den > 0 + && last_dts.count(video_out_stream->index) + && last_dts[video_out_stream->index] != AV_NOPTS_VALUE) { + int64_t frag_size = fragment_n_end - last_fragment_offset_; + double duration = static_cast( + last_dts[video_out_stream->index] + + last_duration[video_out_stream->index] + - last_fragment_start_dts_) + * video_out_stream->time_base.num + / video_out_stream->time_base.den; + if (duration > 0 && frag_size > 0) { + fragments_.push_back({last_fragment_offset_, frag_size, duration}); + Debug(1, "HLS final fragment: offset=%" PRId64 " size=%" PRId64 " duration=%.3f", + last_fragment_offset_, frag_size, duration); + } + } +} + +void VideoStore::writeM3U8(const std::string &m3u8_path, const std::string &video_url, bool is_complete) { if (fragments_.empty()) return; // Calculate max duration for EXT-X-TARGETDURATION (must be integer, rounded up) diff --git a/src/zm_videostore.h b/src/zm_videostore.h index 855227e88..852e05f38 100644 --- a/src/zm_videostore.h +++ b/src/zm_videostore.h @@ -93,11 +93,17 @@ class VideoStore { size_t reorder_queue_size; std::map>> reorder_queues; - // HLS fragment tracking + // HLS fragment tracking. With movflags=frag_keyframe, FFmpeg's mov muxer + // doesn't write a fragment to disk until the *next* keyframe arrives (or + // until av_write_trailer is called). So when keyframe N arrives, fragment + // N-1 is what just got flushed. We snapshot avio_tell *after* + // av_interleaved_write_frame() to capture the position past that flush, and + // record fragment N-1 then. std::vector fragments_; - int64_t last_fragment_offset_; // byte offset where current fragment started - int64_t last_fragment_start_dts_; // DTS of first video keyframe in current fragment + int64_t last_fragment_offset_; // byte offset where the current (in-progress) fragment starts + int64_t last_fragment_start_dts_; // DTS of the keyframe that started the current fragment int64_t init_segment_end_; // byte offset where init segment (ftyp+moov) ends + bool finalized_; // true once finalize() has run trailer + last-fragment recording bool setup_resampler(); int write_packet(AVPacket *pkt, AVStream *stream); @@ -124,6 +130,11 @@ class VideoStore { const std::vector &fragments() const { return fragments_; } int64_t init_segment_end() const { return init_segment_end_; } void writeM3U8(const std::string &path, const std::string &video_url, bool is_complete); + // Flush queues, write trailer, close output, and record the final fragment. + // Call this before writeM3U8(true) so the manifest contains every fragment. + // Safe to call once; subsequent calls are no-ops. The destructor will skip + // the trailer write if finalize() has already run. + void finalize(); const char *get_codec() { if (chosen_codec_data) From 671e1c361fe91b8f3dcec25dc188390cb328d150 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 23:01:00 -0400 Subject: [PATCH 104/168] fix: stop warning on URL/session user mismatch in getAuthUser The auth hash is stateless (HMAC over secret+username+password+IP+date) and intentionally independent of the PHP session. Stream/image URLs minted while user A was logged in keep working for user A's hash even after the active session has rotated to B (re-login, session timeout + new login, another tab, etc.), which is the design. The eager Warning was firing on these legitimate cross-session requests and producing log noise. A genuinely tampered request without a valid hash still falls through to the existing failure-path Info log, which now also reports sessionUser for diagnostics. --- web/includes/auth.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/web/includes/auth.php b/web/includes/auth.php index 022825407..a1d45c74b 100644 --- a/web/includes/auth.php +++ b/web/includes/auth.php @@ -193,15 +193,6 @@ function getAuthUser($auth) { $sessionUser = isset($_SESSION['username']) ? $_SESSION['username'] : null; $filterUser = $requestedUser !== null ? $requestedUser : $sessionUser; - if ($requestedUser !== null && $sessionUser !== null) { - $usersMatch = ZM_CASE_INSENSITIVE_USERNAMES - ? (strcasecmp($requestedUser, $sessionUser) === 0) - : ($requestedUser === $sessionUser); - if (!$usersMatch) { - ZM\Warning("Auth user mismatch: URL user='$requestedUser' but session username='$sessionUser'. This may indicate a stale auth hash from a previous login, cross-tab session contamination, or a tampered request."); - } - } - ZM\Debug("getAuthUser: validating auth='$auth' filterUser='".($filterUser ?? '')."' xff='$xff' directAddr='$directAddr' usingRemoteAddr='$remoteAddr' session_username='".($sessionUser ?? '')."'"); $sql = 'SELECT * FROM Users WHERE Enabled = 1'; @@ -257,7 +248,7 @@ function getAuthUser($auth) { } // end foreach user } // end if - ZM\Info("Unable to authenticate user from auth hash '$auth' (filterUser='".($filterUser ?? '')."' xff='$xff' directAddr='$directAddr' rowsTried=$rowsTried ttl=".ZM_AUTH_HASH_TTL.'h)'); + ZM\Info("Unable to authenticate user from auth hash '$auth' (filterUser='".($filterUser ?? '')."' sessionUser='".($sessionUser ?? '')."' xff='$xff' directAddr='$directAddr' rowsTried=$rowsTried ttl=".ZM_AUTH_HASH_TTL.'h)'); return null; } // end if using auth hash From 61c4c6606b575a2eecb68d40f08c9f09bd2c3d30 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 23:01:00 -0400 Subject: [PATCH 105/168] perf: scope Event neighbor queries to Id only The view() action sets recursive=1 on the Event model, which the subsequent find('neighbors') calls inherited. That made each of the four neighbor lookups (prev/next, prevOfMonitor/nextOfMonitor) SELECT every column from Events plus LEFT JOIN Monitor and Storage, then fire a separate Frames hasMany query per neighbor row. Only Event.Id is used downstream. Pass fields=Event.Id and recursive=-1 on each neighbor call so the generated SQL is just: SELECT Event.Id FROM Events AS Event WHERE Event.Id < ? ORDER BY Event.Id DESC LIMIT 1 The per-monitor variant uses Events_MonitorId_idx which already covers (MonitorId, Id) via InnoDB's implicit PK suffix, so no schema change is needed. --- web/api/app/Controller/EventsController.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/web/api/app/Controller/EventsController.php b/web/api/app/Controller/EventsController.php index cf81de629..de8265d3f 100644 --- a/web/api/app/Controller/EventsController.php +++ b/web/api/app/Controller/EventsController.php @@ -262,9 +262,14 @@ class EventsController extends AppController { return; } - # Get the previous and next events for any monitor + # Get the previous and next events for any monitor. + # Only Id is used below, so skip the wide SELECT + Monitor/Storage joins + Frames hasMany expansion + # that recursive=1 from above would otherwise pull in for each neighbor row. $this->Event->id = $id; - $event_neighbors = $this->Event->find('neighbors'); + $event_neighbors = $this->Event->find('neighbors', array( + 'fields' => array('Event.Id'), + 'recursive' => -1, + )); $event['Event']['Next'] = isset($event_neighbors['next']) ? $event_neighbors['next']['Event']['Id'] : 0; $event['Event']['Prev'] = isset($event_neighbors['prev']) ? $event_neighbors['prev']['Event']['Id'] : 0; @@ -274,7 +279,9 @@ class EventsController extends AppController { # Also get the previous and next events for the same monitor $event_monitor_neighbors = $this->Event->find('neighbors', array( - 'conditions'=>array('Event.MonitorId'=>$event['Event']['MonitorId']) + 'fields' => array('Event.Id'), + 'recursive' => -1, + 'conditions' => array('Event.MonitorId' => $event['Event']['MonitorId']), )); $event['Event']['NextOfMonitor'] = isset($event_monitor_neighbors['next']) ? $event_monitor_neighbors['next']['Event']['Id'] : 0; $event['Event']['PrevOfMonitor'] = isset($event_monitor_neighbors['prev']) ? $event_monitor_neighbors['prev']['Event']['Id'] : 0; From 47d3af70c73f5570f6769d17a2073f657da07ffc Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 23:54:32 -0400 Subject: [PATCH 106/168] fix: keep DefaultVideo='index.m3u8' for events with an HLS manifest refs #4757 Event::~Event() rewrote DefaultVideo to the renamed mp4 filename on every close, which made event.php's HLS detection (str_ends_with DefaultVideo, '.m3u8') return false for every closed event. The HLS player only kicked in for in-progress events or when MP4HLS was manually picked from the codec dropdown. Detect index.m3u8 in the same dirent walk that already computes video_size, and write 'index.m3u8' to DefaultVideo when it's present. mp4-only events keep using the renamed mp4 as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_event.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/zm_event.cpp b/src/zm_event.cpp index 4bb68eb78..cc50f1b35 100644 --- a/src/zm_event.cpp +++ b/src/zm_event.cpp @@ -258,18 +258,27 @@ Event::~Event() { if (frame_data.size()) WriteDbFrames(); uint64_t video_size = 0; + bool has_m3u8 = false; DIR *video_dir; if ((video_dir = opendir(path.c_str())) != NULL) { struct dirent *dir_entry; while ((dir_entry = readdir(video_dir)) != NULL) { struct stat vf_stat; if (stat((path + "/" + dir_entry->d_name).c_str(), &vf_stat) == 0 && - S_ISREG(vf_stat.st_mode)) + S_ISREG(vf_stat.st_mode)) { video_size += vf_stat.st_size; + if (!strcmp(dir_entry->d_name, "index.m3u8")) has_m3u8 = true; + } } closedir(video_dir); } + // Prefer index.m3u8 as DefaultVideo when an HLS manifest was written, so + // event.php picks the HLS player on closed events too — otherwise the player + // selection logic falls back to direct mp4 playback for everything except a + // manual MP4HLS codec choice. + std::string default_video = has_m3u8 ? "index.m3u8" : video_file; + // Use async dbQueue instead of synchronous zmDbDoUpdate to avoid blocking // the close_event_thread (which blocks the analysis thread on the next closeEvent). // Conditionally update Name only if it hasn't been changed by the user during recording. @@ -286,7 +295,7 @@ Event::~Event() { frames, alarm_frames, tot_score, static_cast(alarm_frames ? (tot_score / alarm_frames) : 0), max_score, max_score_frame_id, - video_file.c_str(), // defaults to "" + default_video.c_str(), video_size, id); dbQueue.push(std::move(sql)); From 10a4f9af136063c90a0ad5f7627fa426dd3642e0 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 23:54:40 -0400 Subject: [PATCH 107/168] fix: pick HLS player when index.m3u8 exists, not from DefaultVideo refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed events that have already had their DefaultVideo column rewritten to the renamed mp4 (every event recorded before the C++ fix landed) need a client-side fallback so the HLS player still gets picked up. Treat the on-disk index.m3u8 as the source of truth for HLS availability and gate on the codec choice — auto / MP4 / MP4HLS all use HLS when the manifest is there; MJPEG and any other explicit non-video choice keep the old behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/skins/classic/views/event.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 3b753fff4..fe7928549 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -354,9 +354,13 @@ if (file_exists($Event->Path().'/objdetect.jpg')) {
DefaultVideo(), '.m3u8')) - && file_exists($Event->Path() . '/index.m3u8'); + // Prefer HLS byte-range playback whenever the manifest exists on disk and the + // user hasn't explicitly opted into a non-HLS playback mode. Closed events + // have DefaultVideo rewritten to the renamed mp4, so checking the file system + // is what catches them. + $has_hls = file_exists($Event->Path() . '/index.m3u8') + && (($codec == 'MP4HLS') || ($codec == 'MP4') || ($codec == 'auto') + || str_ends_with($Event->DefaultVideo(), '.m3u8')); if ($has_hls) { $Server = $Event->Server(); $hlsSrc = $Server->PathToIndex() . '?view=view_hls&eid=' . $Event->Id(); From 275b675ac857d1d8e58103535cb2a5c453f5303f Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 11:21:21 -0400 Subject: [PATCH 108/168] =?UTF-8?q?fix:=20stop=20view=5Fhls.php=20emitting?= =?UTF-8?q?=20/zm/index.php=3Findex.php=3Fview=3D=E2=80=A6=20refs=20#4757?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The segment-URL rewrite captured "index.php?…" in $1 and then prepended $base_url . '?', producing a double-prefixed URL that drops the view= routing parameter. Players fetched the manifest itself instead of the mp4 byte range, so Firefox showed a spinner and Chrome silently failed. Capture only the query string (everything after "index.php?") for both the bare segment URLs and the EXT-X-MAP URI rewrite. Same fix that landed (unmerged) in PR #4803 and PR #4806; pulled in here so the full HLS path can be reviewed and merged together. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/views/view_hls.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/views/view_hls.php b/web/views/view_hls.php index 9502b2f24..8b5c63a59 100644 --- a/web/views/view_hls.php +++ b/web/views/view_hls.php @@ -57,16 +57,19 @@ $content = file_get_contents($m3u8_path); $Server = $Event->Server(); $base_url = $Server->PathToIndex(); -// Replace bare URLs with full paths including auth +// Replace bare relative segment URLs with full paths including auth. +// The m3u8 has lines like "index.php?view=view_video&eid=N&file=F" — capture +// only the query string (after "index.php?") so the replacement doesn't emit +// "/zm/index.php?index.php?view=…". $content = preg_replace( - '/^(index\.php\?.+)$/m', + '/^index\.php\?(.+)$/m', $base_url . '?$1' . $auth_query, $content ); -// Also fix the EXT-X-MAP URI +// Also fix the EXT-X-MAP URI (initialization segment) the same way. $content = preg_replace( - '/URI="(index\.php\?[^"]+)"/m', + '/URI="index\.php\?([^"]+)"/m', 'URI="' . $base_url . '?$1' . $auth_query . '"', $content ); From 20da5e5f1204f1e4ae37db039cfd02947dc5ac78 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 11:21:21 -0400 Subject: [PATCH 109/168] fix: report served mp4 filename in view_video.php Content-Disposition refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When DefaultVideo is 'index.m3u8' but the view_video.php fallback resolves $path to the actual mp4 (so byte-range playback works), the Content-Disposition header still advertised filename='index.m3u8'. The download button then saved a playlist instead of video. Derive $filename from $path unconditionally — after the fallback runs, $path is always the file we're streaming, regardless of mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/views/view_video.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/views/view_video.php b/web/views/view_video.php index dff454f44..ea825e2a3 100644 --- a/web/views/view_video.php +++ b/web/views/view_video.php @@ -87,7 +87,10 @@ if ( ! ($fh = @fopen($path, 'rb') ) ) { header('HTTP/1.0 404 Not Found'); die(); } -$filename = ($mode == 'mp4') ? basename($path) : (($Event) ? $Event->DefaultVideo() : ''); +// Always derive the filename from the resolved $path: after the m3u8 fallback +// above, $path can point at an mp4 even when DefaultVideo is 'index.m3u8', so +// reporting DefaultVideo would advertise a manifest while serving mp4 bytes. +$filename = basename($path); $size = filesize($path); $begin = 0; From e378da3d1b0487017566a1cb1138c5245f81590d Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 11:21:21 -0400 Subject: [PATCH 110/168] fix: disable video.js liveui so HLS scrubbing works on in-progress events refs #4757 liveui:true replaces the seekbar with a live-edge-only control, which prevents the user from scrubbing back through the already-recorded portion of an ongoing event. Always render the standard seekbar. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/skins/classic/views/event.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index fe7928549..80c537055 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -406,7 +406,11 @@ if ($video_tag) { autoplay: true, preload: 'auto', playbackRates: rates, - liveui: EndDateTime() ? 'true' : 'false' ?>, + // liveui replaces the seekbar with a live-edge-only control, + // which makes it impossible to scrub back through the already- + // recorded portion of an in-progress event. Always false so the + // standard seekbar is rendered. + liveui: false, liveTracker: { trackingThreshold: 0 } From 375e82fd88fd69662eb4937906a9fcf70ffca920 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 11:47:42 -0400 Subject: [PATCH 111/168] fix: don't force HLS when user explicitly picked MP4 codec refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10a4f9af1 included $codec == 'MP4' in the HLS-eligibility check, which broke native mp4 playback whenever an index.m3u8 was present — picking MP4 from the dropdown silently went through HLS instead. MP4 means "play the mp4 file directly"; honor that. The MP4HLS / auto / DefaultVideo-ends-in-.m3u8 conditions still cover closed events whose stored DefaultVideo predates the C++ fix that preserves 'index.m3u8'. Reported by @IgorA100 on commit 10a4f9af1. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/skins/classic/views/event.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 80c537055..891442acc 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -354,12 +354,13 @@ if (file_exists($Event->Path().'/objdetect.jpg')) {
Path() . '/index.m3u8') - && (($codec == 'MP4HLS') || ($codec == 'MP4') || ($codec == 'auto') + && (($codec == 'MP4HLS') || ($codec == 'auto') || str_ends_with($Event->DefaultVideo(), '.m3u8')); if ($has_hls) { $Server = $Event->Server(); From 4182a829db6c9d801903f23f0a335fbaaa42de5e Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 12:01:19 -0400 Subject: [PATCH 112/168] fix: drain VideoStore reorder_queues inside finalize() refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destructor drained reorder_queues by calling writeVideoFramePacket / writeAudioFramePacket, which call av_interleaved_write_frame on oc. finalize() closes oc->pb, so when Event::~Event() calls finalize() and then deletes the VideoStore, the destructor's queue drain would write into a closed output — undefined behavior at best, silently dropping the trailing reordered frames at worst. Move the drain into finalize() ahead of the trailer write, and gate the destructor's drain on !finalized_ so it still runs in the legacy path where nobody called finalize(). Reported by Copilot on PR #4835. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_videostore.cpp | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index 617662359..c8732bdba 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -692,18 +692,21 @@ Debug(1, "Done flushing"); VideoStore::~VideoStore() { - for (auto &n : reorder_queues) { - auto &queue = n.second; - Debug(1, "Queue for %d length is %zu", n.first, queue.size()); - while (!queue.empty()) { - auto pkt = queue.front(); - queue.pop_front(); - if (pkt->codec_type == AVMEDIA_TYPE_VIDEO) { - writeVideoFramePacket(pkt); - } else if (pkt->codec_type == AVMEDIA_TYPE_AUDIO) { - writeAudioFramePacket(pkt); + // When finalize() has run it already drained these queues; doing it again + // would push packets into a closed AVFormatContext. + if (!finalized_) { + for (auto &n : reorder_queues) { + auto &queue = n.second; + Debug(1, "Queue for %d length is %zu", n.first, queue.size()); + while (!queue.empty()) { + auto pkt = queue.front(); + queue.pop_front(); + if (pkt->codec_type == AVMEDIA_TYPE_VIDEO) { + writeVideoFramePacket(pkt); + } else if (pkt->codec_type == AVMEDIA_TYPE_AUDIO) { + writeAudioFramePacket(pkt); + } } - //delete pkt; } } @@ -1599,6 +1602,23 @@ void VideoStore::finalize() { if (!oc || !oc->pb) return; + // Drain reorder queues before writing the trailer — the destructor would + // otherwise try to run these packets through av_interleaved_write_frame() + // after we've already closed oc->pb here. + for (auto &n : reorder_queues) { + auto &queue = n.second; + Debug(1, "Queue for %d length is %zu", n.first, queue.size()); + while (!queue.empty()) { + auto pkt = queue.front(); + queue.pop_front(); + if (pkt->codec_type == AVMEDIA_TYPE_VIDEO) { + writeVideoFramePacket(pkt); + } else if (pkt->codec_type == AVMEDIA_TYPE_AUDIO) { + writeAudioFramePacket(pkt); + } + } + } + flush_codecs(); Debug(4, "Flushing interleaved queues"); From 2e83b8b0741dbe313e66faa2cef8588ef62e9e30 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 12:01:19 -0400 Subject: [PATCH 113/168] revert: stop forcing DefaultVideo='index.m3u8' for HLS events refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 47d3af70c rewrote DefaultVideo to 'index.m3u8' on close so event.php would pick HLS for closed events. But several PHP call sites use DefaultVideo as a local file path passed to ffmpeg or to filesize() — Event.php's getImageSrc(), Length(), Filesize(), and Video() all rely on it being a real video filename. ffmpeg can't resolve our manifest's relative "index.php?…" segment URLs, so thumbnail/frame generation would break for events with SaveJPEGs disabled. The PHP-side fallback (10a4f9af1, 375e82fd8) keys HLS selection off the on-disk index.m3u8 plus the codec choice, which is enough on its own to route closed events through HLS without touching DefaultVideo. Reported by Copilot on PR #4835. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_event.cpp | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/zm_event.cpp b/src/zm_event.cpp index cc50f1b35..4bb68eb78 100644 --- a/src/zm_event.cpp +++ b/src/zm_event.cpp @@ -258,27 +258,18 @@ Event::~Event() { if (frame_data.size()) WriteDbFrames(); uint64_t video_size = 0; - bool has_m3u8 = false; DIR *video_dir; if ((video_dir = opendir(path.c_str())) != NULL) { struct dirent *dir_entry; while ((dir_entry = readdir(video_dir)) != NULL) { struct stat vf_stat; if (stat((path + "/" + dir_entry->d_name).c_str(), &vf_stat) == 0 && - S_ISREG(vf_stat.st_mode)) { + S_ISREG(vf_stat.st_mode)) video_size += vf_stat.st_size; - if (!strcmp(dir_entry->d_name, "index.m3u8")) has_m3u8 = true; - } } closedir(video_dir); } - // Prefer index.m3u8 as DefaultVideo when an HLS manifest was written, so - // event.php picks the HLS player on closed events too — otherwise the player - // selection logic falls back to direct mp4 playback for everything except a - // manual MP4HLS codec choice. - std::string default_video = has_m3u8 ? "index.m3u8" : video_file; - // Use async dbQueue instead of synchronous zmDbDoUpdate to avoid blocking // the close_event_thread (which blocks the analysis thread on the next closeEvent). // Conditionally update Name only if it hasn't been changed by the user during recording. @@ -295,7 +286,7 @@ Event::~Event() { frames, alarm_frames, tot_score, static_cast(alarm_frames ? (tot_score / alarm_frames) : 0), max_score, max_score_frame_id, - default_video.c_str(), + video_file.c_str(), // defaults to "" video_size, id); dbQueue.push(std::move(sql)); From af375e78dc98114e2d3a97283d5f7f64ff3e8c36 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sat, 16 May 2026 23:44:12 +0300 Subject: [PATCH 114/168] Managing the availability of the clear logs button (log.js) --- web/skins/classic/views/js/log.js | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index 7f3880ed5..f46202d05 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -116,6 +116,7 @@ function manageClearLogsModalBtns() { deleteLogs(getIdSelections()); }); document.getElementById('clearLogsCancelBtn').addEventListener('click', function onClearLogsCancelClick(evt) { + manageClearButtonAvailability(); $j('#clearLogsConfirm').modal('hide'); }); } @@ -204,6 +205,7 @@ function initPage() { const clearLogsBtn = document.getElementById('clearLogsBtn'); if (clearLogsBtn) { clearLogsBtn.addEventListener('click', function onClearLogsClick(evt) { + manageClearButtonAvailability(false); evt.preventDefault(); if (evt.ctrlKey) { // Bypass confirmation, but ensure the modal (and its ticker) exists @@ -216,6 +218,7 @@ function initPage() { deleteLogs(getIdSelections()); }) .fail(function(jqXHR) { + manageClearButtonAvailability(); console.log('error getting clearlogsconfirm', jqXHR); logAjaxFail(jqXHR); }); @@ -231,6 +234,7 @@ function initPage() { $j('#clearLogsConfirm').modal('show'); }) .fail(function(jqXHR) { + manageClearButtonAvailability(); console.log('error getting clearlogsconfirm', jqXHR); logAjaxFail(jqXHR); }); @@ -243,12 +247,10 @@ function initPage() { } // Enable or disable clear button based on selection - table.on('check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table', function() { - const selections = table.bootstrapTable('getSelections'); - const clearLogsBtn = document.getElementById('clearLogsBtn'); - if (clearLogsBtn) { - clearLogsBtn.disabled = !selections.length; - } + table.on('check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table', manageClearButtonAvailability); + + table.on('load-success.bs.table', function() { + manageClearButtonAvailability(); }); $j('#filterStartDateTime, #filterEndDateTime') @@ -259,6 +261,17 @@ function initPage() { .on('change', filterLog); } +function manageClearButtonAvailability(enable = null) { + const selections = table.bootstrapTable('getSelections'); + const clearLogsBtn = document.getElementById('clearLogsBtn'); + if (clearLogsBtn) { + if (enable === false || !selections.length) + clearLogsBtn.disabled = true; + else if (enable === true || selections.length) + clearLogsBtn.disabled = false; + } +} + $j(document).ready(function() { initPage(); }); From ae84fbde7229deaf67174fd0a98dced7b49e8386 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 17 May 2026 00:21:40 +0300 Subject: [PATCH 115/168] Save the selected filter level in the session (log.php) --- web/ajax/log.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/ajax/log.php b/web/ajax/log.php index 60c54e902..4f555d843 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -185,6 +185,10 @@ function queryRequest() { $where .= ' Level = ?'; $query['values'][] = $level_codes[$L]; } + zm_session_start(); + $_SESSION['zmLogFilterLevel'] = $L; + session_write_close(); + if (!empty($_REQUEST['StartDateTime'])) { $start_time = strtotime($_REQUEST['StartDateTime']); if ($start_time) { From 3dc9e9a905e9603e83a68ad978133b09c6611c6c Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 17 May 2026 00:29:01 +0300 Subject: [PATCH 116/168] Renamed session 'ZM_LOG_FILTER_LEVEL' to 'zmLogFilterLevel' (log.php) --- web/skins/classic/views/log.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/log.php b/web/skins/classic/views/log.php index a8d345b10..c689cd1cc 100644 --- a/web/skins/classic/views/log.php +++ b/web/skins/classic/views/log.php @@ -101,7 +101,7 @@ foreach (array_values(ZM\Logger::$codes) as $level) { } echo ''; echo htmlSelect('filterLevel', $levels, - (isset($_SESSION['ZM_LOG_FILTER_LEVEL']) ? $_SESSION['ZM_LOG_FILTER_LEVEL'] : ''), + (isset($_SESSION['zmLogFilterLevel']) ? $_SESSION['zmLogFilterLevel'] : ''), array('data-on-change'=>'filterLog', 'id'=>'filterLevel', 'class'=>'chosen')); #array('class'=>'form-control chosen', 'data-on-change'=>'filterLog')); echo ''; From 26f2d819c55560a72faf221d3089b3a1fecc6236 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 17 May 2026 00:40:14 +0300 Subject: [PATCH 117/168] Avoid endless AJAX table update requests if the query returned 0 rows on the Log page. (log.js) --- web/skins/classic/views/js/log.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index 7f3880ed5..a9a263890 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -56,7 +56,9 @@ function ajaxRequest(params) { success: function(data) { if (!data.rows.length) { // If page is > 1, bt infinitely loops - table.bootstrapTable('selectPage', 1); + // IgorA100 commented out the code below because it leads to endless table updates if the query returns 0 rows. + // This may have been a hack due to incorrect page pagination, which was fixed in https://github.com/ZoneMinder/zoneminder/pull/4818 + //table.bootstrapTable('selectPage', 1); } // rearrange the result into what bootstrap-table expects params.success({ From 5c0532fe3bebe30e563e34fd0bf4f771fa6a3f2e Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 18:15:19 -0400 Subject: [PATCH 118/168] fix: drop DefaultVideo extension check from \$has_hls refs #4757 The str_ends_with(\$Event->DefaultVideo(), '.m3u8') clause overrode the user's codec choice for in-progress events: DefaultVideo is set to 'index.m3u8' by Event's constructor, so picking MP4 from the dropdown still routed through HLS. Base \$has_hls solely on on-disk manifest presence + codec being MP4HLS or auto. MJPEG already short-circuits earlier via \$video_tag. Reported by Copilot on PR #4835. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/skins/classic/views/event.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 891442acc..38563a802 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -355,13 +355,13 @@ if (file_exists($Event->Path().'/objdetect.jpg')) { Path() . '/index.m3u8') - && (($codec == 'MP4HLS') || ($codec == 'auto') - || str_ends_with($Event->DefaultVideo(), '.m3u8')); + && (($codec == 'MP4HLS') || ($codec == 'auto')); if ($has_hls) { $Server = $Event->Server(); $hlsSrc = $Server->PathToIndex() . '?view=view_hls&eid=' . $Event->Id(); From 4aa62a4d82efc0bbb27637dd84f87b0140d96791 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 18:15:20 -0400 Subject: [PATCH 119/168] fix: VideoStore filename use-after-free + null-oc destructor guard refs #4757 Two crash-class bugs reported by Copilot on PR #4835: 1. filename was held as const char* pointing into a caller-owned std::string. Event::AddPacket_() can rename the underlying file and reassign video_incomplete_path, invalidating that pointer; finalize() then fopens through the dangling pointer to parse the mfro trailer. Store filename as std::string inside VideoStore so the storage lives as long as the object does. 2. VideoStore::~VideoStore() dereferenced oc->pb and ran the reorder- queue drain without checking oc. If open() bailed before allocating oc, both paths would crash. Guard on oc being non-null. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_videostore.cpp | 30 ++++++++++++++++-------------- src/zm_videostore.h | 5 ++++- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index c8732bdba..a3b2e97dd 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -62,7 +62,7 @@ VideoStore::VideoStore( resample_ctx(nullptr), fifo(nullptr), converted_in_samples(nullptr), - filename(filename_in), + filename(filename_in ? filename_in : ""), format(format_in), video_first_pts(AV_NOPTS_VALUE), video_first_dts(AV_NOPTS_VALUE), @@ -85,24 +85,24 @@ VideoStore::VideoStore( /* Failure to open audio will not be a total failure. */ bool VideoStore::open() { - Debug(1, "Opening video storage stream %s format: %s", filename, format); + Debug(1, "Opening video storage stream %s format: %s", filename.c_str(), format); - int ret = avformat_alloc_output_context2(&oc, nullptr, nullptr, filename); + int ret = avformat_alloc_output_context2(&oc, nullptr, nullptr, filename.c_str()); if (ret < 0) { Warning( "Could not create video storage stream %s as no out ctx" " could be assigned based on filename: %s", - filename, av_make_error_string(ret).c_str()); + filename.c_str(), av_make_error_string(ret).c_str()); } // Couldn't deduce format from filename, trying from format name if (!oc) { - avformat_alloc_output_context2(&oc, nullptr, format, filename); + avformat_alloc_output_context2(&oc, nullptr, format, filename.c_str()); if (!oc) { Error( "Could not create video storage stream %s as no out ctx" " could not be assigned based on filename or format %s", - filename, format); + filename.c_str(), format); return false; } } // end if ! oc @@ -522,9 +522,9 @@ bool VideoStore::open() { /* open the out file, if needed */ if (!(out_format->flags & AVFMT_NOFILE)) { - ret = avio_open2(&oc->pb, filename, AVIO_FLAG_WRITE, nullptr, nullptr); + ret = avio_open2(&oc->pb, filename.c_str(), AVIO_FLAG_WRITE, nullptr, nullptr); if (ret < 0) { - Error("Could not open out file '%s': %s", filename, av_make_error_string(ret).c_str()); + Error("Could not open out file '%s': %s", filename.c_str(), av_make_error_string(ret).c_str()); return false; } } @@ -563,7 +563,7 @@ bool VideoStore::open() { av_dict_free(&opts); if (ret < 0) { Error("Error occurred when writing out file header to %s: %s", - filename, av_make_error_string(ret).c_str()); + filename.c_str(), av_make_error_string(ret).c_str()); avio_closep(&oc->pb); return false; } @@ -693,8 +693,10 @@ Debug(1, "Done flushing"); VideoStore::~VideoStore() { // When finalize() has run it already drained these queues; doing it again - // would push packets into a closed AVFormatContext. - if (!finalized_) { + // would push packets into a closed AVFormatContext. Also skip when oc was + // never allocated (open() failed before assigning oc) — the write helpers + // dereference oc. + if (!finalized_ && oc) { for (auto &n : reorder_queues) { auto &queue = n.second; Debug(1, "Queue for %d length is %zu", n.first, queue.size()); @@ -710,7 +712,7 @@ VideoStore::~VideoStore() { } } - if (!finalized_ && oc->pb) { + if (!finalized_ && oc && oc->pb) { flush_codecs(); // Flush Queues @@ -1651,8 +1653,8 @@ void VideoStore::finalize() { // bytes and contains the mfra size, so we can subtract that to find where // the final fragment's mdat actually ends. int64_t fragment_n_end = file_size; - if (filename && file_size >= 16) { - FILE *fp = fopen(filename, "rb"); + if (!filename.empty() && file_size >= 16) { + FILE *fp = fopen(filename.c_str(), "rb"); if (fp) { if (fseeko(fp, file_size - 16, SEEK_SET) == 0) { uint8_t mfro[16]; diff --git a/src/zm_videostore.h b/src/zm_videostore.h index 852e05f38..d6e3a7749 100644 --- a/src/zm_videostore.h +++ b/src/zm_videostore.h @@ -71,7 +71,10 @@ class VideoStore { AVAudioFifo *fifo; uint8_t *converted_in_samples; - const char *filename; + // filename is owned (std::string) so it stays valid for the lifetime of + // VideoStore even if the caller later renames/reassigns the source path + // it was constructed from. A bare const char* would dangle in that case. + std::string filename; const char *format; // These are for in From 3b03ec5f12d136560075b4c7e6c536e0a27301d0 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 19:30:19 -0400 Subject: [PATCH 120/168] refactor: route VideoStore destructor through finalize() refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destructor previously duplicated the queue drain, trailer write, and avio close from finalize(), gated on !finalized_. That drifts. Have the destructor just call finalize() — it is idempotent (early returns when finalized_ is already set or when oc was never allocated) — so the shutdown logic lives in one place and resource deallocation stays in the destructor. Reported by Copilot on PR #4835. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_videostore.cpp | 53 ++++--------------------------------------- 1 file changed, 5 insertions(+), 48 deletions(-) diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index a3b2e97dd..4b4c239af 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -692,54 +692,11 @@ Debug(1, "Done flushing"); VideoStore::~VideoStore() { - // When finalize() has run it already drained these queues; doing it again - // would push packets into a closed AVFormatContext. Also skip when oc was - // never allocated (open() failed before assigning oc) — the write helpers - // dereference oc. - if (!finalized_ && oc) { - for (auto &n : reorder_queues) { - auto &queue = n.second; - Debug(1, "Queue for %d length is %zu", n.first, queue.size()); - while (!queue.empty()) { - auto pkt = queue.front(); - queue.pop_front(); - if (pkt->codec_type == AVMEDIA_TYPE_VIDEO) { - writeVideoFramePacket(pkt); - } else if (pkt->codec_type == AVMEDIA_TYPE_AUDIO) { - writeAudioFramePacket(pkt); - } - } - } - } - - if (!finalized_ && oc && oc->pb) { - flush_codecs(); - - // Flush Queues - Debug(4, "Flushing interleaved queues"); - av_interleaved_write_frame(oc, nullptr); - - Debug(1, "Writing trailer"); - /* Write the trailer before close */ - int rc; - if ((rc = av_write_trailer(oc)) < 0) { - Error("Error writing trailer %s", av_err2str(rc)); - } else { - Debug(3, "Success Writing trailer"); - } - - // When will we not be using a file ? - if (!(out_format->flags & AVFMT_NOFILE)) { - /* Close the out file. */ - Debug(4, "Closing"); - if ((rc = avio_close(oc->pb)) < 0) { - Error("Error closing avio %s", av_err2str(rc)); - } - } else { - Debug(3, "Not closing avio because we are not writing to a file."); - } - oc->pb = nullptr; - } // end if oc->pb + // Run the shutdown path through finalize() so the queue-drain / trailer / + // close logic lives in one place. finalize() is idempotent and bails early + // if oc was never allocated, so the legacy "caller didn't call finalize" + // path and the open()-failed-before-allocating-oc path both work. + finalize(); // I wonder if we should be closing the file first. // I also wonder if we really need to be doing all the ctx From 628bcb3e8115920c49d2531849019d20b766f897 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 22:44:14 -0400 Subject: [PATCH 121/168] fix: don't log Error on deadlock inside a caller-managed transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When zmDbDo is called inside a caller-managed transaction (AutoCommit off), max_attempts is 1 and the loop falls through to Error on a 1213 deadlock — which is misleading, because the caller (Event::delete, the zmstats prune+resync TX) has its own outer retry loop that will roll back and succeed. Downgrade to a Debug message in that path; Error is still emitted for non-deadlock failures and for autocommit calls that exhaust their retries. --- scripts/ZoneMinder/lib/ZoneMinder/Database.pm | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm index fe3093e73..152507c7b 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm @@ -274,11 +274,18 @@ sub zmDbDo { for ( my $attempt = 1; $attempt <= $max_attempts; $attempt++ ) { $rows = $dbh->do($sql, undef, @params); last if defined $rows; - if ( ($dbh->err() // 0) == 1213 and $attempt < $max_attempts ) { # 1213 = ER_LOCK_DEADLOCK + my $err_code = $dbh->err() // 0; + if ( $err_code == 1213 and $attempt < $max_attempts ) { # 1213 = ER_LOCK_DEADLOCK Debug("Deadlock on '"._sql_with_bind_values($sql, @params)."' attempt $attempt/$max_attempts, retrying"); select(undef, undef, undef, 0.05 * (1 << $attempt) + rand(0.05)); next; } + if ( $err_code == 1213 and !$dbh->{AutoCommit} ) { + # Caller-managed TX owns the retry loop; don't pollute logs with a + # misleading "Failed ... Deadlock found" Error before they roll back. + Debug('Deadlock in caller-managed TX on '._sql_with_bind_values($sql, @params).'; deferring to caller'); + last; + } Error('Failed '._sql_with_bind_values($sql, @params).' : '.$dbh->errstr()); last; } From 7b4617b1ad8defbea422cb0da28872266f660e13 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 22:44:14 -0400 Subject: [PATCH 122/168] docs: document atomicity tradeoff in zmstats/zmaudit ES resync A concurrent trigger writer can adjust Event_Summaries between our snapshot SELECTs and the per-monitor UPDATE; the UPDATE then overwrites that adjustment with the older snapshot. Drift is bounded by the zmstats/zmaudit interval and corrected on the next pass, because the incremental triggers continue to maintain ES correctly between resyncs. Locking ES before reading aggregates would invert the canonical lock order and re-introduce the deadlock cycle the resync rewrite eliminated. --- scripts/zmaudit.pl.in | 9 +++++++++ scripts/zmstats.pl.in | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index 349630030..67e3fb2fd 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -940,6 +940,15 @@ FROM `Frames` WHERE `EventId`=?'; # per monitor against Event_Summaries. Each UPDATE only X-locks the one ES # row it targets and reads no other table, so it can't form a cycle with # the trigger writers. + # + # Atomicity tradeoff (same as zmstats.pl): a concurrent trigger writer can + # adjust ES between our snapshot SELECTs and the per-monitor UPDATE; our + # UPDATE then overwrites that adjustment with the older snapshot. This is + # intentional. zmaudit is a periodic ground-truth resync — incremental + # trigger maintenance carries ES correctly between zmaudit passes, and any + # drift introduced by this race is bounded and corrected on the next pass. + # Locking ES before reading the aggregates would invert the canonical lock + # order and re-introduce the deadlock this rewrite eliminated. { my %agg; # All-or-nothing per column group: a transient SELECT failure must not diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index db2ac9248..dee478141 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -101,6 +101,15 @@ while (!$zm_terminate) { # READ COMMITTED is still set for the bucket DELETE range scans, so they # don't take next-key/gap locks against concurrent filter deletes / zma # trigger updates on adjacent EventIds. + # + # Atomicity tradeoff: between the per-bucket aggregate SELECT and the + # per-monitor UPDATE, a concurrent trigger writer (zma/zmc/Event::delete) + # can adjust Event_Summaries via the canonical lock chain. Our subsequent + # UPDATE will overwrite that adjustment with our older snapshot. This is + # intentional and safe: the bucket triggers keep ES drift bounded between + # zmstats passes, and any drift introduced by this race is corrected on + # the next pass. Locking ES before the snapshot would invert the canonical + # order and re-introduce the deadlock cycle this rewrite eliminated. { my $attempt = 0; my $max_attempts = 5; From e254ed41d2fa310bc4fdec7351df3bbfe9b9eb9a Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 22:44:14 -0400 Subject: [PATCH 123/168] docs: expand triggers.sql lock-order block with full writer list Adds zm_event.cpp's create path (Events INSERT -> bucket INSERTs -> Event_Summaries UPDATE; event_insert_trigger itself is commented out so zmc does this directly), and lists zmaudit.pl alongside zmstats.pl, so the comment enumerates every writer obligated to follow the canonical order rather than just the trigger paths. --- db/triggers.sql | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/db/triggers.sql b/db/triggers.sql index 774ead5d1..2da2b1ab3 100644 --- a/db/triggers.sql +++ b/db/triggers.sql @@ -117,16 +117,27 @@ FOR EACH ROW drop procedure if exists update_storage_stats// /* ============================================================================ - * Lock-acquisition order for the Events update/delete triggers (and for the - * scripts that touch the same tables). InnoDB X-locks the matched Events row - * during WHERE evaluation, before either BEFORE or AFTER trigger bodies fire, - * so the order is the same regardless of trigger timing: + * Canonical lock-acquisition order for every writer that touches Events, + * the bucket tables, and Event_Summaries. InnoDB X-locks the matched Events + * row during WHERE evaluation, before either BEFORE or AFTER trigger bodies + * fire, so the order is the same regardless of trigger timing: + * * Events[Id] -> Events_Hour/Day/Week/Month[EventId] -> Event_Summaries[MonitorId] - * zmstats.pl prune+resync follows the matching prefix (bucket DELETEs then - * UPDATE Event_Summaries) and crucially does NOT pre-lock Event_Summaries — - * pre-locking ES would invert against the trigger body order and reintroduce - * deadlocks against filter / zma writers. The bucket update/delete triggers - * also propagate into Event_Summaries[MonitorId] in the same direction. + * + * Writers that follow this order (and must continue to): + * - event_update_trigger (AFTER UPDATE on Events) + * - event_delete_trigger (BEFORE DELETE on Events) + * - src/zm_event.cpp Event::createNotification path: INSERT Events, then + * INSERT Events_Hour/Day/Week/Month, then INSERT/UPDATE Event_Summaries + * (event_insert_trigger is commented out below; zmc does it directly) + * - The bucket update/delete triggers cascade into Event_Summaries in the + * same direction + * - zmstats.pl prune+resync (bucket DELETEs then UPDATE Event_Summaries) + * - zmaudit.pl resync (bucket SELECTs then UPDATE Event_Summaries) + * + * Crucially: do NOT pre-lock Event_Summaries before touching the bucket + * tables — that inverts the order and reintroduces the deadlock cycle + * against zma/filter/zmc writers. * ============================================================================ */ drop trigger if exists event_update_trigger// From d01300832ec609350a385e6cea7fb5e963b88adc Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 23:14:37 -0400 Subject: [PATCH 124/168] fix: log Error in Event::delete when deadlock retries are exhausted zmDbDo suppresses its Error log on 1213 inside a caller-managed TX (the caller owns the retry), and the previous fallthrough at the end of the retry loop just `return`ed silently. After 5 failed attempts on persistent contention the event was effectively un-deleted with no record of the failure. Capture errstr before rollback (some drivers clear it) and emit an Error on the bail path. --- scripts/ZoneMinder/lib/ZoneMinder/Event.pm | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm index e336340d4..91ee22eca 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm @@ -426,6 +426,7 @@ sub delete { # Order: Stats -> Event_Data -> Frames -> Events (least to greatest reference depth) my $err = 0; + my $errstr = ''; foreach my $stmt ( ['DELETE FROM Stats WHERE EventId=?', $$event{Id}], ['DELETE FROM Event_Data WHERE EventId=?', $$event{Id}], @@ -435,7 +436,11 @@ sub delete { my ($sql, @bind) = @$stmt; ZoneMinder::Database::zmDbDo($sql, @bind); $err = $ZoneMinder::Database::dbh->err() // 0; - last if $err; + if ($err) { + # Capture before rollback, which can clear errstr on some drivers. + $errstr = $ZoneMinder::Database::dbh->errstr() // ''; + last; + } } if (!$err) { @@ -445,6 +450,11 @@ sub delete { $ZoneMinder::Database::dbh->rollback() if !$in_transaction; if ($in_transaction or $err != 1213 or $attempt >= $max_attempts) { # 1213 = ER_LOCK_DEADLOCK + # Surface the final failure ourselves — zmDbDo suppresses its Error + # log on 1213 inside a caller-managed TX (we own the retry), and the + # exhausted-retries case would otherwise return silently. + Error("Failed deleting event $$event{Id} after $attempt attempt(s): err=$err $errstr") + if $err; return; } Debug("Deadlock deleting event $$event{Id} attempt $attempt/$max_attempts, retrying"); From 1323e9f0230eac5f021a4ca6a1c2d400a75dfb0d Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 23:14:37 -0400 Subject: [PATCH 125/168] docs: reference the actual Event::Event constructor in lock-order block Previous wording named a fictional Event::createNotification function; the bucket+ES insert sequence in src/zm_event.cpp lives in the Event constructor (line 46). Point future readers at the right symbol so they can grep and verify the path. --- db/triggers.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/triggers.sql b/db/triggers.sql index 2da2b1ab3..8b622a522 100644 --- a/db/triggers.sql +++ b/db/triggers.sql @@ -127,7 +127,7 @@ drop procedure if exists update_storage_stats// * Writers that follow this order (and must continue to): * - event_update_trigger (AFTER UPDATE on Events) * - event_delete_trigger (BEFORE DELETE on Events) - * - src/zm_event.cpp Event::createNotification path: INSERT Events, then + * - The Event::Event constructor in src/zm_event.cpp: INSERT Events, then * INSERT Events_Hour/Day/Week/Month, then INSERT/UPDATE Event_Summaries * (event_insert_trigger is commented out below; zmc does it directly) * - The bucket update/delete triggers cascade into Event_Summaries in the From 52d5f80e0813bf841792bc53bca22ff21228dd85 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 23:14:37 -0400 Subject: [PATCH 126/168] docs: clarify per-monitor UPDATE lock state in zmstats resync The previous comment claimed each UPDATE couldn't hold any bucket lock that would deadlock with the trigger path, which conflated statement- level locks with TX-level locks. By the time we reach this loop the TX already holds bucket-row X-locks from the earlier DELETEs plus any ES X-locks acquired by the bucket DELETE triggers cascading. Rewrite the comment to distinguish those TX-held locks from the locks acquired by the new UPDATE statement and to be explicit that the TX's lock acquisition direction is preserved. --- scripts/zmstats.pl.in | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index dee478141..70b066452 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -179,9 +179,14 @@ while (!$zm_terminate) { } } - # One UPDATE per monitor: each takes a single ES X-lock and reads - # nothing else, so it can't hold any bucket-row lock that would - # deadlock with the trigger path. + # One UPDATE per monitor. The transaction at this point is still + # holding the bucket-row X-locks acquired by the earlier DELETEs and + # any ES X-locks the bucket DELETE triggers acquired as a cascade. + # Those were all acquired in the canonical order (buckets -> ES) so + # they don't conflict with the trigger writers. The new statement + # itself only X-locks the one ES row it targets and reads no other + # table, so it doesn't add any cross-table dependency that could form + # a new cycle — its lock acquisition continues in the same direction. if (!$err) { for my $mid (sort { $a <=> $b } keys %agg) { my $a = $agg{$mid}; From ccc0bcf82b969c32bdec0d0ec36caf02b8511b49 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 23:14:38 -0400 Subject: [PATCH 127/168] fix: track per-row UPDATE failures in zmaudit ES and Storage resync Previously zmaudit logged "Finished resyncing Event_Summaries" / "Finished updating Storage DiskSpace" unconditionally as long as the aggregate SELECTs succeeded, masking per-row UPDATE failures (e.g. zmDbDo exhausting its deadlock retries) and skipped aggregate column groups. Track which aggregates were skipped and which per-monitor / per-storage UPDATEs failed (zmDbDo returns undef on failure), and surface that in the audit log instead of claiming the resync is complete. --- scripts/zmaudit.pl.in | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index 67e3fb2fd..c83aed808 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -1025,15 +1025,28 @@ FROM `Frames` WHERE `EventId`=?'; if (@set) { my $sql = 'UPDATE Event_Summaries SET '.join(', ', @set).' WHERE MonitorId=?'; + my $update_attempted = 0; + my $update_failed = 0; for my $mid (sort { $a <=> $b } keys %agg) { my $a = $agg{$mid}; - ZoneMinder::Database::zmDbDo( + $update_attempted++; + my $rv = ZoneMinder::Database::zmDbDo( $sql, (map { $a->{$_} // 0 } @bind_template), $mid ); + $update_failed++ if !defined $rv; + } + my @skipped = grep { !$ok{$_} } qw(events h d w m); + if (!@skipped and !$update_failed) { + aud_print('Finished resyncing Event_Summaries from Events + bucket tables'); + } else { + aud_print(sprintf( + 'Partial Event_Summaries resync: skipped aggregates [%s], %d/%d per-monitor UPDATE(s) failed', + join(',', @skipped) || 'none', + $update_failed, $update_attempted + )); } - aud_print('Finished resyncing Event_Summaries from Events + bucket tables'); } else { Error('zmaudit: every Event_Summaries aggregate SELECT failed; skipping resync'); } @@ -1042,6 +1055,7 @@ FROM `Frames` WHERE `EventId`=?'; # Storage DiskSpace resync: same trap. Snapshot per-Storage sums via plain # SELECT, then UPDATE Storage one row at a time. { + my $storage_done = 0; my $rows = $dbh->selectall_arrayref( 'SELECT StorageId, COALESCE(SUM(DiskSpace), 0) FROM Events GROUP BY StorageId' ); @@ -1053,15 +1067,25 @@ FROM `Frames` WHERE `EventId`=?'; if ($dbh->err()) { Error('zmaudit Storage enumerate failed: '.$dbh->errstr()); } else { + my $attempted = 0; + my $failed = 0; for my $sid (@$storage_ids) { - ZoneMinder::Database::zmDbDo( + $attempted++; + my $rv = ZoneMinder::Database::zmDbDo( 'UPDATE Storage SET DiskSpace=? WHERE Id=?', $disk{$sid} // 0, $sid ); + $failed++ if !defined $rv; + } + if (!$failed) { + aud_print('Finished updating Storage DiskSpace'); + $storage_done = 1; + } else { + aud_print("Partial Storage DiskSpace update: $failed/$attempted row(s) failed"); } } } - aud_print('Finished updating Storage DiskSpace'); + Error('zmaudit Storage DiskSpace resync did not complete') if !$storage_done; } sleep($Config{ZM_AUDIT_CHECK_INTERVAL}) if $continuous; From 1bce09f4e84615e03e083480ec92ed87fe44fff7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 03:29:41 +0000 Subject: [PATCH 128/168] fix: address review feedback - endian decode, send_twice init, setLastViewed, curr_frame_id gating Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/b457dcf6-616f-4df9-bc28-76640bf5dd39 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_eventstream.cpp | 8 +++++--- src/zm_monitorstream.cpp | 12 ++++++------ src/zm_stream.h | 14 +++++++++----- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/zm_eventstream.cpp b/src/zm_eventstream.cpp index cf08b81d4..ea7d5ace0 100644 --- a/src/zm_eventstream.cpp +++ b/src/zm_eventstream.cpp @@ -480,7 +480,7 @@ void EventStream::processCommand(const CmdMsg *msg) { Debug(1, "Got VARPLAY command"); stopped = false; paused = false; - replay_rate = ntohs(((unsigned char)msg->msg_data[2]<<8)|(unsigned char)msg->msg_data[1])-32768; + replay_rate = (((unsigned char)msg->msg_data[1]<<8)|(unsigned char)msg->msg_data[2])-VARPLAY_RATE_OFFSET; if (replay_rate > 50 * ZM_RATE_BASE) { Warning("requested replay rate (%d) is too high. We only support up to 50x", replay_rate); replay_rate = 50 * ZM_RATE_BASE; @@ -494,6 +494,8 @@ void EventStream::processCommand(const CmdMsg *msg) { Debug(1, "Got STOP command"); stopped = true; paused = false; + step = 0; + send_twice = false; break; case CMD_FASTFWD : { Debug(1, "Got FAST FWD command"); @@ -1198,10 +1200,10 @@ void EventStream::runStream() { // Paused or stopped delta = MAX_SLEEP; - // We are paused, so might be stepping + // We are paused, so might be stepping (not when fully stopped) //if ( step != 0 )// Adding 0 is cheaper than an if 0 // curr_frame_id starts at 1 though, so we might skip the first frame? - curr_frame_id += step; + if (!stopped) curr_frame_id += step; } // end if !paused && !stopped } // end scope for mutex lock diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index 6cb7f625e..4dd4f6a91 100644 --- a/src/zm_monitorstream.cpp +++ b/src/zm_monitorstream.cpp @@ -113,7 +113,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { paused = false; delayed = true; } - replay_rate = ntohs(((unsigned char)msg->msg_data[2]<<8)|(unsigned char)msg->msg_data[1])-32768; + replay_rate = (((unsigned char)msg->msg_data[1]<<8)|(unsigned char)msg->msg_data[2])-VARPLAY_RATE_OFFSET; break; case CMD_STOP : Debug(1, "Got STOP command"); @@ -646,15 +646,15 @@ void MonitorStream::runStream() { std::this_thread::sleep_for(MAX_SLEEP); continue; } - monitor->setLastViewed(); - if (frame_type == FRAME_ANALYSIS) - monitor->setLastAnalysisViewed(); - if (stopped) { - // In stopped state, do nothing except wait for a new command + // In stopped state, do nothing except wait for a new command. + // Don't call setLastViewed() so we don't keep capture/decoding active unnecessarily. std::this_thread::sleep_for(MAX_SLEEP); continue; } + monitor->setLastViewed(); + if (frame_type == FRAME_ANALYSIS) + monitor->setLastAnalysisViewed(); if (paused) { if (!was_paused) { diff --git a/src/zm_stream.h b/src/zm_stream.h index f729cc504..8c794ca00 100644 --- a/src/zm_stream.h +++ b/src/zm_stream.h @@ -54,6 +54,9 @@ class StreamBase { enum { DEFAULT_ZOOM=ZM_SCALE_BASE }; enum { DEFAULT_MAXFPS=10 }; enum { DEFAULT_BITRATE=100000 }; + // Offset applied when encoding a signed replay rate as a uint16 for CMD_VARPLAY. + // On the wire: uint16 = rate + VARPLAY_RATE_OFFSET. Receiver subtracts the same offset. + static const int VARPLAY_RATE_OFFSET = 32768; protected: typedef struct { @@ -93,12 +96,12 @@ class StreamBase { CMD_NEXT, CMD_SEEK, // CMD_VARPLAY resumes or starts playback at a caller-specified rate. - // The desired rate is packed as a big-endian uint16 offset by +32768 so that the range - // [-32768, +32767] maps to [0, 65535]. ZM_RATE_BASE (100) represents 1x speed, so: - // 32868 (= 32768 + 100) encodes 1x forward playback, - // 32668 (= 32768 - 100) encodes 1x reverse playback. + // The desired rate is packed as a big-endian uint16 offset by +VARPLAY_RATE_OFFSET so that + // the range [-32768, +32767] maps to [0, 65535]. ZM_RATE_BASE (100) represents 1x speed, so: + // 32868 (= VARPLAY_RATE_OFFSET + 100) encodes 1x forward playback, + // 32668 (= VARPLAY_RATE_OFFSET - 100) encodes 1x reverse playback. // Negative rates play in reverse; rates > ZM_RATE_BASE play faster than real-time. - // MSG payload: msg_data[1..2] = (rate + 32768) as network-byte-order uint16. + // MSG payload: msg_data[1..2] = (rate + VARPLAY_RATE_OFFSET) as network-byte-order uint16. CMD_VARPLAY, CMD_GET_IMAGE, CMD_QUIT, @@ -202,6 +205,7 @@ class StreamBase { paused(false), stopped(false), step(0), + send_twice(false), maxfps(DEFAULT_MAXFPS), base_fps(0.0), effective_fps(0.0), From 03801a9e238dddd47111fbc85232d7988f5f4b6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 12:37:39 +0000 Subject: [PATCH 129/168] fix: reset step/send_twice in MonitorStream CMD_STOP; reset stopped in EventStream.js lifecycle Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/8c14d453-7815-44fd-956a-0cda13c218f9 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_monitorstream.cpp | 2 ++ web/js/EventStream.js | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index 4dd4f6a91..48ba5420f 100644 --- a/src/zm_monitorstream.cpp +++ b/src/zm_monitorstream.cpp @@ -120,6 +120,8 @@ void MonitorStream::processCommand(const CmdMsg *msg) { stopped = true; paused = false; delayed = false; + step = 0; + send_twice = false; break; case CMD_FASTFWD : Debug(1, "Got FAST FWD command"); diff --git a/web/js/EventStream.js b/web/js/EventStream.js index 52eb8ee99..c9072ea24 100644 --- a/web/js/EventStream.js +++ b/web/js/EventStream.js @@ -91,6 +91,7 @@ function EventStream(config) { this.currentEventId = eventId; this.rate = (options.rate !== undefined) ? options.rate : 100; this.paused = false; + this.stopped = false; this.lastOptions = Object.assign({}, options); // Fresh connkey for this stream @@ -203,6 +204,7 @@ function EventStream(config) { this.started = false; this.paused = false; + this.stopped = false; this.connKey = null; this.streamCmdParms.connkey = null; this.consecutiveErrors = 0; @@ -248,6 +250,7 @@ function EventStream(config) { } this.started = false; this.connKey = null; + this.stopped = false; this.streamCmdParms.connkey = null; // Delay before restarting — exponential backoff From 411b6916bf9e8cc469601d2a805f70bffc2b457d Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 17 May 2026 08:50:38 -0400 Subject: [PATCH 130/168] fix: avoid DB-backed logging inside zmDbDo when caller manages the transaction ZoneMinder::Logger->logPrint runs INSERT INTO Logs on the same dbh. Calling Debug()/Error() from zmDbDo's failure path inside a caller-managed transaction would execute another statement on the connection, clearing the err/errstr state the caller needs to see for rollback/retry. The result could be a caller observing err=0 after a deadlock-victim TX and committing what looks like success but is actually a rolled-back no-op. Bail silently from zmDbDo when AutoCommit is off; the caller owns the retry loop and is responsible for logging. Logging in the autocommit path is still safe because each statement is its own TX. --- scripts/ZoneMinder/lib/ZoneMinder/Database.pm | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm index 152507c7b..d37f15775 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm @@ -274,18 +274,20 @@ sub zmDbDo { for ( my $attempt = 1; $attempt <= $max_attempts; $attempt++ ) { $rows = $dbh->do($sql, undef, @params); last if defined $rows; + + # In a caller-managed transaction, never issue ANY logging here that can + # write to the Logs table: ZoneMinder::Logger->logPrint INSERTs into + # Logs using the same $dbh, which would clear $dbh->err / $dbh->errstr + # before the caller reads them for rollback/retry. Bail silently — the + # caller owns the retry loop and is responsible for logging. + last if !$dbh->{AutoCommit}; + my $err_code = $dbh->err() // 0; if ( $err_code == 1213 and $attempt < $max_attempts ) { # 1213 = ER_LOCK_DEADLOCK Debug("Deadlock on '"._sql_with_bind_values($sql, @params)."' attempt $attempt/$max_attempts, retrying"); select(undef, undef, undef, 0.05 * (1 << $attempt) + rand(0.05)); next; } - if ( $err_code == 1213 and !$dbh->{AutoCommit} ) { - # Caller-managed TX owns the retry loop; don't pollute logs with a - # misleading "Failed ... Deadlock found" Error before they roll back. - Debug('Deadlock in caller-managed TX on '._sql_with_bind_values($sql, @params).'; deferring to caller'); - last; - } Error('Failed '._sql_with_bind_values($sql, @params).' : '.$dbh->errstr()); last; } From 8fb4ad79e671aab93acb1485e040d146c3a2b2b8 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 17 May 2026 08:50:38 -0400 Subject: [PATCH 131/168] perf: restrict zmstats ES resync to monitors with pruned buckets Two issues with the previous implementation: 1. Aggregate SELECTs ran GROUP BY MonitorId across the full bucket tables every zmstats cycle (default 60s). Events_Month grows for weeks; this turned the stats daemon into a constant full-scan workload on busy installs. 2. The per-monitor UPDATE loop X-locked every Event_Summaries row on every cycle even when nothing changed, adding avoidable contention with the trigger writers this rewrite is supposed to protect. Capture MonitorIds as we SELECT bucket rows for pruning, then skip the resync entirely if no rows were pruned. When rows were pruned, restrict the aggregate SELECTs (WHERE MonitorId IN ...) and the per-monitor UPDATEs to that touched set. zmaudit remains the periodic deep-resync safety net for drift in untouched monitors. Also capture errstr before rollback so the gave-up Error reports the actual reason instead of an empty string on drivers that clear errstr on rollback. --- scripts/zmstats.pl.in | 116 ++++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index 70b066452..1db33a126 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -121,6 +121,8 @@ while (!$zm_terminate) { $dbh->begin_work(); my $err = 0; + my $errstr; # captured before rollback() — rollback can clear errstr + my %touched_monitors; # MonitorIds whose buckets we just modified foreach my $bucket ( ['Events_Hour', '1 hour'], ['Events_Day', '1 day'], @@ -128,26 +130,37 @@ while (!$zm_terminate) { ['Events_Month', '1 month'], ) { my ($table, $interval) = @$bucket; - my $event_ids = $dbh->selectcol_arrayref( - "SELECT EventId FROM $table WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL $interval)" + my $rows = $dbh->selectall_arrayref( + "SELECT EventId, MonitorId FROM $table WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL $interval)" ); $err = $dbh->err() // 0; - last if $err; - if ($event_ids and @$event_ids) { - zmDbDo( - "DELETE FROM $table WHERE EventId IN (".join(',', map { '?' } @$event_ids).')', - @$event_ids - ); - $err = $dbh->err() // 0; - last if $err; - } + if ($err) { $errstr = $dbh->errstr() // ''; last; } + next if !$rows or !@$rows; + my @event_ids = map { $_->[0] } @$rows; + $touched_monitors{$_->[1]} = 1 for @$rows; + zmDbDo( + "DELETE FROM $table WHERE EventId IN (".join(',', map { '?' } @event_ids).')', + @event_ids + ); + $err = $dbh->err() // 0; + if ($err) { $errstr = $dbh->errstr() // ''; last; } } - # Snapshot the per-monitor bucket aggregates. Plain SELECT under RC is - # a consistent read and takes no row locks, so this can't deadlock with - # the trigger writers. - my %agg; - if (!$err) { + # Only resync ES for monitors we actually touched in this cycle. If + # nothing was pruned, the bucket triggers maintain ES correctly between + # zmstats passes; zmaudit is the periodic deep-resync safety net. + # Restricting to touched monitors also avoids X-locking every ES row + # on every zmstats cycle (which would contend with the trigger writers + # this rewrite is meant to protect). + if (!$err and %touched_monitors) { + my @mids = sort { $a <=> $b } keys %touched_monitors; + my $placeholders = join(',', map { '?' } @mids); + + # Snapshot the per-monitor bucket aggregates for the touched monitors + # only. Plain SELECT under RC is a consistent read and takes no row + # locks, so this can't deadlock with the trigger writers. + my %agg; + $agg{$_} ||= {} for @mids; # seed so monitors with zero rows still get zeroed foreach my $bucket ( ['Events_Hour', 'h'], ['Events_Day', 'd'], @@ -156,58 +169,49 @@ while (!$zm_terminate) { ) { my ($table, $key) = @$bucket; my $rows = $dbh->selectall_arrayref( - "SELECT MonitorId, COUNT(*), COALESCE(SUM(DiskSpace), 0) FROM $table GROUP BY MonitorId" + "SELECT MonitorId, COUNT(*), COALESCE(SUM(DiskSpace), 0) FROM $table". + " WHERE MonitorId IN ($placeholders) GROUP BY MonitorId", + undef, @mids ); $err = $dbh->err() // 0; - last if $err; + if ($err) { $errstr = $dbh->errstr() // ''; last; } for my $r (@$rows) { $agg{$r->[0]}{$key.'_c'} = $r->[1]; $agg{$r->[0]}{$key.'_s'} = $r->[2]; } } - } - # Pull the universe of MonitorIds from Event_Summaries so any monitor - # with zero rows in every bucket still gets zeroed out. - if (!$err) { - my $monitor_ids = $dbh->selectcol_arrayref('SELECT MonitorId FROM Event_Summaries'); - $err = $dbh->err() // 0; + # One UPDATE per touched monitor. The transaction at this point is + # still holding the bucket-row X-locks acquired by the earlier + # DELETEs and any ES X-locks the bucket DELETE triggers acquired as + # a cascade. Those were all acquired in the canonical order + # (buckets -> ES) so they don't conflict with the trigger writers. + # The new statement itself only X-locks the one ES row it targets + # and reads no other table, so it doesn't add any cross-table + # dependency that could form a new cycle — its lock acquisition + # continues in the same direction. if (!$err) { - for my $mid (@$monitor_ids) { - $agg{$mid} ||= {}; + for my $mid (@mids) { + my $a = $agg{$mid}; + zmDbDo( + 'UPDATE Event_Summaries SET '. + 'HourEvents=?, HourEventDiskSpace=?, '. + 'DayEvents=?, DayEventDiskSpace=?, '. + 'WeekEvents=?, WeekEventDiskSpace=?, '. + 'MonthEvents=?, MonthEventDiskSpace=? '. + 'WHERE MonitorId=?', + $a->{h_c} // 0, $a->{h_s} // 0, + $a->{d_c} // 0, $a->{d_s} // 0, + $a->{w_c} // 0, $a->{w_s} // 0, + $a->{m_c} // 0, $a->{m_s} // 0, + $mid + ); + $err = $dbh->err() // 0; + if ($err) { $errstr = $dbh->errstr() // ''; last; } } } } - # One UPDATE per monitor. The transaction at this point is still - # holding the bucket-row X-locks acquired by the earlier DELETEs and - # any ES X-locks the bucket DELETE triggers acquired as a cascade. - # Those were all acquired in the canonical order (buckets -> ES) so - # they don't conflict with the trigger writers. The new statement - # itself only X-locks the one ES row it targets and reads no other - # table, so it doesn't add any cross-table dependency that could form - # a new cycle — its lock acquisition continues in the same direction. - if (!$err) { - for my $mid (sort { $a <=> $b } keys %agg) { - my $a = $agg{$mid}; - zmDbDo( - 'UPDATE Event_Summaries SET '. - 'HourEvents=?, HourEventDiskSpace=?, '. - 'DayEvents=?, DayEventDiskSpace=?, '. - 'WeekEvents=?, WeekEventDiskSpace=?, '. - 'MonthEvents=?, MonthEventDiskSpace=? '. - 'WHERE MonitorId=?', - $a->{h_c} // 0, $a->{h_s} // 0, - $a->{d_c} // 0, $a->{d_s} // 0, - $a->{w_c} // 0, $a->{w_s} // 0, - $a->{m_c} // 0, $a->{m_s} // 0, - $mid - ); - $err = $dbh->err() // 0; - last if $err; - } - } - if (!$err) { $dbh->commit(); last; @@ -215,7 +219,7 @@ while (!$zm_terminate) { $dbh->rollback(); if ($err != 1213 or $attempt >= $max_attempts) { # 1213 = ER_LOCK_DEADLOCK - Error("Event_Summaries prune+resync gave up after $attempt attempt(s): ".$dbh->errstr()); + Error("Event_Summaries prune+resync gave up after $attempt attempt(s): ".($errstr // '')); last; } Debug("Deadlock during Event_Summaries prune+resync, attempt $attempt/$max_attempts"); From d098a055b8ceafa76b1a90dabea6a052f0c4d73e Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 17 May 2026 08:50:38 -0400 Subject: [PATCH 132/168] fix: track Event_Summaries enumerate failure as partial resync in zmaudit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, an enumerate failure (SELECT MonitorId FROM Event_Summaries) left @skipped empty and the per-monitor UPDATE loop ran for whatever rows the bucket aggregates returned — but monitors that exist only in Event_Summaries (no current bucket rows) never got zeroed, while the audit log claimed a full resync. Track enumerate success and report partial resync when it fails. --- scripts/zmaudit.pl.in | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index c83aed808..0abdea919 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -1001,11 +1001,14 @@ FROM `Frames` WHERE `EventId`=?'; # Include monitors that have an Event_Summaries row but no rows in any # successfully-aggregated source — those need to be zeroed for the - # column groups we did read. + # column groups we did read. If we can't enumerate, we can't claim a + # full resync regardless of which aggregates succeeded. + my $enumerate_ok = 0; my $existing = $dbh->selectcol_arrayref('SELECT MonitorId FROM Event_Summaries'); if ($dbh->err()) { Error("zmaudit Event_Summaries enumerate failed: ".$dbh->errstr()); } else { + $enumerate_ok = 1; $agg{$_} ||= {} for @$existing; } @@ -1038,12 +1041,13 @@ FROM `Frames` WHERE `EventId`=?'; $update_failed++ if !defined $rv; } my @skipped = grep { !$ok{$_} } qw(events h d w m); - if (!@skipped and !$update_failed) { + if (!@skipped and !$update_failed and $enumerate_ok) { aud_print('Finished resyncing Event_Summaries from Events + bucket tables'); } else { aud_print(sprintf( - 'Partial Event_Summaries resync: skipped aggregates [%s], %d/%d per-monitor UPDATE(s) failed', + 'Partial Event_Summaries resync: skipped aggregates [%s], enumerate %s, %d/%d per-monitor UPDATE(s) failed', join(',', @skipped) || 'none', + $enumerate_ok ? 'ok' : 'failed', $update_failed, $update_attempted )); } From 7563c15f766d1f6947b2f093f7e4d7b2783432a5 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 17 May 2026 10:18:49 -0400 Subject: [PATCH 133/168] refactor: simplify Event::delete bind-list to a plain SQL list Every row in the previous arrayref-of-arrayref carried the same single bind value (the event Id), so the [$sql, $$event{Id}] wrapping and the my ($sql, @bind) = @$stmt unpacking were doing no work. Iterate over the SQL strings directly and pass $$event{Id} as the one bind value. --- scripts/ZoneMinder/lib/ZoneMinder/Event.pm | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm index 91ee22eca..34ce35925 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm @@ -427,14 +427,13 @@ sub delete { # Order: Stats -> Event_Data -> Frames -> Events (least to greatest reference depth) my $err = 0; my $errstr = ''; - foreach my $stmt ( - ['DELETE FROM Stats WHERE EventId=?', $$event{Id}], - ['DELETE FROM Event_Data WHERE EventId=?', $$event{Id}], - ['DELETE FROM Frames WHERE EventId=?', $$event{Id}], - ['DELETE FROM Events WHERE Id=?', $$event{Id}], + foreach my $sql ( + 'DELETE FROM Stats WHERE EventId=?', + 'DELETE FROM Event_Data WHERE EventId=?', + 'DELETE FROM Frames WHERE EventId=?', + 'DELETE FROM Events WHERE Id=?', ) { - my ($sql, @bind) = @$stmt; - ZoneMinder::Database::zmDbDo($sql, @bind); + ZoneMinder::Database::zmDbDo($sql, $$event{Id}); $err = $ZoneMinder::Database::dbh->err() // 0; if ($err) { # Capture before rollback, which can clear errstr on some drivers. From dcf03c82e60086b87b82ad6cd1046679f3702325 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 17 May 2026 19:48:42 +0300 Subject: [PATCH 134/168] More accurate page navigation and statistics display (log.js) --- web/skins/classic/views/js/log.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index a9a263890..81b993efa 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -54,11 +54,10 @@ function ajaxRequest(params) { data: params.data, timeout: 0, success: function(data) { - if (!data.rows.length) { - // If page is > 1, bt infinitely loops - // IgorA100 commented out the code below because it leads to endless table updates if the query returns 0 rows. - // This may have been a hack due to incorrect page pagination, which was fixed in https://github.com/ZoneMinder/zoneminder/pull/4818 - //table.bootstrapTable('selectPage', 1); + if (!data.rows.length && data.total > 0) { + // If the page is greater than 1, it loops infinitely. + table.bootstrapTable('selectPage', 1); + return; } // rearrange the result into what bootstrap-table expects params.success({ @@ -95,8 +94,8 @@ function filterLog() { function updateHeaderStats(data) { var pageNum = table.bootstrapTable('getOptions').pageNumber; var pageSize = table.bootstrapTable('getOptions').pageSize; - var startRow = ( (pageNum - 1 ) * pageSize ) + 1; - var stopRow = pageNum * pageSize; + var startRow = (data.total > 0) ? (( (pageNum - 1 ) * pageSize ) + 1) : 0; + var stopRow = (data.total > 0) ? ((data.total > pageSize) ? pageNum * pageSize : data.total) : 0; var newClass = (data.logstate == 'ok') ? 'text-success' : (data.logstate == 'alert' ? 'text-warning' : ((data.logstate == 'alarm' ? 'text-danger' : ''))); $j('#logState').text(data.logstate); From c1850820fe919eaf3cca650d96cc7203f7b27e2e Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 17 May 2026 21:25:02 +0300 Subject: [PATCH 135/168] Open and close the session only once per page refresh. (log.php) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open and close the session only once per page refresh. First, save all changes in the $newSessionValue array, then open the session, set the new session values, and close the session. Only write the correct $_REQUEST['level'] values ​​to "zmLogFilterLevel" (if they are in ZM\Logger::$codes). --- web/ajax/log.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index 4f555d843..95c3ceb5a 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -69,6 +69,7 @@ function createRequest() { function queryRequest() { // Offset specifies the starting row to return, used for pagination + $newSessionValue = []; $offset = 0; if (isset($_REQUEST['offset'])) { if ((!is_int($_REQUEST['offset']) and !ctype_digit($_REQUEST['offset']))) { @@ -162,9 +163,7 @@ function queryRequest() { $where .= 'Component = ?'; $query['values'][] = $_REQUEST['Component']; } - zm_session_start(); - $_SESSION['zmLogComponent'] = !empty($_REQUEST['Component']) ? $_REQUEST['Component'] : ''; - session_write_close(); + $newSessionValue['zmLogComponent'] = !empty($_REQUEST['Component']) ? $_REQUEST['Component'] : ''; if (!empty($_REQUEST['ServerId'])) { if ($where) $where .= ' AND '; @@ -185,9 +184,7 @@ function queryRequest() { $where .= ' Level = ?'; $query['values'][] = $level_codes[$L]; } - zm_session_start(); - $_SESSION['zmLogFilterLevel'] = $L; - session_write_close(); + $newSessionValue['zmLogFilterLevel'] = isset($level_codes[$L]) ? $L : ''; if (!empty($_REQUEST['StartDateTime'])) { $start_time = strtotime($_REQUEST['StartDateTime']); @@ -209,6 +206,13 @@ function queryRequest() { ZM\Warning("Unable to parse EndDateTime ".$_REQUEST['EndDateTime']. " into a timestamp"); } } + + zm_session_start(); + foreach ($newSessionValue as $name => $value) { + $_SESSION[$name] = $value; + } + session_write_close(); + if ($where) $where = ' WHERE '.$where; $data['totalNotFiltered'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'`', 'Total'); From 22cb2454f922caa1d9cc964fbc95a35b396428ea Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 17 May 2026 22:39:08 +0300 Subject: [PATCH 136/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/skins/classic/views/js/log.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index f46202d05..1ea82962e 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -115,8 +115,11 @@ function manageClearLogsModalBtns() { document.getElementById('clearLogsConfirmBtn').disabled = true; deleteLogs(getIdSelections()); }); - document.getElementById('clearLogsCancelBtn').addEventListener('click', function onClearLogsCancelClick(evt) { + $j('#clearLogsConfirm').on('hidden.bs.modal', function onClearLogsConfirmHidden() { manageClearButtonAvailability(); + }); + document.getElementById('clearLogsCancelBtn').addEventListener('click', function onClearLogsCancelClick(evt) { + evt.preventDefault(); $j('#clearLogsConfirm').modal('hide'); }); } From b57f773f18ade410cef10b952f545329f16e3358 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Mon, 18 May 2026 00:15:22 +0300 Subject: [PATCH 137/168] Prevent the "#clearLogsBtn" status from appearing active after clicking the "#clearLogsConfirmBtn" button. The issue was related to an unnecessary call to manageClearButtonAvailability() (log.js) --- web/skins/classic/views/js/log.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index 1ea82962e..274434dc7 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -112,11 +112,15 @@ function updateHeaderStats(data) { function manageClearLogsModalBtns() { document.getElementById('clearLogsConfirmBtn').addEventListener('click', function onClearLogsConfirmClick(evt) { evt.preventDefault(); + $j('#clearLogsConfirm').modal('hide'); document.getElementById('clearLogsConfirmBtn').disabled = true; deleteLogs(getIdSelections()); }); - $j('#clearLogsConfirm').on('hidden.bs.modal', function onClearLogsConfirmHidden() { - manageClearButtonAvailability(); + $j('#clearLogsConfirm').on('hide.bs.modal', function onClearLogsConfirmHidden(evt) { + const idRelatedTarget = $j(document.activeElement).attr('id'); + // When executing deleteLogs(), we always call a table update + // after which manageClearButtonAvailability() is always executed, so there is no need to execute manageClearButtonAvailability() here + if (idRelatedTarget != "clearLogsConfirmBtn") manageClearButtonAvailability(); }); document.getElementById('clearLogsCancelBtn').addEventListener('click', function onClearLogsCancelClick(evt) { evt.preventDefault(); @@ -142,7 +146,6 @@ function deleteLogs(log_ids) { data: {'ids[]': chunk}, success: function(data) { if (!log_ids.length) { - $j('#clearLogsConfirm').modal('hide'); table.bootstrapTable('refresh'); } else { if (ticker.innerHTML.length < 1 || ticker.innerHTML.length > 10) { @@ -155,7 +158,6 @@ function deleteLogs(log_ids) { }, error: function(jqxhr) { logAjaxFail(jqxhr); - $j('#clearLogsConfirm').modal('hide'); table.bootstrapTable('refresh'); } }); @@ -268,10 +270,11 @@ function manageClearButtonAvailability(enable = null) { const selections = table.bootstrapTable('getSelections'); const clearLogsBtn = document.getElementById('clearLogsBtn'); if (clearLogsBtn) { - if (enable === false || !selections.length) + if (enable === false || !selections.length) { clearLogsBtn.disabled = true; - else if (enable === true || selections.length) + } else if (enable === true || selections.length) { clearLogsBtn.disabled = false; + } } } From 01b142531c9251f69c53aa062eb9274e923353aa Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Mon, 18 May 2026 20:04:53 -0400 Subject: [PATCH 138/168] fix: skip zmDbDo success Debug when inside a caller-managed transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same hazard as the failure-path Debug: ZoneMinder::Logger->logPrint INSERTs into Logs using the same $dbh, so a success Debug fires an extra write inside a TX that's trying to minimize lock interactions — and any err/errstr change it provokes is visible to the caller. The autocommit path keeps the success Debug (it's a separate TX, no caller interaction). --- scripts/ZoneMinder/lib/ZoneMinder/Database.pm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm index d37f15775..45bc7d966 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm @@ -291,7 +291,11 @@ sub zmDbDo { Error('Failed '._sql_with_bind_values($sql, @params).' : '.$dbh->errstr()); last; } - if ( defined $rows and ZoneMinder::Logger::logLevel() > INFO ) { + # Skip the success Debug when we're inside a caller-managed transaction: + # ZoneMinder::Logger->logPrint INSERTs into Logs on this same $dbh, which + # would add an extra write to a TX that's trying to be lock-minimal and + # could change $dbh->err / $dbh->errstr the caller will later inspect. + if ( defined $rows and $dbh->{AutoCommit} and ZoneMinder::Logger::logLevel() > INFO ) { ($rows) = $rows =~ /^(.*)$/; # de-taint Debug('Succeeded '._sql_with_bind_values($sql, @params)." : $rows rows affected"); } From 5183fb647a0ca6ea5b746b20021c2380c4a1b585 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Mon, 18 May 2026 20:04:54 -0400 Subject: [PATCH 139/168] fix: use $dbh->do for SET TRANSACTION to preserve the next-TX isolation level SET TRANSACTION ISOLATION LEVEL applies to the very next transaction on the connection. zmDbDo's success Debug INSERT INTO Logs is a real statement on the same $dbh; with database debug logging enabled, that INSERT becomes the "next transaction" and silently consumes the isolation directive. The intended READ COMMITTED then never applies to the prune/resync/delete TX that follows. Call $dbh->do directly for SET TRANSACTION in both Event::delete and zmstats.pl, bypassing zmDbDo's logging. SET TRANSACTION can't deadlock so zmDbDo's retry was no benefit here anyway. --- scripts/ZoneMinder/lib/ZoneMinder/Event.pm | 6 +++++- scripts/zmstats.pl.in | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm index 34ce35925..e3c52ea4b 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm @@ -420,7 +420,11 @@ sub delete { while (1) { $attempt++; if (!$in_transaction) { - ZoneMinder::Database::zmDbDo('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); + # Use $dbh->do directly, NOT zmDbDo: zmDbDo's success Debug would + # write to the Logs table on this same $dbh, and that INSERT would + # become the "next transaction" that consumes the isolation level + # directive — silently dropping our delete TX back to the default. + $ZoneMinder::Database::dbh->do('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); $ZoneMinder::Database::dbh->begin_work(); } diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index 1db33a126..150ef2d61 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -116,8 +116,12 @@ while (!$zm_terminate) { while (1) { $attempt++; # SET TRANSACTION ... applies only to the next transaction, so it must - # be issued before begin_work and re-issued on each retry. - zmDbDo('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); + # be issued before begin_work and re-issued on each retry. Use + # $dbh->do directly, NOT zmDbDo: zmDbDo's success Debug would write to + # the Logs table on this same $dbh, and that INSERT would become the + # "next transaction" that consumes the isolation directive — silently + # dropping our prune+resync TX back to the default. + $dbh->do('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); $dbh->begin_work(); my $err = 0; From 07d50a8fed64e300deb311e419e0dd485da5c7aa Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Mon, 18 May 2026 20:04:54 -0400 Subject: [PATCH 140/168] fix: CAS-style Storage DiskSpace update in zmaudit to preserve concurrent adjustments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlike Event_Summaries, Storage.DiskSpace has no trigger-based incremental maintenance — Event::delete and the event-finalize paths do their own +/- adjustments in application code. The previous absolute snapshot+overwrite could undo a concurrent Event::delete adjustment that committed between our Events SUM SELECT and our Storage UPDATE, making accounting transiently wrong under normal load until the next zmaudit pass. Read the current Storage.DiskSpace alongside the aggregate, skip rows that are already correct, and UPDATE with a null-safe equality guard (MariaDB <=>) so a concurrent writer's newer value blocks the overwrite. Track CAS-deferred rows separately from failures and surface both counts in the audit log. --- scripts/zmaudit.pl.in | 44 +++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index 0abdea919..8c55ee68b 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -1056,33 +1056,57 @@ FROM `Frames` WHERE `EventId`=?'; } } - # Storage DiskSpace resync: same trap. Snapshot per-Storage sums via plain - # SELECT, then UPDATE Storage one row at a time. + # Storage DiskSpace resync. Unlike Event_Summaries, Storage.DiskSpace is + # NOT maintained by DB triggers — Event::delete and the event-finalize + # paths do their own +/- adjustments in application code. So overwriting + # with a stale absolute snapshot would actively undo a concurrent + # adjustment instead of being self-corrected next pass. + # + # CAS pattern: read DiskSpace alongside the SUM snapshot, then UPDATE + # WHERE DiskSpace is still the value we observed. If a concurrent writer + # adjusted it between the SELECT and the UPDATE, the WHERE won't match + # and we leave their newer adjustment in place. Next zmaudit pass picks + # up any residual drift. { my $storage_done = 0; - my $rows = $dbh->selectall_arrayref( + my $events_rows = $dbh->selectall_arrayref( 'SELECT StorageId, COALESCE(SUM(DiskSpace), 0) FROM Events GROUP BY StorageId' ); if ($dbh->err()) { Error('zmaudit Storage aggregate failed: '.$dbh->errstr()); } else { - my %disk = map { $_->[0] => $_->[1] } grep { defined $_->[0] } @$rows; - my $storage_ids = $dbh->selectcol_arrayref('SELECT Id FROM Storage'); + my %disk = map { $_->[0] => $_->[1] } grep { defined $_->[0] } @$events_rows; + my $storage_rows = $dbh->selectall_arrayref('SELECT Id, DiskSpace FROM Storage'); if ($dbh->err()) { Error('zmaudit Storage enumerate failed: '.$dbh->errstr()); } else { my $attempted = 0; my $failed = 0; - for my $sid (@$storage_ids) { + my $deferred = 0; + for my $r (@$storage_rows) { + my ($sid, $current) = @$r; + my $target = $disk{$sid} // 0; + # Skip if already correct (avoids redundant X-locks). + next if defined $current and $current == $target; $attempted++; + # MariaDB null-safe equality (<=>) handles NULL DiskSpace. my $rv = ZoneMinder::Database::zmDbDo( - 'UPDATE Storage SET DiskSpace=? WHERE Id=?', - $disk{$sid} // 0, $sid + 'UPDATE Storage SET DiskSpace=? WHERE Id=? AND DiskSpace <=> ?', + $target, $sid, $current ); - $failed++ if !defined $rv; + if (!defined $rv) { + $failed++; + } elsif ($rv == 0) { + # CAS lost: concurrent writer adjusted DiskSpace. Leave theirs. + $deferred++; + } } if (!$failed) { - aud_print('Finished updating Storage DiskSpace'); + if ($deferred) { + aud_print("Updated Storage DiskSpace ($attempted attempted, $deferred deferred to concurrent writers)"); + } else { + aud_print('Finished updating Storage DiskSpace'); + } $storage_done = 1; } else { aud_print("Partial Storage DiskSpace update: $failed/$attempted row(s) failed"); From 673d5a9336eb106494da078f72d79c2f0682379d Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 20 May 2026 08:50:31 -0400 Subject: [PATCH 141/168] fix: chunk zmstats bucket-prune DELETE IN-list to avoid packet-size limits The DELETE WHERE EventId IN (?,?,...) is intentional: it locks each row via the primary key, keeping the lock range minimal and preserving the canonical lock order that this PR's deadlock fix relies on. But a single IN-list with tens of thousands of placeholders (Events_Month after weeks of accumulation) can hit max_allowed_packet and max_prepared_stmt_count. Split the EventId list into 1000-row batches and loop. PK-based locking is preserved; SQL/packet size stays bounded. Switching to a predicate- based DELETE would re-introduce range locks on the bucket index and undo the deadlock work. --- scripts/zmstats.pl.in | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index 150ef2d61..0265c4674 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -127,6 +127,13 @@ while (!$zm_terminate) { my $err = 0; my $errstr; # captured before rollback() — rollback can clear errstr my %touched_monitors; # MonitorIds whose buckets we just modified + # Chunk size for DELETE WHERE EventId IN (...) — keeps each DELETE + # well under max_allowed_packet / max_prepared_stmt_count on installs + # where Events_Month has accumulated tens of thousands of aged rows, + # while preserving PK-based per-row locking (DELETE by predicate would + # range-lock the bucket index and re-introduce the lock-ordering + # inversions this rewrite was meant to eliminate). + my $delete_chunk = 1000; foreach my $bucket ( ['Events_Hour', '1 hour'], ['Events_Day', '1 day'], @@ -142,11 +149,17 @@ while (!$zm_terminate) { next if !$rows or !@$rows; my @event_ids = map { $_->[0] } @$rows; $touched_monitors{$_->[1]} = 1 for @$rows; - zmDbDo( - "DELETE FROM $table WHERE EventId IN (".join(',', map { '?' } @event_ids).')', - @event_ids - ); - $err = $dbh->err() // 0; + for (my $i = 0; $i < @event_ids; $i += $delete_chunk) { + my $end = $i + $delete_chunk - 1; + $end = $#event_ids if $end > $#event_ids; + my @batch = @event_ids[$i .. $end]; + zmDbDo( + "DELETE FROM $table WHERE EventId IN (".join(',', map { '?' } @batch).')', + @batch + ); + $err = $dbh->err() // 0; + last if $err; + } if ($err) { $errstr = $dbh->errstr() // ''; last; } } From 25da6cce9ec5b3e4a65d84251bff578c88c59050 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 20 May 2026 08:50:32 -0400 Subject: [PATCH 142/168] fix: CAS-guard Event_Summaries UPDATE in zmaudit to preserve concurrent writes zmaudit's per-monitor UPDATE writes absolute snapshot values across all 12 counter columns. Without a CAS guard, a concurrent writer touching ES between our snapshot and our UPDATE gets clobbered. The TotalEvents / ArchivedEvents columns are particularly exposed because zmstats doesn't maintain them (it only touches Hour/Day/Week/Month), so any drift introduced by zmaudit racing event_delete_trigger or the zmc insert path persists until the next zmaudit cycle. Read the current ES row alongside the aggregates, skip monitors whose snapshot matches the current row (no X-lock for no-op writes), and guard each UPDATE with null-safe equality (MariaDB <=>) on every column we're writing. Track CAS-deferred and skipped counts separately from failures in the audit log. zmstats does not need the same treatment: its UPDATE runs inside a TX that already holds the bucket X-locks that gate the trigger writers updating its column set. --- scripts/zmaudit.pl.in | 116 ++++++++++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index 8c55ee68b..752fdef70 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -941,14 +941,14 @@ FROM `Frames` WHERE `EventId`=?'; # row it targets and reads no other table, so it can't form a cycle with # the trigger writers. # - # Atomicity tradeoff (same as zmstats.pl): a concurrent trigger writer can - # adjust ES between our snapshot SELECTs and the per-monitor UPDATE; our - # UPDATE then overwrites that adjustment with the older snapshot. This is - # intentional. zmaudit is a periodic ground-truth resync — incremental - # trigger maintenance carries ES correctly between zmaudit passes, and any - # drift introduced by this race is bounded and corrected on the next pass. - # Locking ES before reading the aggregates would invert the canonical lock - # order and re-introduce the deadlock this rewrite eliminated. + # Atomicity: zmaudit guards each per-monitor UPDATE with a CAS predicate + # built from the ES values we read at snapshot time (see "Read current ES + # values" below). If a concurrent trigger writer adjusts ES between our + # snapshot and our UPDATE, the WHERE doesn't match and we leave their + # adjustment in place — necessary here because TotalEvents/ArchivedEvents + # have no zmstats correction path, so an overwrite would persist until + # the next zmaudit pass. zmstats can skip CAS because its TX holds the + # bucket X-locks that gate the trigger writers updating its column set. { my %agg; # All-or-nothing per column group: a transient SELECT failure must not @@ -999,56 +999,108 @@ FROM `Frames` WHERE `EventId`=?'; } } - # Include monitors that have an Event_Summaries row but no rows in any - # successfully-aggregated source — those need to be zeroed for the - # column groups we did read. If we can't enumerate, we can't claim a - # full resync regardless of which aggregates succeeded. + # Read current ES values alongside the enumerate so we can: + # 1. Skip rows that already match the aggregate snapshot (no-op). + # 2. CAS-guard the UPDATE so a concurrent trigger writer (zmc insert + # path, event_delete_trigger updating TotalEvents/ArchivedEvents, + # Events_*_update_trigger updating bucket disk-space) that adjusts + # ES between our snapshot and our write doesn't get clobbered. + # TotalEvents/ArchivedEvents have no zmstats correction path, so without + # CAS a transient race could leave drift until the next zmaudit pass. + my %current; my $enumerate_ok = 0; - my $existing = $dbh->selectcol_arrayref('SELECT MonitorId FROM Event_Summaries'); + my $existing = $dbh->selectall_arrayref(q{ + SELECT MonitorId, + TotalEvents, TotalEventDiskSpace, + ArchivedEvents, ArchivedEventDiskSpace, + HourEvents, HourEventDiskSpace, + DayEvents, DayEventDiskSpace, + WeekEvents, WeekEventDiskSpace, + MonthEvents, MonthEventDiskSpace + FROM Event_Summaries + }); if ($dbh->err()) { Error("zmaudit Event_Summaries enumerate failed: ".$dbh->errstr()); } else { $enumerate_ok = 1; - $agg{$_} ||= {} for @$existing; + for my $r (@$existing) { + my $mid = $r->[0]; + $agg{$mid} ||= {}; + $current{$mid} = { + total_c => $r->[1], total_s => $r->[2], + archived_c => $r->[3], archived_s => $r->[4], + h_c => $r->[5], h_s => $r->[6], + d_c => $r->[7], d_s => $r->[8], + w_c => $r->[9], w_s => $r->[10], + m_c => $r->[11], m_s => $r->[12], + }; + } } - # Build the SET clause from only the column groups we successfully read. - my @set; - my @bind_template; + # Build SET and CAS-guard parallel lists for the column groups we + # successfully read. Each entry: [SET piece, CAS piece, %agg key]. + my @set_pieces; if ($ok{events}) { - push @set, 'TotalEvents=?, TotalEventDiskSpace=?, ArchivedEvents=?, ArchivedEventDiskSpace=?'; - push @bind_template, qw(total_c total_s archived_c archived_s); + push @set_pieces, + ['TotalEvents=?', 'TotalEvents<=>?', 'total_c'], + ['TotalEventDiskSpace=?', 'TotalEventDiskSpace<=>?', 'total_s'], + ['ArchivedEvents=?', 'ArchivedEvents<=>?', 'archived_c'], + ['ArchivedEventDiskSpace=?', 'ArchivedEventDiskSpace<=>?', 'archived_s']; } for my $bucket (['h','Hour'], ['d','Day'], ['w','Week'], ['m','Month']) { my ($key, $col) = @$bucket; next unless $ok{$key}; - push @set, "${col}Events=?, ${col}EventDiskSpace=?"; - push @bind_template, $key.'_c', $key.'_s'; + push @set_pieces, + ["${col}Events=?", "${col}Events<=>?", $key.'_c'], + ["${col}EventDiskSpace=?", "${col}EventDiskSpace<=>?", $key.'_s']; } - if (@set) { - my $sql = 'UPDATE Event_Summaries SET '.join(', ', @set).' WHERE MonitorId=?'; + if (@set_pieces) { + my $sql = 'UPDATE Event_Summaries SET '. + join(', ', map { $_->[0] } @set_pieces). + ' WHERE MonitorId=? AND '. + join(' AND ', map { $_->[1] } @set_pieces); + my $update_attempted = 0; my $update_failed = 0; + my $update_deferred = 0; # CAS lost — concurrent writer adjusted ES + my $update_skipped = 0; # already correct, no UPDATE needed for my $mid (sort { $a <=> $b } keys %agg) { my $a = $agg{$mid}; + my $c = $current{$mid}; + next if !$c; # monitor exists in aggregates but not ES; original behavior was to skip + my @target = map { $a->{$_->[2]} // 0 } @set_pieces; + my @cas = map { $c->{$_->[2]} } @set_pieces; + # Skip rows where the snapshot already matches every target column. + my $needs_update = 0; + for my $i (0 .. $#set_pieces) { + if (!defined $cas[$i] or $cas[$i] != $target[$i]) { $needs_update = 1; last; } + } + if (!$needs_update) { $update_skipped++; next; } $update_attempted++; my $rv = ZoneMinder::Database::zmDbDo( $sql, - (map { $a->{$_} // 0 } @bind_template), - $mid + @target, $mid, @cas ); - $update_failed++ if !defined $rv; + if (!defined $rv) { + $update_failed++; + } elsif ($rv == 0) { + $update_deferred++; + } } - my @skipped = grep { !$ok{$_} } qw(events h d w m); - if (!@skipped and !$update_failed and $enumerate_ok) { - aud_print('Finished resyncing Event_Summaries from Events + bucket tables'); + my @skipped_aggs = grep { !$ok{$_} } qw(events h d w m); + if (!@skipped_aggs and !$update_failed and $enumerate_ok) { + if ($update_deferred or $update_skipped) { + aud_print("Finished resyncing Event_Summaries ($update_attempted attempted, $update_deferred deferred to concurrent writers, $update_skipped no-op)"); + } else { + aud_print('Finished resyncing Event_Summaries from Events + bucket tables'); + } } else { aud_print(sprintf( - 'Partial Event_Summaries resync: skipped aggregates [%s], enumerate %s, %d/%d per-monitor UPDATE(s) failed', - join(',', @skipped) || 'none', + 'Partial Event_Summaries resync: skipped aggregates [%s], enumerate %s, %d/%d UPDATE(s) failed, %d deferred', + join(',', @skipped_aggs) || 'none', $enumerate_ok ? 'ok' : 'failed', - $update_failed, $update_attempted + $update_failed, $update_attempted, $update_deferred )); } } else { From a01f196917f5cec84112297198b2cb28ab230e8d Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 20 May 2026 20:35:06 +0300 Subject: [PATCH 143/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/skins/classic/views/js/log.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index 81b993efa..585034a71 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -95,7 +95,7 @@ function updateHeaderStats(data) { var pageNum = table.bootstrapTable('getOptions').pageNumber; var pageSize = table.bootstrapTable('getOptions').pageSize; var startRow = (data.total > 0) ? (( (pageNum - 1 ) * pageSize ) + 1) : 0; - var stopRow = (data.total > 0) ? ((data.total > pageSize) ? pageNum * pageSize : data.total) : 0; + var stopRow = (data.total > 0) ? Math.min(data.total, pageNum * pageSize) : 0; var newClass = (data.logstate == 'ok') ? 'text-success' : (data.logstate == 'alert' ? 'text-warning' : ((data.logstate == 'alarm' ? 'text-danger' : ''))); $j('#logState').text(data.logstate); From 27d196a53be8146fd09a0d4bc3daf8de117e2f55 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Wed, 20 May 2026 20:36:33 +0300 Subject: [PATCH 144/168] Edited comment (log.js) --- web/skins/classic/views/js/log.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index 585034a71..b1becec42 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -55,7 +55,7 @@ function ajaxRequest(params) { timeout: 0, success: function(data) { if (!data.rows.length && data.total > 0) { - // If the page is greater than 1, it loops infinitely. + // The requested page is out of range; reset to page 1. table.bootstrapTable('selectPage', 1); return; } From 19cc1b89b6effdf49b20bd19b1be857b26c54a23 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 20 May 2026 18:30:26 -0400 Subject: [PATCH 145/168] fix: only show 'Use ONVIF' badge on console when listener is enabled web/ajax/console.php was returning ONVIF_Alarm_Text under the ONVIF_Event_Listener key whenever the column existed (always), so console.js displayed "Use ONVIF 'MotionAlarm'" for every monitor regardless of whether the listener was actually enabled. Gate the alarm text on the actual ONVIF_Event_Listener boolean, returning 0 otherwise so the JS falsy check works as intended. Co-Authored-By: Claude Opus 4.7 --- web/ajax/console.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/ajax/console.php b/web/ajax/console.php index e79250cfc..e7d57ea4e 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -407,7 +407,10 @@ function queryRequest() { } $row['Analysing'] = isset($monitor['Analysing']) ? $monitor['Analysing'] : 'None'; $row['Recording'] = isset($monitor['Recording']) ? $monitor['Recording'] : 'None'; - $row['ONVIF_Event_Listener'] = isset($monitor['ONVIF_Event_Listener']) ? $monitor['ONVIF_Alarm_Text'] : 0; + // console.js treats this as both an enable flag AND the text to display: + // if (row.ONVIF_Event_Listener) html += "Use ONVIF '" + row.ONVIF_Event_Listener + "'" + // So send the alarm text only when the listener is actually enabled, else 0. + $row['ONVIF_Event_Listener'] = !empty($monitor['ONVIF_Event_Listener']) ? $monitor['ONVIF_Alarm_Text'] : 0; $row['UpdatedOn'] = isset($monitor['UpdatedOn']) ? $monitor['UpdatedOn'] : ''; $row['Type'] = $monitor['Type']; $row['Capturing'] = isset($monitor['Capturing']) ? $monitor['Capturing'] : 'None'; From 61d8a7c71d27aa683800d3870b436c5310bbde3e Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 20 May 2026 23:00:14 -0400 Subject: [PATCH 146/168] fix: enrich zms auth-failure warning with diagnostic fields The previous warning ("Unable to authenticate user from ") gave no way to distinguish the common cases without flipping on Debug: - Stale auth hash on a long-lived whose TTL expired - Missing/wrong user= or auth= in URL - Disabled/deleted user - REMOTE_ADDR mismatch when ZM_AUTH_HASH_IPS is enabled Include user, auth-hash prefix, REQUEST_URI, XFF, and REMOTE_ADDR in the message so the noise is diagnosable from the log alone. Co-Authored-By: Claude Opus 4.7 --- src/zms.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/zms.cpp b/src/zms.cpp index d2e7d64f4..a8331bc5b 100644 --- a/src/zms.cpp +++ b/src/zms.cpp @@ -259,7 +259,23 @@ int main(int argc, const char *argv[], char **envp) { fputs("HTTP/1.0 403 Forbidden\r\n\r\n", stdout); const char *referer = getenv("HTTP_REFERER"); - Warning("Unable to authenticate user from %s", referer); + const char *request_uri = getenv("REQUEST_URI"); + const char *xff = getenv("HTTP_X_FORWARDED_FOR"); + const char *remote = getenv("REMOTE_ADDR"); + // Most failures here are stale auth hashes on long-lived + // streams whose hash TTL expired; the browser keeps reconnecting with the + // baked-in URL. Including user/auth-prefix/uri/xff makes the noise diagnosable + // without flipping on Debug. + char auth_prefix[9] = {0}; + if (*auth) strncpy(auth_prefix, auth, sizeof(auth_prefix)-1); + Warning("Unable to authenticate user (user='%s' auth='%s%s' uri='%s' referer='%s' xff='%s' remote='%s')", + username.c_str(), + auth_prefix, + (*auth && strlen(auth) > 8) ? "..." : "", + request_uri ? request_uri : "", + referer ? referer : "", + xff ? xff : "", + remote ? remote : ""); return exit_zm(0); } if ( !ValidateAccess(user, monitor_id) ) { From 6db9b291585bc0e6942caa15034a4403e2e43adf Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 20 May 2026 23:00:59 -0400 Subject: [PATCH 147/168] feat: refresh auth hash and retry on MonitorStream image error zms returns 403 on a stale auth hash (default TTL 2h), but the live keeps the original src and reconnects forever with the expired hash, generating one zms warning per retry until the user reloads the page. On img_onerror, fetch a fresh auth hash from the existing navBar status endpoint, splice it into the img src, and reconnect. Capped at 3 attempts with 2s/4s/8s backoff so a genuinely-down camera doesn't loop indefinitely. img_onload resets the attempt counter on success. Co-Authored-By: Claude Opus 4.7 --- web/js/MonitorStream.js | 44 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index a063f2e6d..cc1949e13 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -81,12 +81,54 @@ function MonitorStream(monitorData) { this.bottomElement = e; }; + this.MAX_AUTH_REFRESH_ATTEMPTS = 3; + this.authRefreshAttempts = 0; + this.authRefreshTimer = null; + this.img_onerror = function() { console.log('Image stream has been stopped! stopping streamCmd'); this.streamCmdTimer = clearInterval(this.streamCmdTimer); - this.writeTextInfoBlock("Error", {showImg: false}); + + // zms returns 403 on a stale auth hash (default TTL 2h). The browser keeps + // reconnecting the with the same baked-in src, so each retry generates + // another zms warning. Try refreshing the auth hash and rebuilding src + // before giving up. + if (this.authRefreshAttempts >= this.MAX_AUTH_REFRESH_ATTEMPTS) { + this.writeTextInfoBlock("Error", {showImg: false}); + return; + } + this.authRefreshAttempts++; + const backoffMs = 2000 * Math.pow(2, this.authRefreshAttempts - 1); // 2s, 4s, 8s + console.log("Stream error; refreshing auth and reconnecting in "+backoffMs+ + "ms (attempt "+this.authRefreshAttempts+"/"+this.MAX_AUTH_REFRESH_ATTEMPTS+")"); + this.writeTextInfoBlock("Reconnecting..."); + + const self = this; + if (this.authRefreshTimer) clearTimeout(this.authRefreshTimer); + this.authRefreshTimer = setTimeout(function() { + $j.getJSON(thisUrl + '?view=request&request=status&entity=navBar' + (auth_relay ? '&' + auth_relay : '')) + .done(function(data) { + if (data && data.auth) { + auth_hash = data.auth; + } + const stream = self.getElement(); + if (stream && stream.src) { + const newSrc = stream.src.replace(/auth=\w+/i, 'auth='+auth_hash); + stream.src = ''; + stream.src = newSrc; + } + }) + .fail(function() { + self.writeTextInfoBlock("Error", {showImg: false}); + }); + }, backoffMs); }; this.img_onload = function() { + this.authRefreshAttempts = 0; + if (this.authRefreshTimer) { + clearTimeout(this.authRefreshTimer); + this.authRefreshTimer = null; + } if (!this.streamCmdTimer) { console.log('Image stream has loaded! starting streamCmd for monitor ID='+this.id+' connKey='+this.connKey+' in '+statusRefreshTimeout + 'ms'); this.streamCmdQuery(); // This is to get an instant status update From 0ae7e74177dd52c89388b0787c102384e818f254 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 21 May 2026 11:35:08 +0300 Subject: [PATCH 148/168] Don't use the $newSessionValue array and cast $_REQUEST['level'] to a scalar string (log.php) --- web/ajax/log.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index 95c3ceb5a..bbaabcdb3 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -69,7 +69,6 @@ function createRequest() { function queryRequest() { // Offset specifies the starting row to return, used for pagination - $newSessionValue = []; $offset = 0; if (isset($_REQUEST['offset'])) { if ((!is_int($_REQUEST['offset']) and !ctype_digit($_REQUEST['offset']))) { @@ -163,7 +162,6 @@ function queryRequest() { $where .= 'Component = ?'; $query['values'][] = $_REQUEST['Component']; } - $newSessionValue['zmLogComponent'] = !empty($_REQUEST['Component']) ? $_REQUEST['Component'] : ''; if (!empty($_REQUEST['ServerId'])) { if ($where) $where .= ' AND '; @@ -177,14 +175,13 @@ function queryRequest() { $query['values'][] = $_REQUEST['level']; } */ - $L = $_REQUEST['level'] ?? ''; + $L = (!empty($_REQUEST['level'])) ? (string) $_REQUEST['level'] : ''; $level_codes = array_flip(ZM\Logger::$codes); if (!empty($L) && isset($level_codes[$L])) { if ($where) $where .= ' AND '; $where .= ' Level = ?'; $query['values'][] = $level_codes[$L]; } - $newSessionValue['zmLogFilterLevel'] = isset($level_codes[$L]) ? $L : ''; if (!empty($_REQUEST['StartDateTime'])) { $start_time = strtotime($_REQUEST['StartDateTime']); @@ -208,9 +205,8 @@ function queryRequest() { } zm_session_start(); - foreach ($newSessionValue as $name => $value) { - $_SESSION[$name] = $value; - } + $_SESSION['zmLogComponent'] = !empty($_REQUEST['Component']) ? $_REQUEST['Component'] : ''; + $_SESSION['zmLogFilterLevel'] = isset($level_codes[$L]) ? $L : ''; session_write_close(); if ($where) $where = ' WHERE '.$where; From 805ebcdf3ff24c1126bf69067c28bdb62ba89aa9 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 21 May 2026 11:38:25 +0300 Subject: [PATCH 149/168] Don't use the $newSessionValue array and cast $_REQUEST['level'] to a scalar string (log.php) --- web/ajax/log.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index bbaabcdb3..5ff560dbc 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -1,5 +1,5 @@ Date: Thu, 21 May 2026 13:54:23 +0300 Subject: [PATCH 150/168] Correctly save setCookie('zmWatchMuted') when "=false" (MonitorStream.js) --- web/js/MonitorStream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index a063f2e6d..31e53d3af 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -1051,7 +1051,7 @@ function MonitorStream(monitorData) { console.warn(`volumeSlider for monitor with ID=${this.id} not found`); } if (currentView != 'montage') { - setCookie('zmWatchMuted', audioStream.muted); + setCookie('zmWatchMuted', (audioStream.muted) ? 'true' : 'false'); setCookie('zmWatchVolume', parseInt(audioStream.volume * 100)); } }; From baa66facf9097059eb02bbfd9db0ae4519832e98 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 21 May 2026 07:15:51 -0400 Subject: [PATCH 151/168] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/ajax/log.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index 5ff560dbc..bbaabcdb3 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -1,5 +1,5 @@ Date: Thu, 21 May 2026 07:20:36 -0400 Subject: [PATCH 152/168] fix: string-compare BIGINT counters in zmaudit CAS skip checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perl's != / == coerces both sides to NV (double-precision float). BIGINT DiskSpace columns can exceed 2^53 (~9 PB), at which point distinct integers collapse to the same double. The "already correct, skip" checks for both the Event_Summaries CAS loop and the Storage CAS loop were comparing scalars with numeric operators, so two distinct DiskSpace values above 2^53 could be treated as equal and the resync skipped — and skipped again on every subsequent pass because the collapse is deterministic, so drift would persist indefinitely. Switch both checks to string equality. DBI binds these scalars as strings anyway, so this matches what the database will compare against when the WHERE clause runs. --- scripts/zmaudit.pl.in | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index 752fdef70..a4929844d 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -1072,9 +1072,14 @@ FROM `Frames` WHERE `EventId`=?'; my @target = map { $a->{$_->[2]} // 0 } @set_pieces; my @cas = map { $c->{$_->[2]} } @set_pieces; # Skip rows where the snapshot already matches every target column. + # Compare as strings, not numerics: Perl's != coerces to NV (double) + # and collapses BIGINTs above 2^53 to the same value, which would + # produce false "already correct" skips that persist across passes. + # DBI binds these scalars as strings anyway, so string compare is + # the same equality the database will see. my $needs_update = 0; for my $i (0 .. $#set_pieces) { - if (!defined $cas[$i] or $cas[$i] != $target[$i]) { $needs_update = 1; last; } + if (!defined $cas[$i] or "$cas[$i]" ne "$target[$i]") { $needs_update = 1; last; } } if (!$needs_update) { $update_skipped++; next; } $update_attempted++; @@ -1138,8 +1143,10 @@ FROM `Frames` WHERE `EventId`=?'; for my $r (@$storage_rows) { my ($sid, $current) = @$r; my $target = $disk{$sid} // 0; - # Skip if already correct (avoids redundant X-locks). - next if defined $current and $current == $target; + # Skip if already correct (avoids redundant X-locks). Compare as + # strings to avoid the BIGINT/NV precision-collapse trap — see + # the Event_Summaries loop comment for the same issue. + next if defined $current and "$current" eq "$target"; $attempted++; # MariaDB null-safe equality (<=>) handles NULL DiskSpace. my $rv = ZoneMinder::Database::zmDbDo( From 0f44886ba11c237101c14966ccc75f902875c679 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 21 May 2026 18:46:07 +0300 Subject: [PATCH 153/168] More correct parsing of $_REQUEST['level'] before assigning its value to $L (log.php) --- web/ajax/log.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index bbaabcdb3..cae9844ec 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -175,7 +175,7 @@ function queryRequest() { $query['values'][] = $_REQUEST['level']; } */ - $L = (!empty($_REQUEST['level'])) ? (string) $_REQUEST['level'] : ''; + $L = (isset($_REQUEST['level']) && !empty($_REQUEST['level']) && is_scalar($_REQUEST['level'])) ? (string) $_REQUEST['level'] : ''; $level_codes = array_flip(ZM\Logger::$codes); if (!empty($L) && isset($level_codes[$L])) { if ($where) $where .= ' AND '; From a5ea0b242eb993f00eb408dbf14157fe873d3883 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 22 May 2026 08:59:46 -0400 Subject: [PATCH 154/168] fix: keep console summary row aligned when toggling event columns The footer matched cells positionally with `eventCells[index]`, but bootstrap-table omits hidden columns from the rebuilt tfoot, so hiding e.g. Hour shifted every later period's total into the wrong cell. Tag each event header with a unique colEvents class and target the footer cell by that class instead of by index. Bootstrap-table also rebuilds the tfoot on column-switch, which wiped our injected totals until the next ajax refresh. Cache the last footer payload and re-apply it on column-switch / column-switch-all. Co-Authored-By: Claude Opus 4.7 --- web/skins/classic/views/console.php | 2 +- web/skins/classic/views/js/console.js | 54 +++++++++++++++------------ 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/web/skins/classic/views/console.php b/web/skins/classic/views/console.php index 57e49ad53..8ac013361 100644 --- a/web/skins/classic/views/console.php +++ b/web/skins/classic/views/console.php @@ -294,7 +294,7 @@ echo $navbar ?> ); parseFilter($filter); $eventsLink = canView('Events') ? '?view='.ZM_WEB_EVENTS_VIEW.'&page=1'.$filter['querystring'] : ''; - echo '
'.PHP_EOL; diff --git a/web/skins/classic/views/js/console.js b/web/skins/classic/views/js/console.js index 8f21cc31f..2eb0f06bd 100644 --- a/web/skins/classic/views/js/console.js +++ b/web/skins/classic/views/js/console.js @@ -2,9 +2,11 @@ const table = $j('#consoleTable'); var ajax = null; var monitors = {}; // Store monitors by ID for function modal +var lastFooter = null; // Cached footer payload for re-applying after column toggles // Update footer with dynamic totals function updateFooter(footer) { + lastFooter = footer; // Target the footer within the bootstrap-table wrapper // Bootstrap-table may transform td to th and wrap content in divs var footerRow = $j('#consoleTable').closest('.bootstrap-table').find('tfoot tr'); @@ -32,35 +34,33 @@ function updateFooter(footer) { // Update bandwidth/FPS (in Function column) updateCell('td.colFunction, th.colFunction', footer.bandwidth_fps); - // Update event totals + // Update event totals. Target each period by its unique colEvents + // class rather than by positional index: bootstrap-table drops hidden columns + // from the tfoot DOM entirely, so an index-based lookup would shift every + // period after the hidden one into the wrong cell. var eventPeriods = ['Total', 'Hour', 'Day', 'Week', 'Month', 'Archived']; - var eventCells = footerRow.find('td.colEvents, th.colEvents'); - eventPeriods.forEach(function(period, index) { - if (eventCells.length > index) { - var cell = $j(eventCells[index]); - // Only update the th-inner div if it exists - var innerDiv = cell.find('.th-inner'); - var target = innerDiv.length ? innerDiv : cell; + eventPeriods.forEach(function(period) { + var sel = 'td.col' + period + 'Events, th.col' + period + 'Events'; + var cell = footerRow.find(sel); + if (!cell.length) return; - var contentHtml = footer[period + 'Events'] + '
' + - footer[period + 'EventDiskSpace'] + '
'; + var innerDiv = cell.find('.th-inner'); + var target = innerDiv.length ? innerDiv : cell; - // Create or update link with filter querystring - if (canView.Events && footer[period + 'FilterQuery']) { - var link = target.find('a'); - if (link.length) { - // Update existing link href and content - link.attr('href', '?view=' + ZM_WEB_EVENTS_VIEW + footer[period + 'FilterQuery']); - link.html(contentHtml); - } else { - // Create new link - target.html('' + - contentHtml + ''); - } + var contentHtml = footer[period + 'Events'] + '
' + + footer[period + 'EventDiskSpace'] + '
'; + + if (canView.Events && footer[period + 'FilterQuery']) { + var link = target.find('a'); + if (link.length) { + link.attr('href', '?view=' + ZM_WEB_EVENTS_VIEW + footer[period + 'FilterQuery']); + link.html(contentHtml); } else { - // No permission or no filter query, just show text - target.html(contentHtml); + target.html('' + + contentHtml + ''); } + } else { + target.html(contentHtml); } }); @@ -566,6 +566,12 @@ function initPage() { $j('.functionLnk').click(manageFunctionModal); }); + // Re-apply cached footer totals when columns are toggled, because + // bootstrap-table rebuilds tfoot on column-switch and clears our content. + table.on('column-switch.bs.table column-switch-all.bs.table', function() { + if (lastFooter) updateFooter(lastFooter); + }); + // Makes table sortable - disabled by default, enabled by Sort button // Note: This may need adjustment for bootstrap-table compatibility $j('#consoleTableBody').sortable({ From 7c5bd7e55c92e2651abb271d2afe202b5672b0e9 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 22 May 2026 17:48:42 +0300 Subject: [PATCH 155/168] More correct retrieval of zmLogFilterLevel from session (log.php) --- web/skins/classic/views/log.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/skins/classic/views/log.php b/web/skins/classic/views/log.php index c689cd1cc..0cb9b561d 100644 --- a/web/skins/classic/views/log.php +++ b/web/skins/classic/views/log.php @@ -99,9 +99,10 @@ $levels = array(''=>translate('All')); foreach (array_values(ZM\Logger::$codes) as $level) { $levels[$level] = $level; } +$selectedLevel = (isset($_SESSION['zmLogFilterLevel']) && !empty($_SESSION['zmLogFilterLevel']) && is_scalar($_SESSION['zmLogFilterLevel'])) ? (string) $_SESSION['zmLogFilterLevel'] : ''; +$selectedLevel = isset($levels[$selectedLevel]) ? $selectedLevel : ''; echo ''; -echo htmlSelect('filterLevel', $levels, - (isset($_SESSION['zmLogFilterLevel']) ? $_SESSION['zmLogFilterLevel'] : ''), +echo htmlSelect('filterLevel', $levels, $selectedLevel, array('data-on-change'=>'filterLog', 'id'=>'filterLevel', 'class'=>'chosen')); #array('class'=>'form-control chosen', 'data-on-change'=>'filterLog')); echo ''; From 46fdd66c1878897c80db30a91759244ac741afe7 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 22 May 2026 17:59:22 +0300 Subject: [PATCH 156/168] More correctly retrieves Component from $_REQUEST and writes it to the session (log.php) --- web/ajax/log.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/ajax/log.php b/web/ajax/log.php index cae9844ec..83ef4645d 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -157,10 +157,11 @@ function queryRequest() { $where = '(' .implode(' OR ', $likes). ')'; } - if (!empty($_REQUEST['Component'])) { + $requestComponent = (isset($_REQUEST['Component']) && !empty($_REQUEST['Component']) && is_scalar($_REQUEST['Component'])) ? (string) $_REQUEST['Component'] : ''; + if (!empty($requestComponent)) { if ($where) $where .= ' AND '; $where .= 'Component = ?'; - $query['values'][] = $_REQUEST['Component']; + $query['values'][] = $requestComponent; } if (!empty($_REQUEST['ServerId'])) { @@ -205,7 +206,7 @@ function queryRequest() { } zm_session_start(); - $_SESSION['zmLogComponent'] = !empty($_REQUEST['Component']) ? $_REQUEST['Component'] : ''; + $_SESSION['zmLogComponent'] = $requestComponent; $_SESSION['zmLogFilterLevel'] = isset($level_codes[$L]) ? $L : ''; session_write_close(); From 97d29ae7047b55089e172cad355b46c6bc570282 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Mon, 18 May 2026 18:29:00 -0400 Subject: [PATCH 157/168] fix: make zm_update-1.39.10.sql idempotent for reruns zmupdate.pl reruns migrations where the file version is >= the current DB version, so once the DB reaches 1.39.10 the script fires again. The unconditional ALTER TABLE Logs ADD/DROP INDEX statements failed on the second run with "Duplicate key name". Guard both Logs index changes with INFORMATION_SCHEMA.STATISTICS checks (same pattern used for the Sessions index), and DEALLOCATE PREPARE after each block so prepared-statement names don't accumulate. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/zm_update-1.39.10.sql | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/db/zm_update-1.39.10.sql b/db/zm_update-1.39.10.sql index c4dbfee57..ee93735b3 100644 --- a/db/zm_update-1.39.10.sql +++ b/db/zm_update-1.39.10.sql @@ -5,8 +5,33 @@ -- without rebuilding the table by changing the primary key. -- Removing Logs_Component_idx because it's now redundant. -- -ALTER TABLE `Logs` ADD INDEX `Logs_Component_Level_TimeKey_Id_idx` (`Component`, `Level`, `TimeKey`, `Id`); -ALTER TABLE `Logs` DROP INDEX `Logs_Component_idx`; +SET @s = (SELECT IF( + (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Logs' + AND table_schema = DATABASE() + AND index_name = 'Logs_Component_Level_TimeKey_Id_idx' + ) > 0, + "SELECT 'Logs_Component_Level_TimeKey_Id_idx already exists on Logs table'", + "ALTER TABLE `Logs` ADD INDEX `Logs_Component_Level_TimeKey_Id_idx` (`Component`, `Level`, `TimeKey`, `Id`)" +)); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @s = (SELECT IF( + (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'Logs' + AND table_schema = DATABASE() + AND index_name = 'Logs_Component_idx' + ) > 0, + "ALTER TABLE `Logs` DROP INDEX `Logs_Component_idx`", + "SELECT 'Logs_Component_idx already removed from Logs table'" +)); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; -- Recalibrate stock ZonePresets for modern HD resolutions. -- @@ -43,4 +68,4 @@ SET @s = (SELECT IF( PREPARE stmt FROM @s; EXECUTE stmt; - +DEALLOCATE PREPARE stmt; From 60d2e3d7a858637f8f6f369ca45032bf8c9b9ff7 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Mon, 18 May 2026 18:36:23 -0400 Subject: [PATCH 158/168] fix: polyfill str_starts_with/str_ends_with/str_contains for PHP 7 These functions are PHP 8.0+ but ZoneMinder is called from views on PHP 7 installs (event.php uses str_ends_with at file scope, triggering a fatal "Call to undefined function" before the page can render). Add function_exists-guarded polyfills at the top of functions.php so PHP 8+ keeps the native implementations and PHP 7.x picks up the fallbacks. functions.php is required by index.php before any view, so all four call sites (functions.php, event.php, views/image.php) are covered. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/includes/functions.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/web/includes/functions.php b/web/includes/functions.php index ec3b0d4bc..4d5ad44a3 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -21,6 +21,26 @@ require_once('Filter.php'); require_once('FilterTerm.php'); +// Polyfills for PHP 8.0+ string functions, so views and callers don't have to +// guard each use. ZoneMinder still supports PHP 7.x in some distros. +if (!function_exists('str_starts_with')) { + function str_starts_with(string $haystack, string $needle): bool { + return $needle === '' || strncmp($haystack, $needle, strlen($needle)) === 0; + } +} +if (!function_exists('str_ends_with')) { + function str_ends_with(string $haystack, string $needle): bool { + if ($needle === '' || $needle === $haystack) return true; + $nlen = strlen($needle); + return $nlen <= strlen($haystack) && substr_compare($haystack, $needle, -$nlen) === 0; + } +} +if (!function_exists('str_contains')) { + function str_contains(string $haystack, string $needle): bool { + return $needle === '' || strpos($haystack, $needle) !== false; + } +} + function noCacheHeaders() { header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); // Date in the past header('Last-Modified: '.gmdate( 'D, d M Y H:i:s' ).' GMT'); // always modified From db6747402ede901abcc8027b6cbbf408cc707288 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 23 May 2026 09:24:35 -0400 Subject: [PATCH 159/168] fix: keep DefaultVideo as mp4 name throughout event lifecycle refs #4839 DefaultVideo was being set to 'index.m3u8' at INSERT when the video writer was enabled, then overwritten with the codec-bearing mp4 name at event close. That left a window during recording where consumers saw 'index.m3u8' instead of a real mp4 filename, and forced every consumer (view_video.php, image.php, event.php) to special-case the 'index.m3u8' string. Now: - INSERT leaves DefaultVideo empty. - After videoStore->open() succeeds, UPDATE DefaultVideo to the codec-bearing incomplete.{codec}.{container} name. If the rename to add the codec failed, fall back to the plain incomplete name so DefaultVideo always matches what is actually on disk. - Close-time UPDATE (existing) sets the final {id}-video.{codec}. {container}. Invariant for consumers: non-empty DefaultVideo => a playable mp4 is being written or has been written. The codec is encoded in the name for the entire recording lifetime. event.php: drop the str_ends_with(DefaultVideo, '.m3u8') branch from $video_tag (codec detection on the next line already covers in-progress events now that DefaultVideo carries the codec). Drop the DefaultVideo === 'index.m3u8' download-tooltip special case. Remove the stale comment about the constructor-time 'index.m3u8' value. The === 'index.m3u8' checks in view_video.php and image.php are left in place as belt-and-braces for any legacy DB rows recorded under the old behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_event.cpp | 5 ++++- web/skins/classic/views/event.php | 7 ++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/zm_event.cpp b/src/zm_event.cpp index 4bb68eb78..7838b72af 100644 --- a/src/zm_event.cpp +++ b/src/zm_event.cpp @@ -140,7 +140,7 @@ Event::Event( state_id, monitor->getOrientation(), 0, - (monitor->GetOptVideoWriter() != 0) ? "index.m3u8" : video_incomplete_file.c_str(), + "", // DefaultVideo: populated after videoStore opens (codec known) save_jpegs, storage->SchemeString().c_str(), monitor->Latitude(), @@ -815,6 +815,9 @@ void Event::Run() { video_incomplete_file = new_incomplete; video_incomplete_path = new_incomplete_path; } + // Surface the (codec-bearing if rename succeeded) name to consumers before close + zmDbDo(stringtf("UPDATE Events SET DefaultVideo='%s' WHERE Id=%" PRIu64, + video_incomplete_file.c_str(), id)); } } // end if GetOptVideoWriter diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 38563a802..1a2979faf 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -152,7 +152,6 @@ if ((!$replayMode) or !$replayModes[$replayMode]) { } $video_tag = ($codec == 'MP4') || ($codec == 'MP4HLS') || - str_ends_with($Event->DefaultVideo(), '.m3u8') || ((false !== strpos($Event->DefaultVideo(), 'h264') || false !== strpos($Event->DefaultVideo(), 'av1')) && ($codec === 'auto')); @@ -211,7 +210,7 @@ if ( $Event->Id() and !file_exists($Event->Path()) ) DefaultVideo()) ?>" + title="DefaultVideo() ?>" download DefaultVideo() ? '' : 'style="display:none;"' ?> > @@ -357,9 +356,7 @@ if ($video_tag) { // Prefer HLS byte-range playback when the manifest exists on disk and the // user picked MP4HLS / auto. Explicit MP4 must stay native ("play the mp4 // file directly"); explicit MJPEG never reaches here because $video_tag is - // false for it. DefaultVideo's extension is deliberately not consulted — - // in-progress events have DefaultVideo='index.m3u8' from the constructor, - // and using that as a signal would override the explicit MP4 choice. + // false for it. $has_hls = file_exists($Event->Path() . '/index.m3u8') && (($codec == 'MP4HLS') || ($codec == 'auto')); if ($has_hls) { From 12ef81b8a5681ad520c54e7e4e5015ef4531a646 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 23 May 2026 09:48:49 -0400 Subject: [PATCH 160/168] fix: avoid OOB read on negative gsoap error codes in ONVIF Subscribe fixes #4842 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOAP_STRINGS[] was indexed by the gsoap return code with only an rc>8 upper-bound check, so SOAP_EOF (-1) — a common timeout/disconnect result — produced SOAP_STRINGS[-1], an out-of-bounds read that surfaced as garbage strings like "Always" in error logs. The cutoff of 8 was also stale: the array had 13 entries (0-12), so codes 9-12 were silently dropping their names too, and the sparse gsoap range (TCP/HTTP/SSL at 27-31, validation at 39-52, SOAP_STOP at 1000) was unrepresentable by an array. Replace the array with a soap_error_name(int) switch in the anonymous namespace that covers the codes seen in practice on ONVIF cameras (SOAP_EOF, the SOAP_* codes 0-13/15/20/22-23, the network/SSL block 27-31, SOAP_VERSIONMISMATCH, SOAP_STOP) and returns "UNKNOWN" otherwise. The Subscribe() call site collapses to a single Error() call — no more branching on rc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_monitor_onvif.cpp | 63 +++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/src/zm_monitor_onvif.cpp b/src/zm_monitor_onvif.cpp index 8413979c7..fd92c79f7 100644 --- a/src/zm_monitor_onvif.cpp +++ b/src/zm_monitor_onvif.cpp @@ -41,23 +41,42 @@ namespace { inline std::string FormatDurationSeconds(int seconds) { return "PT" + std::to_string(seconds) + "S"; } -} -std::string SOAP_STRINGS[] = { - "SOAP_OK", // 0 - "SOAP_CLI_FAULT", // 1 - "SOAP_SVR_FAULT", // 2 - "SOAP_TAG_MISMATCH", // 3 - "SOAP_TYPE", // 4 - "SOAP_SYNTAX_ERROR", // 5 - "SOAP_NO_TAG", // 6 - "SOAP_IOB", // 7 - "SOAP_MUSTUNDERSTAND", // 8 - "SOAP_NAMESPACE", // 9 - "SOAP_USER_ERROR", // 10 - "SOAP_FATAL_ERROR", // 11 - "SOAP_FAULT", // 12 -}; + // gsoap error codes are sparse (range -1..1000 with gaps), so a lookup + // table is the wrong shape. Cover the codes that show up in practice for + // ONVIF cameras; everything else falls through to "UNKNOWN". + const char *soap_error_name(int rc) { + switch (rc) { + case SOAP_EOF: return "SOAP_EOF"; // -1, also a timeout/disconnect + case SOAP_OK: return "SOAP_OK"; // 0 + case SOAP_CLI_FAULT: return "SOAP_CLI_FAULT"; // 1 + case SOAP_SVR_FAULT: return "SOAP_SVR_FAULT"; // 2 + case SOAP_TAG_MISMATCH: return "SOAP_TAG_MISMATCH"; // 3 + case SOAP_TYPE: return "SOAP_TYPE"; // 4 + case SOAP_SYNTAX_ERROR: return "SOAP_SYNTAX_ERROR"; // 5 + case SOAP_NO_TAG: return "SOAP_NO_TAG"; // 6 + case SOAP_IOB: return "SOAP_IOB"; // 7 + case SOAP_MUSTUNDERSTAND: return "SOAP_MUSTUNDERSTAND"; // 8 + case SOAP_NAMESPACE: return "SOAP_NAMESPACE"; // 9 + case SOAP_USER_ERROR: return "SOAP_USER_ERROR"; // 10 + case SOAP_FATAL_ERROR: return "SOAP_FATAL_ERROR"; // 11 + case SOAP_FAULT: return "SOAP_FAULT"; // 12 + case SOAP_NO_METHOD: return "SOAP_NO_METHOD"; // 13 + case SOAP_GET_METHOD: return "SOAP_GET_METHOD"; // 15 + case SOAP_EOM: return "SOAP_EOM"; // 20 + case SOAP_HDR: return "SOAP_HDR"; // 22 + case SOAP_NULL: return "SOAP_NULL"; // 23 + case SOAP_UDP_ERROR: return "SOAP_UDP_ERROR"; // 27 + case SOAP_TCP_ERROR: return "SOAP_TCP_ERROR"; // 28 + case SOAP_HTTP_ERROR: return "SOAP_HTTP_ERROR"; // 29 + case SOAP_SSL_ERROR: return "SOAP_SSL_ERROR"; // 30 + case SOAP_ZLIB_ERROR: return "SOAP_ZLIB_ERROR"; // 31 + case SOAP_VERSIONMISMATCH: return "SOAP_VERSIONMISMATCH"; // 39 + case SOAP_STOP: return "SOAP_STOP"; // 1000 + default: return "UNKNOWN"; + } + } +} ONVIF::ONVIF(Monitor *parent_) : parent(parent_) @@ -296,14 +315,10 @@ void ONVIF::Subscribe() { || (fault_string && (std::strstr(fault_string, "authoriz") || std::strstr(fault_string, "Authoriz")))); - if (rc > 8) { - Error("ONVIF: Couldn't create subscription at %s! %d, fault:%s, detail:%s", event_endpoint_url_.c_str(), - rc, fault_string, detail ? detail : "null"); - } else { - Error("ONVIF: Couldn't create subscription at %s! %d %s, fault:%s, detail:%s", event_endpoint_url_.c_str(), - rc, SOAP_STRINGS[rc].c_str(), - fault_string, detail ? detail : "null"); - } + Error("ONVIF: Couldn't create subscription at %s! %d %s, fault:%s, detail:%s", + event_endpoint_url_.c_str(), + rc, soap_error_name(rc), + fault_string, detail ? detail : "null"); // If authentication failed and we were using digest, try plain authentication if (auth_error && !try_usernametoken_auth) { From ac13e84ace4baf34b4a40f1776666c99e7733770 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 24 May 2026 22:28:05 +0300 Subject: [PATCH 161/168] Fix: Correct reading of zmWatchMuted cookies --- web/js/MonitorStream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index 31e53d3af..7de2f0bea 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -7,7 +7,7 @@ function MonitorStream(monitorData) { this.name = monitorData.name; this.started = false; this.zmsState = null; - this.muted = (currentView == 'watch') ? !!getCookie('zmWatchMuted') : true; + this.muted = (currentView == 'watch') ? (getCookie('zmWatchMuted') !== 'false') : true; this.connKey = monitorData.connKey; this.genConnKey = function() { return (Math.floor((Math.random() * 999999) + 1)).toLocaleString('en-US', {minimumIntegerDigits: 6, useGrouping: false}); From 36c9924d2c66055433ea3561184922b0744f34ce Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 28 May 2026 13:44:13 +0300 Subject: [PATCH 162/168] More readable text color for the DBG level for the dark theme (log.css) --- web/skins/classic/css/dark/views/log.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/css/dark/views/log.css b/web/skins/classic/css/dark/views/log.css index ac4d7ded9..98d1577e3 100644 --- a/web/skins/classic/css/dark/views/log.css +++ b/web/skins/classic/css/dark/views/log.css @@ -39,7 +39,7 @@ tr.log-war td { } tr.log-dbg td { - color: #666666; + color: #aaaaaa; font-style: italic; } From 22b99a3502097294ff0146edb59ac9bd8a159528 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 28 May 2026 16:41:28 +0300 Subject: [PATCH 163/168] Remove "Loading, please wait" if nothing is found (add_monitors.js) Bootstrap table will display "No matching records found" --- web/skins/classic/views/js/add_monitors.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/skins/classic/views/js/add_monitors.js b/web/skins/classic/views/js/add_monitors.js index d317be61e..8be64791f 100644 --- a/web/skins/classic/views/js/add_monitors.js +++ b/web/skins/classic/views/js/add_monitors.js @@ -53,6 +53,8 @@ function probe(params) { const rows = data.Streams; // rearrange the result into what bootstrap-table expects params.success({total: rows.length, totalNotFiltered: rows.length, rows: rows}); + } else { + params.success({total: 0, totalNotFiltered: 0, rows: []}); } }, error: function(jqXHR) { From e66032d310ddd6c94ba1d5b617e97b62856fb18b Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 28 May 2026 22:25:16 +0300 Subject: [PATCH 164/168] Apply scrollbar style to all elements (skin.css) --- web/skins/classic/css/base/skin.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css index 72feed568..a366e233c 100644 --- a/web/skins/classic/css/base/skin.css +++ b/web/skins/classic/css/base/skin.css @@ -1210,21 +1210,21 @@ a.flip { } /* Change scrollbar style */ -div::-webkit-scrollbar, nav::-webkit-scrollbar, .chosen-results::-webkit-scrollbar { +*::-webkit-scrollbar { width: 11px; height: 11px; } -html, div, nav, .chosen-results { +* { scrollbar-width: thin; scrollbar-color: var(--sliderBG) var(--scrollbarBG); } -html::-webkit-scrollbar-track, div::-webkit-scrollbar-track, nav::-webkit-scrollbar-track, .chosen-results::-webkit-scrollbar-track { +*::-webkit-scrollbar-track { background: var(--scrollbarBG); } -html::-webkit-scrollbar-thumb, div::-webkit-scrollbar-thumb, nav::-webkit-scrollbar-thumb, .chosen-results::-webkit-scrollbar-thumb { +*::-webkit-scrollbar-thumb { background-color: var(--sliderBG); border-radius: 6px; border: 3px solid var(--scrollbarBG); From 5d453d8c7823f63bd86be7eec69f3ca42c665fa2 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 28 May 2026 22:28:48 +0300 Subject: [PATCH 165/168] Changed the style of the active button in "nav #pills-tab" (monitor.css) --- web/skins/classic/css/base/views/monitor.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/skins/classic/css/base/views/monitor.css b/web/skins/classic/css/base/views/monitor.css index b9e809372..699b833ed 100644 --- a/web/skins/classic/css/base/views/monitor.css +++ b/web/skins/classic/css/base/views/monitor.css @@ -74,6 +74,10 @@ tr td input[type="radio"] { text-align: right; } +#content { + padding-top: 0.5rem; +} + body.sticky #content { overflow-y: auto; height: 100%; @@ -106,6 +110,11 @@ nav #pills-tab .nav-item.form-control-sm { height: auto; } +nav #pills-tab a.nav-link.active { + background-color: var(--colorBackgroundButtons); + border: 1px #ccc solid; +} + .EncoderParameters label { vertical-align: top; } From 9a010ba67e53e0e77504b6c238f071b0f463b887 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Thu, 28 May 2026 22:33:59 +0300 Subject: [PATCH 166/168] Changed the style of the active button in "nav #pills-tab" for the dark theme (monitor.css) --- web/skins/classic/css/dark/views/monitor.css | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/web/skins/classic/css/dark/views/monitor.css b/web/skins/classic/css/dark/views/monitor.css index cf15ee368..5dbc281aa 100644 --- a/web/skins/classic/css/dark/views/monitor.css +++ b/web/skins/classic/css/dark/views/monitor.css @@ -1,7 +1,7 @@ .swatch { - border: 1px solid black; - margin-left: 3px; - padding: 0px; + border: 1px solid black; + margin-left: 3px; + padding: 0px; } /* Encoder preset diagnostics — advisory text under the EncoderParameters textarea */ @@ -10,3 +10,9 @@ color: #b58900; margin-top: 4px; } + +nav #pills-tab a.nav-link.active { + background-color: rgb(68, 68, 68); + color: #dddddd; + border: 1px solid var(--gray); +} From 40710d5c1dcb9a892ea53029c931143e7d067772 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 29 May 2026 00:23:17 +0300 Subject: [PATCH 167/168] Fix Aligning input fields (sidebar.css) --- web/skins/classic/css/base/sidebar.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/skins/classic/css/base/sidebar.css b/web/skins/classic/css/base/sidebar.css index 3b1806528..3d45caaed 100644 --- a/web/skins/classic/css/base/sidebar.css +++ b/web/skins/classic/css/base/sidebar.css @@ -271,7 +271,7 @@ body #sidebarMain .sub-menu-list { .extruder-wrapper span.term, .extruder-wrapper span.term .term-value-wrapper input, .extruder-wrapper span.term > span { /* Aligning input fields */ - width: 100%; + width: 100% !important; } .extruder-wrapper span.term > span:first-child{ From a075e55006ff83ca2e6456f53a89d7660f2ca073 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 29 May 2026 00:33:02 +0300 Subject: [PATCH 168/168] Added the "controlHeader" class to the filter block on the Events page (Filter.php) --- web/includes/Filter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/includes/Filter.php b/web/includes/Filter.php index ad7efabf6..8639504ce 100644 --- a/web/includes/Filter.php +++ b/web/includes/Filter.php @@ -1089,7 +1089,7 @@ class Filter extends ZM_Object { // This displays filters from the events page. // public function simple_widget() { - $html = '
'; + $html = '
'; $terms = $this->terms(); $attrTypes = $this->attrTypes(); $opTypes = $this->opTypes();
' .htmlspecialchars($eventCounts[$i]['title']) .'