Files
zoneminder/web/ajax/console.php
T
Isaac ConnorandClaude Opus 5 9c06eb6ba2 refactor: connect to the database on first use, not on include
database.php called dbConnect() at file scope, so including it opened a socket,
and on failure rendered views/no_database_connection.php and exit()ed from
inside a library include. Every model in web/includes requires this file, so
merely loading a class did both.

Connect on first use instead. $dbConn becomes tri-state - false for "not
attempted", null for "attempt failed", a PDO for connected - and two accessors
sit on top of it:

  zmDbConn()        opens if needed; on failure renders the error view and
                    stops, which is what the include used to do, just at the
                    point a query is actually attempted.
  zmDbConnOrNull()  opens if needed but returns null instead of ending the
                    request, for callers with a fallback.

dbQuery() is the funnel every fetch helper goes through, so routing it plus
dbEscape(), dbError() and dbInsertId() through the accessors covers the library.
The five callers that reached for the raw global are updated: config.php.in,
Event.php and ajax/console.php need a connection and take zmDbConn(); logger.php
takes zmDbConnOrNull() and falls through to its error_log target, so a logging
call can no longer end the request or open a connection by itself.

ZMSessionHandler captured $dbConn in its constructor. It is constructed while
session.php is being included, before anything has needed the database, so with
a lazy connection that captured false. It now resolves per call and its methods
return "no session" rather than dereferencing a bool.

Two smaller fixes fall out. The error view was included by a relative path that
only resolved when the cwd was web/, so it never worked for requests served out
of web/api/; it is now anchored with __DIR__. And dbDisconnect() set $dbConn to
null, which in the new tri-state means "connecting failed" and would send the
next query to the error page; it sets false so a later query can reconnect.
Nothing calls dbDisconnect() today.

This does NOT make database.php includable without a database. It requires
logger.php, which requires config.php, which reads ZoneMinder's configuration
out of the Config table at include time. Until that cycle is broken the
connection still happens during bootstrap, just from config.php rather than from
here.

Tests: tests/php/test_database_lazy_connect.php, 7 assertions, all pass. It
tokenises database.php and asserts nothing runs at include time, that dbQuery()
goes through the accessor, and that only the connection plumbing touches the
global. Verified it reports the pre-refactor file's `if ( !dbConnect() )` - an
earlier version of the check skipped tokens inside parentheses and so passed on
exactly the code it exists to reject.

Not covered by tests: behaviour when the database is genuinely unreachable, and
the session handler against a live database. Needs manual testing on an
installed tree, including stopping mysql to confirm the error view still renders
for both a web request and an API request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
2026-08-17 20:52:10 -04:00

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) ) ) {
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(' &gt; ', array_map(function($Group) {
if (canView('Stream')) {
return '<a href="?view=montagereview&amp;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(), '&amp;');
// 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'), '&amp;');
if ($event->DefaultVideo()) {
$videoSrc = $event->getStreamSrc(array('mode' => 'mp4'), '&amp;');
$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="'.($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="'.($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&amp;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();
}
?>