Files
zoneminder/web/includes/actions/options.php
Isaac Connor 787722d103 feat: add AI dataset/model/class management UI to Options
Add three Options tabs (AI Datasets, AI Models, AI Classes) with full
CRUD, backed by the AI_* tables:

- List views (_options_ai_{datasets,models,classes}.php), edit modals
  (ajax/modals/ai_{dataset,model,class}.php) and action handlers
  (actions/ai_{dataset,model,class}.php).
- options.js loads the modals over ajax, wires the Add/edit buttons and
  the AI Classes dataset filter.
- options.php dispatches the new tab includes; functions.php registers
  the three tabs in the Options sub-menu with readable labels (they are
  not Config categories, so they need explicit entries).
- actions/options.php routes object=ai_* deletes to the matching
  handler; saves post directly to view=ai_*.

All tabs and actions are gated on System permission.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:24:50 -04:00

323 lines
12 KiB
PHP

<?php
//
// ZoneMinder web action file
// Copyright (C) 2019 ZoneMinder LLC
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
//
// System edit actions
if ( !canEdit('System') ) {
ZM\Warning('Must have System permissions to perform options actions');
return;
}
global $error_message;
// AI object-detection CRUD lives in dedicated action files. The list views post
// here (view=options) with object=ai_* for deletes; route those to the matching
// handler, which processes both save and delete via $action.
if ( isset($_REQUEST['object']) && in_array($_REQUEST['object'], array('ai_model', 'ai_dataset', 'ai_class'), true) ) {
require_once('includes/actions/'.$_REQUEST['object'].'.php');
return;
}
if ( $action == 'delete' ) {
if ( isset($_REQUEST['object']) ) {
if ( $_REQUEST['object'] == 'server' ) {
if ( !empty($_REQUEST['markIds']) ) {
foreach ( $_REQUEST['markIds'] as $Id ) {
dbQuery('DELETE FROM Servers WHERE Id=?', array($Id));
ZM\AuditAction('delete', 'server', $Id, '');
}
}
$refreshParent = true;
} else if ( $_REQUEST['object'] == 'storage' ) {
if ( !empty($_REQUEST['markIds']) ) {
foreach ( $_REQUEST['markIds'] as $Id ) {
dbQuery('DELETE FROM Storage WHERE Id=?', array($Id));
ZM\AuditAction('delete', 'storage', $Id, '');
}
}
$refreshParent = true;
} else if ( $_REQUEST['object'] == 'role' ) {
if ( !empty($_REQUEST['markRids']) ) {
foreach ( $_REQUEST['markRids'] as $Id ) {
dbQuery('DELETE FROM User_Roles WHERE Id=?', array($Id));
ZM\AuditAction('delete', 'role', $Id, '');
}
}
$redirect = '?view=options&tab=roles';
} # end if isset($_REQUEST['object'] )
} else if ( isset($_REQUEST['markUids']) ) {
// deletes users
foreach ($_REQUEST['markUids'] as $markUid) {
dbQuery('DELETE FROM Users WHERE Id = ?', array($markUid));
ZM\AuditAction('delete', 'user', $markUid, '');
}
if ($markUid == $user->Id()) {
userLogout();
$redirect = '?view=login';
} else {
$redirect = '?view=options&tab=users';
}
}
} else if ( $action == 'options' && isset($_REQUEST['tab']) ) {
$result = dbQuery('SELECT Name,Value,Type,`System` FROM Config WHERE Category=? ORDER BY Id ASC', array($_REQUEST['tab']));
if (!$result) {
$error_message .= 'Error loading config for tab ' . htmlspecialchars($_REQUEST['tab']) . ': ' . htmlspecialchars(dbLastError()) . '<br/>';
return;
}
$changed = false;
while ($config = dbFetchNext($result)) {
unset($newValue);
if ( ($config['Type'] == 'boolean') and empty($_REQUEST['newConfig'][$config['Name']]) ) {
$newValue = 0;
} else if (isset($_REQUEST['newConfig'][$config['Name']])) {
$newValue = preg_replace("/\r\n/", "\n", $_REQUEST['newConfig'][$config['Name']]);
}
if (isset($newValue) && ($newValue != $config['Value'])) {
# Handle special cases first
if ($config['Name'] == 'ZM_LANG_DEFAULT') {
# Verify that the language file exists in the lang directory.
if (!file_exists(ZM_PATH_WEB.'/lang/'.$newValue.'.php')) {
$error_message .= 'Error setting ' . $config['Name'].'. New value ' .$newValue.' not saved because '.ZM_PATH_WEB.'/lang/'.$newValue.'.php doesn\'t exist.<br/>';
ZM\Error($error_message);
continue;
}
}
if (dbQuery('UPDATE Config SET Value=? WHERE Name=?', array($newValue, $config['Name'])) === null) {
$error_message .= 'Error saving ' . htmlspecialchars($config['Name']) . ': ' . htmlspecialchars(dbLastError()) . '<br/>';
ZM\Error('Error saving config '.$config['Name'].': '.dbLastError());
} else {
$changed = true;
}
} # end if value changed
} # end foreach config entry
if ( $changed ) {
ZM\AuditAction('update', 'config', 0, 'Tab: '.$_REQUEST['tab']);
switch ( $_REQUEST['tab'] ) {
case 'system' :
case 'config' :
$restartWarning = true;
break;
case 'API':
case 'web' :
case 'tools' :
break;
case 'logging' :
case 'network' :
case 'mail' :
case 'upload' :
$restartWarning = true;
break;
case 'highband' :
case 'medband' :
case 'lowband' :
break;
}
loadConfig(false);
# Might need to update auth hash
# This doesn't work because the config are constants and won't really be loaded until the next refresh.
#generateAuthHash(ZM_AUTH_HASH_IPS, true);
}
# On error stay on the page (no redirect) so the error is shown; otherwise
# redirect to drop the POST and reload with fresh values.
if (!$error_message) {
$redirect = '?view=options&tab='.$_REQUEST['tab'];
}
return;
} else if ($action == 'save') {
if (isset($_REQUEST['object'])) {
if ($_REQUEST['object'] == 'dnsmasq') {
$config = isset($_REQUEST['config']) ? $_REQUEST['config'] : [];
$conf = '';
foreach ($config as $name=>$value) {
if ($name == 'dhcp-host') {
foreach ($value as $mac=>$ip) {
$conf .= $name.'='.$mac.','.$ip.PHP_EOL;
}
} else if ($name == 'dhcp-option') {
foreach ($value as $option_name=>$option_value) {
$conf .= $name.'='.$option_name.','.$option_value.PHP_EOL;
}
} else if (
($name == 'bind-interfaces')
or
($name == 'dhcp-authoritative')
) {
if ($value=='yes') {
$conf .= $name.PHP_EOL;
}
} else if ($name == 'dhcp-range') {
$conf .= $name.'='.$value['min'].','.$value['max'].','.$value['expires'].PHP_EOL;
} else {
if (is_array($value)) {
foreach ($value as $v) {
}
} else {
$conf .= $name.'='.$value.PHP_EOL;
}
}
}
if (false===file_put_contents(ZM_PATH_DNSMASQ_CONF, $conf)) {
ZM\Warning("Failed to writh to ".ZM_PATH_DNSMASQ_CONF);
} else {
exec('sudo -n /bin/systemctl restart dnsmasq.service');
}
exec('sudo -n /bin/systemctl restart dnsmasq.service');
}
}
} else if ($action == 'start') {
if (isset($_REQUEST['object'])) {
if ($_REQUEST['object'] == 'dnsmasq') {
exec('sudo -n /bin/systemctl start dnsmasq.service', $output, $result);
if ($result) {
ZM\Warning("Error execing sudo -n /bin/systemctl start dnsmasq.service. Output: ".implode(PHP_EOL, $output));
} else {
ZM\Debug("Error execing sudo -n /bin/systemctl start dnsmasq.service. Output: ".implode(PHP_EOL, $output));
}
}
}
} else if ($action == 'stop') {
if (isset($_REQUEST['object'])) {
if ($_REQUEST['object'] == 'dnsmasq') {
exec('sudo -n /bin/systemctl stop dnsmasq.service', $output, $result);
if ($result) {
ZM\Warning("Error execing sudo -n /bin/systemctl start dnsmasq.service. Output: ".implode(PHP_EOL, $output));
} else {
ZM\Debug("Error execing sudo -n /bin/systemctl start dnsmasq.service. Output: ".implode(PHP_EOL, $output));
}
}
}
} else if ($action == 'menuitems') {
if (!canEdit('System')) {
ZM\Warning('Need System permission to edit menu items');
} else if (isset($_REQUEST['items'])) {
require_once('includes/MenuItem.php');
$allItems = ZM\MenuItem::find();
foreach ($allItems as $item) {
$id = $item->Id();
$enabled = isset($_REQUEST['items'][$id]['Enabled']) ? 1 : 0;
$label = isset($_REQUEST['items'][$id]['Label']) ? trim($_REQUEST['items'][$id]['Label']) : null;
$sortOrder = isset($_REQUEST['items'][$id]['SortOrder']) ? intval($_REQUEST['items'][$id]['SortOrder']) : $item->SortOrder();
if ($label === '') $label = null;
$iconType = isset($_REQUEST['items'][$id]['IconType']) ? $_REQUEST['items'][$id]['IconType'] : $item->IconType();
if (!in_array($iconType, ['material', 'fontawesome', 'image', 'none'])) $iconType = 'material';
$icon = isset($_REQUEST['items'][$id]['Icon']) ? trim($_REQUEST['items'][$id]['Icon']) : $item->Icon();
if ($icon === '') $icon = null;
$link = isset($_REQUEST['items'][$id]['Link']) ? trim($_REQUEST['items'][$id]['Link']) : $item->Link();
if ($link === '') $link = null;
// The MenuKey is only editable for custom (non-built-in) entries, so it
// is only present in the request for those rows; keep it otherwise.
$menuKey = isset($_REQUEST['items'][$id]['MenuKey']) ? trim($_REQUEST['items'][$id]['MenuKey']) : $item->MenuKey();
if ($menuKey === '') $menuKey = $item->MenuKey();
if (!$item->save([
'MenuKey' => $menuKey,
'Enabled' => $enabled,
'Label' => $label,
'SortOrder' => $sortOrder,
'Icon' => $icon,
'IconType' => $iconType,
'Link' => $link,
])) {
$error_message .= 'Failed to save menu item ' . htmlspecialchars($menuKey) . ': ' . htmlspecialchars($item->get_last_error()) . '<br/>';
}
}
// Insert any new menu entries added via the "Add" button.
if (isset($_REQUEST['newItems']) && is_array($_REQUEST['newItems'])) {
foreach ($_REQUEST['newItems'] as $new) {
$menuKey = isset($new['MenuKey']) ? trim($new['MenuKey']) : '';
if ($menuKey === '') continue;
$enabled = isset($new['Enabled']) ? 1 : 0;
$label = isset($new['Label']) ? trim($new['Label']) : null;
if ($label === '') $label = null;
$sortOrder = isset($new['SortOrder']) ? intval($new['SortOrder']) : 0;
$iconType = isset($new['IconType']) ? $new['IconType'] : 'material';
if (!in_array($iconType, ['material', 'fontawesome', 'image', 'none'])) $iconType = 'material';
$icon = isset($new['Icon']) ? trim($new['Icon']) : null;
if ($icon === '') $icon = null;
$link = isset($new['Link']) ? trim($new['Link']) : null;
if ($link === '') $link = null;
$newItem = new ZM\MenuItem();
if (!$newItem->save([
'MenuKey' => $menuKey,
'Enabled' => $enabled,
'Label' => $label,
'SortOrder' => $sortOrder,
'Icon' => $icon,
'IconType' => $iconType,
'Link' => $link,
])) {
$error_message .= 'Failed to add menu item ' . htmlspecialchars($menuKey) . ': ' . htmlspecialchars($newItem->get_last_error()) . '<br/>';
}
}
}
}
# Stay on the page when something failed so the error is shown.
if (!$error_message) {
$redirect = '?view=options&tab=menu';
}
} else if ($action == 'deletemenuitems') {
if (!canEdit('System')) {
ZM\Warning('Need System permission to delete menu items');
} else if (!empty($_REQUEST['deleteIds']) && is_array($_REQUEST['deleteIds'])) {
$ids = array_values(array_filter(array_map('intval', $_REQUEST['deleteIds']), function($v) { return $v > 0; }));
if (count($ids)) {
$placeholders = implode(',', array_fill(0, count($ids), '?'));
if (dbQuery('DELETE FROM `Menu_Items` WHERE `Id` IN ('.$placeholders.')', $ids) === null) {
$error_message .= 'Failed to delete menu entries: ' . htmlspecialchars(dbLastError()) . '<br/>';
}
}
}
if (!$error_message) {
$redirect = '?view=options&tab=menu';
}
} else if ($action == 'resetmenu') {
if (!canEdit('System')) {
ZM\Warning('Need System permission to reset menu items');
} else {
dbQuery('DELETE FROM Menu_Items');
dbQuery("INSERT INTO `Menu_Items` (`MenuKey`, `Enabled`, `SortOrder`) VALUES
('Console', 1, 10),
('Montage', 1, 20),
('MontageReview', 1, 30),
('Events', 1, 40),
('Options', 1, 50),
('Log', 1, 60),
('Devices', 1, 70),
('IntelGpu', 1, 80),
('Groups', 1, 90),
('Filters', 1, 100),
('Snapshots', 1, 110),
('Reports', 1, 120),
('ReportEventAudit', 1, 130),
('Map', 1, 140)");
}
$redirect = '?view=options&tab=menu';
} // end if object vs action
?>