mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-11-07 05:03:00 -05:00
Looking for other instances of the CronTab.php fatal in the previous commit found eleven, in ten files, dating from 2019 to 2023. Each is a call to a logger function from the global namespace without the \ZM\ prefix, so each is a fatal the moment its branch runs: web/ajax/console.php, events.php, reports.php, add_monitors.php, status.php web/includes/actions/snapshot.php (two), donate.php web/includes/functions.php, session.php web/views/image.php They have survived this long because of where they sit. Every one is on an error or diagnostic path -- an invalid sort order, a zero byte download, a failed read lock -- so the call is only reached when something has already gone wrong, and then it turns a handled problem into a blank 500. That is the shape of the bug on the monitor save: the write had worked, and the thing that broke was the attempt to log. Verified before changing any of them that each file is in the global namespace, does not define the function itself and does not import it, so the call really does not resolve. Tests: tests/php/test_logger_calls_qualified.php, 16 checks. It tokenizes every php file under web/ and reports unqualified calls, with the function names read out of logger.php rather than listed, so a new one is covered the day it is added. web/api is skipped as cakephp with its own logging, and assets as third party. Php's own tokenizer rather than a regex, because the distinctions matter: a method call on an object that happens to have an Error() method is fine, as is a definition, a mention, a qualified call, the same call from inside namespace ZM, and the name appearing in a comment or a string. There is a check for each of those, since a scan that quietly matched nothing would pass the tree-wide check on its own. Verified the scan fails when the Debug() in CronTab.php is put back. Ran: tests/php/test_logger_calls_qualified.php (16 passed), the rest of tests/php and tests/js, npx eslint . (clean), utils/check-homoglyphs.py (clean). test_bandwidth_clamp.php fails on this box for an unrelated reason: it cannot read /etc/zm/conf.d/03custom.conf and the permission warning lands in its captured output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
694 lines
28 KiB
PHP
694 lines
28 KiB
PHP
<?php
|
|
$message = '';
|
|
$data = array();
|
|
|
|
// Handle query task for bootstrap-table AJAX requests
|
|
if (!empty($_REQUEST['task'])) {
|
|
$task = $_REQUEST['task'];
|
|
|
|
if ($task == 'query') {
|
|
$data = queryRequest();
|
|
ajaxResponse($data);
|
|
return;
|
|
}
|
|
|
|
if ($task == 'export') {
|
|
exportMonitorsJSON();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Handle legacy action-based requests
|
|
if ( canEdit('Monitors') ) {
|
|
switch ( $_REQUEST['action'] ) {
|
|
case 'sort' :
|
|
{
|
|
$monitor_ids = $_POST['monitor_ids'];
|
|
# Two concurrent sorts could generate odd sorting... so lock the table.
|
|
$dbConn = zmDbConn();
|
|
$dbConn->beginTransaction();
|
|
$dbConn->exec('LOCK TABLES Monitors WRITE');
|
|
for ( $i = 0; $i < count($monitor_ids); $i += 1 ) {
|
|
$monitor_id = $monitor_ids[$i];
|
|
$monitor_id = preg_replace('/^monitor_id-/', '', $monitor_id);
|
|
if ( ( !$monitor_id ) or ! ( is_integer($monitor_id) or ctype_digit($monitor_id) ) ) {
|
|
\ZM\Warning('Got '.$monitor_id.' from '.$monitor_ids[$i]);
|
|
continue;
|
|
}
|
|
dbQuery('UPDATE Monitors SET Sequence=? WHERE Id=?', array($i, $monitor_id));
|
|
} // end for each monitor_id
|
|
$dbConn->commit();
|
|
$dbConn->exec('UNLOCK TABLES');
|
|
|
|
return;
|
|
} // end case sort
|
|
default:
|
|
ZM\Warning('unknown action '.$_REQUEST['action']);
|
|
}
|
|
} else {
|
|
ZM\Warning('Cannot edit monitors');
|
|
}
|
|
|
|
ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissions for user ' . $user->Username());
|
|
|
|
//
|
|
// FUNCTION DEFINITIONS
|
|
//
|
|
|
|
function queryRequest() {
|
|
global $user, $Servers;
|
|
require_once('includes/Monitor.php');
|
|
require_once('includes/Group.php');
|
|
require_once('includes/Group_Monitor.php');
|
|
require_once getSkinFile('views/_monitor_filters.php');
|
|
|
|
$data = array(
|
|
'total' => 0,
|
|
'totalNotFiltered' => 0,
|
|
'rows' => array()
|
|
);
|
|
|
|
// Get pagination parameters
|
|
$offset = 0;
|
|
if (isset($_REQUEST['offset']) and ($_REQUEST['offset'] != 'NaN')) {
|
|
if ((!is_int($_REQUEST['offset']) and !ctype_digit($_REQUEST['offset']))) {
|
|
ZM\Error('Invalid value for offset: ' . $_REQUEST['offset']);
|
|
} else {
|
|
$offset = $_REQUEST['offset'];
|
|
}
|
|
}
|
|
|
|
$limit = 0;
|
|
if (isset($_REQUEST['limit']) and ($_REQUEST['limit'] != 'NaN')) {
|
|
if ((!is_int($_REQUEST['limit']) and !ctype_digit($_REQUEST['limit']))) {
|
|
ZM\Error('Invalid value for limit: ' . $_REQUEST['limit']);
|
|
} else {
|
|
$limit = $_REQUEST['limit'];
|
|
}
|
|
}
|
|
|
|
// Get search parameter
|
|
$search = isset($_REQUEST['search']) ? $_REQUEST['search'] : '';
|
|
|
|
// Get sort parameters
|
|
$sort = isset($_REQUEST['sort']) ? $_REQUEST['sort'] : 'Sequence';
|
|
$order = isset($_REQUEST['order']) ? strtoupper($_REQUEST['order']) : 'ASC';
|
|
|
|
// Build monitor query with filters from request parameters, falling back to cookies
|
|
$conditions = array();
|
|
$values = array();
|
|
|
|
// 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' => 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.
|
|
// 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(array_map('validCardinal', $groupIds)));
|
|
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]);
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (array('Capturing','Analysing','Recording') as $filter) {
|
|
if ($request_filters[$filter]) {
|
|
$filter_values = is_array($request_filters[$filter]) ? $request_filters[$filter] : array($request_filters[$filter]);
|
|
if (count($filter_values)) {
|
|
$conditions[] = 'M.'.$filter.' IN (' . implode(',', array_fill(0, count($filter_values), '?')) . ')';
|
|
$values = array_merge($values, $filter_values);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Carries the Deleted restriction as well as the status match, so that
|
|
// selecting the 'Deleted' pseudo status is what surfaces deleted monitors.
|
|
$conditions[] = monitorStatusFilterSql($request_filters['Status'], $values,
|
|
'COALESCE(S.Status, IF(M.Type="WebSite","Running","NotRunning"))');
|
|
|
|
// Build SQL query
|
|
$sql = 'SELECT M.*, S.*, E.*, (SELECT Name FROM Manufacturers WHERE Manufacturers.Id=M.ManufacturerId) AS Manufacturer, (SELECT Name FROM Models where Models.Id=M.ModelId) AS Model
|
|
FROM Monitors AS M
|
|
LEFT JOIN Monitor_Status AS S ON S.MonitorId=M.Id
|
|
LEFT JOIN Event_Summaries AS E ON E.MonitorId=M.Id
|
|
WHERE ' . implode(' AND ', $conditions);
|
|
|
|
// Get total count before filtering
|
|
$monitors = dbFetchAll($sql, null, $values);
|
|
$unfiltered_monitors = array();
|
|
foreach ($monitors as $monitor) {
|
|
if (visibleMonitor($monitor['Id'])) {
|
|
$unfiltered_monitors[] = $monitor;
|
|
}
|
|
}
|
|
$data['totalNotFiltered'] = count($unfiltered_monitors);
|
|
|
|
// Apply search filter
|
|
$filtered_monitors = $unfiltered_monitors;
|
|
if ($search != '') {
|
|
$search_lower = strtolower($search);
|
|
$filtered_monitors = array_filter($unfiltered_monitors, function($monitor) use ($search_lower) {
|
|
// Search across common fields without creating Monitor object
|
|
return (
|
|
stripos($monitor['Name'], $search_lower) !== false ||
|
|
stripos($monitor['Function'], $search_lower) !== false ||
|
|
stripos($monitor['Path'], $search_lower) !== false ||
|
|
stripos($monitor['Device'], $search_lower) !== false ||
|
|
stripos($monitor['Host'], $search_lower) !== false ||
|
|
stripos($monitor['Id'], $search_lower) !== false ||
|
|
(isset($monitor['Status']) && stripos($monitor['Status'], $search_lower) !== false)
|
|
);
|
|
});
|
|
}
|
|
|
|
// Apply MonitorName and Source request filters
|
|
if ($request_filters['MonitorName']) {
|
|
$regexp = $request_filters['MonitorName'];
|
|
if (!strpos($regexp, '/')) $regexp = '/'.$regexp.'/i';
|
|
$filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($regexp) {
|
|
return @preg_match($regexp, $monitor['Name']);
|
|
});
|
|
}
|
|
|
|
if ($request_filters['Source']) {
|
|
$regexp = $request_filters['Source'];
|
|
if (!preg_match("/^\/.+\/[a-z]*$/i", $regexp))
|
|
$regexp = '/'.$regexp.'/i';
|
|
$filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($regexp) {
|
|
// Match against Path field directly instead of creating Monitor object
|
|
return (preg_match($regexp, $monitor['Path']) || preg_match($regexp, $monitor['Device']) || preg_match($regexp, $monitor['Host']));
|
|
});
|
|
}
|
|
|
|
// 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(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);
|
|
});
|
|
}
|
|
}
|
|
|
|
$data['total'] = count($filtered_monitors);
|
|
|
|
// Sort monitors
|
|
usort($filtered_monitors, function($a, $b) use ($sort, $order) {
|
|
$aVal = isset($a[$sort]) ? $a[$sort] : '';
|
|
$bVal = isset($b[$sort]) ? $b[$sort] : '';
|
|
|
|
if (is_numeric($aVal) && is_numeric($bVal)) {
|
|
$result = $aVal - $bVal;
|
|
} else {
|
|
$result = strcasecmp($aVal, $bVal);
|
|
}
|
|
|
|
return $order == 'ASC' ? $result : -$result;
|
|
});
|
|
|
|
// Apply pagination
|
|
if ($limit > 0) {
|
|
$filtered_monitors = array_slice($filtered_monitors, $offset, $limit);
|
|
} else {
|
|
$filtered_monitors = array_slice($filtered_monitors, $offset);
|
|
}
|
|
|
|
// Get storage areas and servers
|
|
$storage_areas = ZM\Storage::find();
|
|
$StorageById = array();
|
|
foreach ($storage_areas as $S) {
|
|
$StorageById[$S->Id()] = $S;
|
|
}
|
|
|
|
$ServersById = array();
|
|
foreach ($Servers as $s) {
|
|
$ServersById[$s->Id()] = $s;
|
|
}
|
|
|
|
// Get group IDs for each monitor
|
|
$monitor_ids = array_map(function($m) { return $m['Id']; }, $filtered_monitors);
|
|
$group_ids_by_monitor_id = array();
|
|
if (count($monitor_ids)) {
|
|
foreach (ZM\Group_Monitor::find(array('MonitorId'=>$monitor_ids)) as $GM) {
|
|
if (!isset($group_ids_by_monitor_id[$GM->MonitorId()]))
|
|
$group_ids_by_monitor_id[$GM->MonitorId()] = array();
|
|
$group_ids_by_monitor_id[$GM->MonitorId()][] = $GM->GroupId();
|
|
}
|
|
}
|
|
|
|
// Process each monitor and build row data
|
|
$footer_totals = array(
|
|
'monitor_count' => count($filtered_monitors),
|
|
'total_bandwidth' => 0,
|
|
'total_fps' => 0,
|
|
'total_analysis_fps' => 0,
|
|
'total_zones' => 0,
|
|
'event_totals' => array(
|
|
'Total' => array('events' => 0, 'diskspace' => 0),
|
|
'Hour' => array('events' => 0, 'diskspace' => 0),
|
|
'Day' => array('events' => 0, 'diskspace' => 0),
|
|
'Week' => array('events' => 0, 'diskspace' => 0),
|
|
'Month' => array('events' => 0, 'diskspace' => 0),
|
|
'Archived' => array('events' => 0, 'diskspace' => 0)
|
|
)
|
|
);
|
|
|
|
foreach ($filtered_monitors as $monitor) {
|
|
$Monitor = new ZM\Monitor($monitor);
|
|
$Monitor->GroupIds(isset($group_ids_by_monitor_id[$Monitor->Id()]) ? $group_ids_by_monitor_id[$Monitor->Id()] : array());
|
|
|
|
// Accumulate footer totals
|
|
$footer_totals['total_bandwidth'] += isset($monitor['CaptureBandwidth']) ? $monitor['CaptureBandwidth'] : 0;
|
|
$footer_totals['total_fps'] += isset($monitor['CaptureFPS']) ? floatval($monitor['CaptureFPS']) : 0;
|
|
$footer_totals['total_analysis_fps'] += isset($monitor['AnalysisFPS']) ? floatval($monitor['AnalysisFPS']) : 0;
|
|
$footer_totals['total_zones'] += isset($monitor['ZoneCount']) ? intval($monitor['ZoneCount']) : 0;
|
|
|
|
foreach (array('Total', 'Hour', 'Day', 'Week', 'Month', 'Archived') as $period) {
|
|
$footer_totals['event_totals'][$period]['events'] += isset($monitor[$period.'Events']) ? intval($monitor[$period.'Events']) : 0;
|
|
$footer_totals['event_totals'][$period]['diskspace'] += isset($monitor[$period.'EventDiskSpace']) ? intval($monitor[$period.'EventDiskSpace']) : 0;
|
|
}
|
|
|
|
$row = array();
|
|
$row['Id'] = $monitor['Id'];
|
|
$row['Name'] = validHtmlStr($monitor['Name']);
|
|
$row['Function'] = $monitor['Function'];
|
|
$row['Enabled'] = $monitor['Enabled'];
|
|
$row['Manufacturer'] = $monitor['Manufacturer'];
|
|
$row['Model'] = $monitor['Model'];
|
|
$row['Sequence'] = isset($monitor['Sequence']) ? $monitor['Sequence'] : 0;
|
|
|
|
// Status. A deleted monitor has no daemons, so whatever Monitor_Status row
|
|
// it left behind is stale - report it as Deleted instead of a status the
|
|
// row cannot actually be in.
|
|
$row['Deleted'] = $monitor['Deleted'] ? 1 : 0;
|
|
if ($monitor['Deleted']) {
|
|
$monitor['Status'] = 'Deleted';
|
|
} else if (!$monitor['Status']) {
|
|
if ($monitor['Type'] == 'WebSite')
|
|
$monitor['Status'] = 'Running';
|
|
else
|
|
$monitor['Status'] = 'NotRunning';
|
|
}
|
|
$row['Status'] = $monitor['Status'];
|
|
|
|
// Server
|
|
if (count($Servers)) {
|
|
$Server = isset($ServersById[$monitor['ServerId']]) ? $ServersById[$monitor['ServerId']] : new ZM\Server($monitor['ServerId']);
|
|
$row['Server'] = validHtmlStr($Server->Name());
|
|
$row['ServerId'] = $monitor['ServerId'];
|
|
}
|
|
|
|
// Source
|
|
$row['Source'] = validHtmlStr($Monitor->Source());
|
|
$row['Width'] = $Monitor->Width();
|
|
$row['Height'] = $Monitor->Height();
|
|
|
|
// Storage
|
|
if (isset($StorageById[$monitor['StorageId']])) {
|
|
$row['Storage'] = validHtmlStr($StorageById[$monitor['StorageId']]->Name());
|
|
} else if ($monitor['StorageId']) {
|
|
$row['Storage'] = '<span class="error">Deleted '.$monitor['StorageId'].'</span>';
|
|
} else {
|
|
$row['Storage'] = '';
|
|
}
|
|
|
|
// Event counts with filter querystrings
|
|
$eventCounts = array(
|
|
'Total' => array(
|
|
'filter' => array('Query' => array('terms' => array()))
|
|
),
|
|
'Hour' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'StartDateTime', 'op' => '>=', 'val' => '-1 hour')
|
|
)))
|
|
),
|
|
'Day' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'StartDateTime', 'op' => '>=', 'val' => '-1 day')
|
|
)))
|
|
),
|
|
'Week' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'StartDateTime', 'op' => '>=', 'val' => '-7 day')
|
|
)))
|
|
),
|
|
'Month' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'StartDateTime', 'op' => '>=', 'val' => '-1 month')
|
|
)))
|
|
),
|
|
'Archived' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'Archived', 'op' => '=', 'val' => '1')
|
|
)))
|
|
)
|
|
);
|
|
|
|
foreach ($eventCounts as $period => $eventCount) {
|
|
$row[$period.'Events'] = (int)$monitor[$period.'Events'];
|
|
$row[$period.'EventDiskSpace'] = human_filesize($monitor[$period.'EventDiskSpace']);
|
|
|
|
// Generate filter querystring for this period and monitor
|
|
$filter = addFilterTerm(
|
|
$eventCount['filter'],
|
|
count($eventCount['filter']['Query']['terms']),
|
|
array('cnj' => 'and', 'attr' => 'Monitor', 'op' => '=', 'val' => $monitor['Id'])
|
|
);
|
|
parseFilter($filter);
|
|
$row[$period.'FilterQuery'] = $filter['querystring'];
|
|
}
|
|
|
|
// Zone count
|
|
$row['ZoneCount'] = $monitor['ZoneCount'];
|
|
|
|
// FPS and bandwidth
|
|
$row['CaptureFPS'] = isset($monitor['CaptureFPS']) ? $monitor['CaptureFPS'] : '0.00';
|
|
$row['AnalysisFPS'] = isset($monitor['AnalysisFPS']) ? $monitor['AnalysisFPS'] : '0.00';
|
|
|
|
// Format bandwidth with units (bytes per second) - use human_filesize for consistency
|
|
$bandwidth = isset($monitor['CaptureBandwidth']) ? $monitor['CaptureBandwidth'] : 0;
|
|
if ($bandwidth > 0) {
|
|
$row['CaptureBandwidth'] = human_filesize($bandwidth).'/s';
|
|
} else {
|
|
$row['CaptureBandwidth'] = '';
|
|
}
|
|
$row['Analysing'] = isset($monitor['Analysing']) ? $monitor['Analysing'] : 'None';
|
|
$row['Recording'] = isset($monitor['Recording']) ? $monitor['Recording'] : 'None';
|
|
// 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';
|
|
|
|
// Groups
|
|
if (canView('Groups')) {
|
|
$groups_html = implode('<br/>',
|
|
array_map(function($group_id) {
|
|
$Group = ZM\Group::find_one(array('Id'=>$group_id));
|
|
if ($Group) {
|
|
$Groups = $Group->Parents();
|
|
array_push($Groups, $Group);
|
|
} else {
|
|
$Groups = array();
|
|
}
|
|
return implode(' > ', array_map(function($Group) {
|
|
if (canView('Stream')) {
|
|
return '<a href="?view=montagereview&GroupId='.$Group->Id().'">'.validHtmlStr($Group->Name()).'</a>';
|
|
} else {
|
|
return validHtmlStr($Group->Name());
|
|
}
|
|
}, $Groups));
|
|
}, $Monitor->GroupIds())
|
|
);
|
|
$row['Groups'] = $groups_html;
|
|
} else {
|
|
$row['Groups'] = '';
|
|
}
|
|
|
|
// Thumbnail
|
|
$row['Thumbnail'] = '';
|
|
if (ZM_WEB_LIST_THUMBS && ($monitor['Capturing'] != 'None') && canView('Stream')) {
|
|
$ratio_factor = $Monitor->ViewWidth() ? $Monitor->ViewHeight() / $Monitor->ViewWidth() : 1;
|
|
$options = array(
|
|
'width' => ZM_WEB_LIST_THUMB_WIDTH,
|
|
'height' => ZM_WEB_LIST_THUMB_HEIGHT ? ZM_WEB_LIST_THUMB_HEIGHT : ZM_WEB_LIST_THUMB_WIDTH * $ratio_factor,
|
|
'scale' => $Monitor->ViewWidth() ? intval(100 * ZM_WEB_LIST_THUMB_WIDTH / $Monitor->ViewWidth()) : 100,
|
|
'mode' => 'single',
|
|
);
|
|
|
|
$stillSrc = $Monitor->getStreamSrc($options);
|
|
// Calculate optimal scale for the popup stream based on browser width
|
|
// The JS overlay uses 60% of window.innerWidth (see calculateOverlayDimensions in skin.js)
|
|
$browser_width = 1920; // Default fallback
|
|
if (isset($_COOKIE['zmBrowserSizes'])) {
|
|
$zmBrowserSizes = jsonDecode($_COOKIE['zmBrowserSizes']);
|
|
if (!empty($zmBrowserSizes['innerWidth'])) {
|
|
$browser_width = validInt($zmBrowserSizes['innerWidth']);
|
|
}
|
|
}
|
|
$target_width = $browser_width * 0.6; // Match JS overlay sizing (60% of viewport)
|
|
$options['scale'] = $Monitor->ViewWidth() ? intval(100 * $target_width / $Monitor->ViewWidth()) : 100;
|
|
if ($options['scale'] > 100) $options['scale'] = 100;
|
|
else if ($options['scale'] < 10) $options['scale'] = 10;
|
|
$options['mode'] = 'jpeg';
|
|
$streamSrc = $Monitor->getStreamSrc($options);
|
|
|
|
$videoAttr = '';
|
|
$go2rtcAttr = '';
|
|
$liveStreamAttr = '';
|
|
|
|
// Debug info for troubleshooting
|
|
$debugInfo = 'Analysing='.$monitor['Analysing'].',Decoding='.$monitor['Decoding'].
|
|
',Go2RTC='.$Monitor->Go2RTCEnabled().
|
|
',ZM_GO2RTC_PATH='.(defined('ZM_GO2RTC_PATH') ? ZM_GO2RTC_PATH : 'undefined');
|
|
$debugAttr = ' data-debug="'.htmlspecialchars($debugInfo).'"';
|
|
|
|
// STILL THUMBNAIL: When not analysing/decoding, use the most recent event thumbnail
|
|
// (ZM's nph-zms can't produce frames when not decoding)
|
|
if ($monitor['Analysing'] == 'None' && $monitor['Decoding'] == 'None') {
|
|
$debugAttr .= ' data-debug-still="event"';
|
|
$event = ZM\Event::find_one(
|
|
array(
|
|
'MonitorId' => $monitor['Id'],
|
|
'EndDateTime' => 'NOT NULL'
|
|
),
|
|
array('order' => 'Id DESC')
|
|
);
|
|
if ($event) {
|
|
$stillSrc = $event->getThumbnailSrc(array(), '&');
|
|
// Default stream/video sources from event (may be overridden by live streaming below)
|
|
// Use browser width for optimal scale (same as live stream calculation above)
|
|
$event_scale = $event->Width() ? intval(100 * $target_width / $event->Width()) : 100;
|
|
if ($event_scale > 100) $event_scale = 100;
|
|
else if ($event_scale < 10) $event_scale = 10;
|
|
$streamSrc = $event->getStreamSrc(array(
|
|
'mode' => 'jpeg', 'scale' => $event_scale, 'maxfps' => ZM_WEB_VIDEO_MAXFPS,
|
|
'replay' => 'single', 'rate' => '400'), '&');
|
|
if ($event->DefaultVideo()) {
|
|
$videoSrc = $event->getStreamSrc(array('mode' => 'mp4'), '&');
|
|
$videoAttr = ' video_src="'.$videoSrc.'" data-event-start="'.htmlspecialchars($event->StartDateTime()).'"';
|
|
}
|
|
}
|
|
} else {
|
|
$debugAttr .= ' data-debug-still="monitor"';
|
|
}
|
|
|
|
// HOVER OVERLAY: Check for external streaming methods (these work independently of ZM's decode/analyze)
|
|
// Priority: go2rtc > rtsp2web > janus > event replay > ZM native stream
|
|
if ($Monitor->Go2RTCEnabled() && defined('ZM_GO2RTC_PATH') && ZM_GO2RTC_PATH) {
|
|
$liveStreamAttr = ' data-stream-type="go2rtc"'.
|
|
' data-go2rtc-src="'.htmlspecialchars(ZM_GO2RTC_PATH).'"'.
|
|
' data-stream-channel="'.validHtmlStr($Monitor->StreamChannel() ?: 'Restream').'"'.
|
|
' data-monitor-id="'.$monitor['Id'].'"';
|
|
$go2rtcAttr = ' go2rtc_src="'.htmlspecialchars(ZM_GO2RTC_PATH).'" go2rtc_mid="'.$monitor['Id'].'"';
|
|
$debugAttr .= ' data-debug-overlay="go2rtc"';
|
|
} else if ($Monitor->RTSP2WebEnabled() && defined('ZM_RTSP2WEB_PATH') && ZM_RTSP2WEB_PATH) {
|
|
$liveStreamAttr = ' data-stream-type="rtsp2web"'.
|
|
' data-rtsp2web-src="'.htmlspecialchars(ZM_RTSP2WEB_PATH).'"'.
|
|
' data-stream-channel="'.validHtmlStr($Monitor->StreamChannel() ?: 'Restream').'"'.
|
|
' data-monitor-id="'.$monitor['Id'].'"';
|
|
$debugAttr .= ' data-debug-overlay="rtsp2web"';
|
|
} else if ($Monitor->JanusEnabled()) {
|
|
$janusPath = defined('ZM_JANUS_PATH') && ZM_JANUS_PATH ? ZM_JANUS_PATH : '';
|
|
$liveStreamAttr = ' data-stream-type="janus"'.
|
|
' data-janus-src="'.htmlspecialchars($janusPath).'"'.
|
|
' data-monitor-id="'.$monitor['Id'].'"';
|
|
$debugAttr .= ' data-debug-overlay="janus"';
|
|
} else if ($monitor['Analysing'] == 'None' && $monitor['Decoding'] == 'None') {
|
|
// No external streaming and ZM not running - overlay will use event replay (already set above)
|
|
$debugAttr .= ' data-debug-overlay="event-replay"';
|
|
} else {
|
|
// Use native ZM streaming (zms) - Analysing or Decoding is running
|
|
$debugAttr .= ' data-debug-overlay="zm-native"';
|
|
}
|
|
|
|
$thmbWidth = ($options['width']) ? 'width:'.$options['width'].'px;' : '';
|
|
$thmbHeight = ($options['height']) ? 'height:'.$options['height'].'px;' : '';
|
|
|
|
$row['Thumbnail'] = '<div class="colThumbnail" style="'.$thmbHeight.'"><a href="?view=watch&mid='.$monitor['Id'].'">'.
|
|
'<img id="thumbnail'.$Monitor->Id().'" src="'.$stillSrc.'" style="'.$thmbWidth.$thmbHeight.
|
|
'" stream_src="'.$streamSrc.'" still_src="'.$stillSrc.'"'.$videoAttr.$go2rtcAttr.$liveStreamAttr.$debugAttr.
|
|
' data-monitor-width="'.$Monitor->ViewWidth().'" data-monitor-height="'.$Monitor->ViewHeight().'"'.
|
|
($options['width'] ? ' width="'.$options['width'].'"' : '').
|
|
($options['height'] ? ' height="'.$options['height'].'"' : '').
|
|
' loading="lazy" /></a></div>';
|
|
}
|
|
|
|
$data['rows'][] = $row;
|
|
}
|
|
|
|
// Add footer totals to response
|
|
$data['footer'] = array(
|
|
'monitor_count' => $footer_totals['monitor_count'],
|
|
'bandwidth_fps' => human_filesize($footer_totals['total_bandwidth']).'/s '.
|
|
round($footer_totals['total_fps'], 2).' fps / '.
|
|
round($footer_totals['total_analysis_fps'], 2).' fps',
|
|
'total_zones' => $footer_totals['total_zones']
|
|
);
|
|
|
|
// Build filter querystrings for footer event totals (matching header behavior)
|
|
$eventCountsFooter = array(
|
|
'Total' => array(
|
|
'filter' => array('Query' => array('terms' => array()))
|
|
),
|
|
'Hour' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'StartDateTime', 'op' => '>=', 'val' => '-1 hour')
|
|
)))
|
|
),
|
|
'Day' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'StartDateTime', 'op' => '>=', 'val' => '-1 day')
|
|
)))
|
|
),
|
|
'Week' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'StartDateTime', 'op' => '>=', 'val' => '-7 day')
|
|
)))
|
|
),
|
|
'Month' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'StartDateTime', 'op' => '>=', 'val' => '-1 month')
|
|
)))
|
|
),
|
|
'Archived' => array(
|
|
'filter' => array('Query' => array('terms' => array(
|
|
array('cnj' => 'and', 'attr' => 'Archived', 'op' => '=', 'val' => '1')
|
|
)))
|
|
)
|
|
);
|
|
|
|
// Add formatted event totals to footer with filter querystrings
|
|
foreach (array('Total', 'Hour', 'Day', 'Week', 'Month', 'Archived') as $period) {
|
|
$data['footer'][$period.'Events'] = $footer_totals['event_totals'][$period]['events'];
|
|
$data['footer'][$period.'EventDiskSpace'] = human_filesize($footer_totals['event_totals'][$period]['diskspace']);
|
|
|
|
// Generate filter querystring for footer (include monitor filter if monitors are filtered)
|
|
$filter = $eventCountsFooter[$period]['filter'];
|
|
if (count($monitor_ids) > 0) {
|
|
$filter = addFilterTerm(
|
|
$filter,
|
|
count($filter['Query']['terms']),
|
|
array('cnj' => 'and', 'attr' => 'Monitor', 'op' => 'IN', 'val' => implode(',', $monitor_ids))
|
|
);
|
|
}
|
|
parseFilter($filter);
|
|
$data['footer'][$period.'FilterQuery'] = $filter['querystring'];
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
function exportMonitorsJSON() {
|
|
require_once('includes/Monitor.php');
|
|
require_once('includes/Zone.php');
|
|
require_once('includes/Group.php');
|
|
require_once('includes/Group_Monitor.php');
|
|
|
|
if (!canView('Monitors')) {
|
|
ajaxError('Insufficient permissions');
|
|
return;
|
|
}
|
|
|
|
// Fields to exclude from export (runtime/instance-specific, not useful for import)
|
|
$exclude_fields = array('Id', 'Deleted', 'ServerId', 'StorageId', 'Sequence', 'ZoneCount');
|
|
$zone_exclude_fields = array('Id', 'MonitorId');
|
|
|
|
// Get all non-deleted visible monitors
|
|
$sql = 'SELECT M.* FROM Monitors AS M WHERE M.Deleted=false ORDER BY M.Sequence ASC';
|
|
$db_monitors = dbFetchAll($sql);
|
|
|
|
// Build group name lookup: MonitorId => array of group names
|
|
$group_names_by_monitor = array();
|
|
$all_groups = ZM\Group::find();
|
|
$groups_by_id = array();
|
|
foreach ($all_groups as $G) {
|
|
$groups_by_id[$G->Id()] = $G;
|
|
}
|
|
foreach (ZM\Group_Monitor::find() as $GM) {
|
|
$mid = $GM->MonitorId();
|
|
$gid = $GM->GroupId();
|
|
if (isset($groups_by_id[$gid])) {
|
|
if (!isset($group_names_by_monitor[$mid])) {
|
|
$group_names_by_monitor[$mid] = array();
|
|
}
|
|
$group_names_by_monitor[$mid][] = $groups_by_id[$gid]->Name();
|
|
}
|
|
}
|
|
|
|
$export = array(
|
|
'version' => ZM_VERSION,
|
|
'exported' => date('c'),
|
|
'monitors' => array()
|
|
);
|
|
|
|
foreach ($db_monitors as $db_row) {
|
|
if (!visibleMonitor($db_row['Id'])) continue;
|
|
|
|
$Monitor = new ZM\Monitor($db_row);
|
|
|
|
// Serialize all monitor fields via to_json, then filter
|
|
$monitor_data = json_decode($Monitor->to_json(), true);
|
|
foreach ($exclude_fields as $field) {
|
|
unset($monitor_data[$field]);
|
|
}
|
|
|
|
// Add group names
|
|
$monitor_data['Groups'] = isset($group_names_by_monitor[$db_row['Id']])
|
|
? $group_names_by_monitor[$db_row['Id']]
|
|
: array();
|
|
|
|
// Add zones
|
|
$zones = ZM\Zone::find(array('MonitorId' => $db_row['Id']));
|
|
$monitor_data['Zones'] = array();
|
|
foreach ($zones as $zone) {
|
|
$zone_data = json_decode($zone->to_json(), true);
|
|
foreach ($zone_exclude_fields as $field) {
|
|
unset($zone_data[$field]);
|
|
}
|
|
$monitor_data['Zones'][] = $zone_data;
|
|
}
|
|
|
|
$export['monitors'][] = $monitor_data;
|
|
}
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Content-Disposition: attachment; filename="zm_monitors_export.json"');
|
|
echo json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
exit();
|
|
}
|
|
?>
|