Files
zoneminder/web/ajax/status.php
T
Isaac Connor db9ba436b7 fix: break sort-value ties by Id when finding prev/next events
getNearEvents compared only the sort column with >= / <= and excluded the
current event, using Id solely as a secondary ORDER BY. When several events
share a sort value - most often StartDateTime, which has one-second
resolution, with multiple monitors recording in step - the later event of
a tie chose the earlier one as its Next. Event playback, gapless or not,
then looped between the two and the Next button could not escape.

Compare the full (sort value, Id) tuple so ordering is strict in both
directions. Read the current event's sort value through the same
Events/Monitors join as the searches, which also makes Prev/Next work
when sorting by MonitorName (the value was previously looked up in the
Events row and was always NULL).
2026-09-28 07:55:57 -04:00

618 lines
22 KiB
PHP

<?php
if (!isset($_REQUEST['entity'])) {
Error("No entity pass to status request.");
http_response_code(404);
return;
} else {
}
if ($_REQUEST['entity'] == 'navBar') {
global $bandwidth_options, $user;
$data = array();
if ( ZM_OPT_USE_AUTH && (ZM_AUTH_RELAY == 'hashed') ) {
$auth_hash = generateAuthHash(ZM_AUTH_HASH_IPS);
$data['auth'] = $auth_hash;
$data['auth_relay'] = get_auth_relay();
}
// Each widget on the navbar has its own function
// Call the functions we want to dynamically update
$data['getBandwidthHTML'] = getBandwidthHTML($bandwidth_options, $user);
$data['getSysLoadHTML'] = getSysLoadHTML();
$data['getCpuUsageHTML'] = getCpuUsageHTML();
$data['getDbConHTML'] = getDbConHTML();
$data['getStorageHTML'] = getStorageHTML();
//$data['getShmHTML'] = getShmHTML();
$data['getRamHTML'] = getRamHTML();
$data['getLogStatusHTML'] = logState();
ajaxResponse($data);
return;
}
$statusData = array(
'system' => array(
'permission' => 'System',
'table' => 'Monitors',
'limit' => 1,
'elements' => array(
'MonitorCount' => array( 'sql' => 'count(*)' ),
'ActiveMonitorCount' => array( 'sql' => 'count(if(`Capturing` != \'None\',1,NULL))' ),
'State' => array( 'func' => function() { return daemonCheck() ? translate('Running') : translate('Stopped'); } ),
'Load' => array( 'func' => function() { return getLoad(); } ),
'Disk' => array( 'func' => function() { return getDiskPercent(); } ),
),
),
'monitor' => array(
#'permission' => 'Monitors',
'object' => 'Monitor',
'table' => 'Monitors',
'limit' => 1,
'selector' => 'Monitors.Id',
'elements' => array(
'Id' => array( 'sql' => 'Monitors.Id' ),
'Name' => array( 'sql' => 'Monitors.Name' ),
'Type' => true,
'Capturing' => true,
'Analysing' => true,
'Recording' => true,
'Enabled' => true,
'LinkedMonitors' => true,
'Triggers' => true,
'Device' => true,
'Channel' => true,
'Format' => true,
'Host' => true,
'Port' => true,
'Path' => true,
'Width' => array( 'sql' => 'Monitors.Width' ),
'Height' => array( 'sql' => 'Monitors.Height' ),
'Palette' => true,
'Orientation' => true,
'Brightness' => true,
'Contrast' => true,
'Hue' => true,
'Colour' => true,
'EventPrefix' => true,
'LabelFormat' => true,
'LabelX' => true,
'LabelY' => true,
'LabelSize' => true,
'ImageBufferCount' => true,
'WarmupCount' => true,
'PreEventCount' => true,
'PostEventCount' => true,
'AlarmFrameCount' => true,
'SectionLength' => true,
'MotionFrameSkip' => true,
'MaxFPS' => true,
'AlarmMaxFPS' => true,
'FPSReportInterval' => true,
'RefBlendPerc' => true,
'Controllable' => true,
'ControlId' => true,
'ControlDevice' => true,
'ControlAddress' => true,
'AutoStopTimeout' => true,
'TrackMotion' => true,
'TrackDelay' => true,
'ReturnLocation' => true,
'ReturnDelay' => true,
'DefaultView' => true,
'DefaultRate' => true,
'DefaultScale' => true,
'WebColour' => true,
'Sequence' => true,
'MinEventId' => array( 'sql' => '(SELECT min(Events.Id) FROM Events WHERE Events.MonitorId = Monitors.Id)' ),
'MaxEventId' => array( 'sql' => '(SELECT max(Events.Id) FROM Events WHERE Events.MonitorId = Monitors.Id)' ),
'TotalEvents' => array( 'sql' => '(SELECT count(Events.Id) FROM Events WHERE Events.MonitorId = Monitors.Id)' ),
'Status' => (isset($_REQUEST['id'])?array( 'zmu' => '-m '.escapeshellarg($_REQUEST['id']).' -s' ):null),
'FrameRate' => (isset($_REQUEST['id'])?array( 'zmu' => '-m '.escapeshellarg($_REQUEST['id']).' -f' ):null),
'CaptureFPS' => [ 'sql'=>'(SELECT `CaptureFPS` FROM Monitor_Status WHERE MonitorId=Monitors.Id)' ],
'AnalysisFPS' => [ 'sql'=>'(SELECT `AnalysisFPS` FROM Monitor_Status WHERE MonitorId=Monitors.Id)' ],
'CaptureBandwidth' => [ 'sql'=>'(SELECT `CaptureBandwidth` FROM Monitor_Status WHERE MonitorId=Monitors.Id)' ],
),
),
'events' => array(
'permission' => 'Events',
'table' => 'Events',
'selector' => 'Events.MonitorId',
'elements' => array(
'Id' => true,
'MonitorId' => true,
'Name' => true,
'Cause' => true,
'Notes' => true,
'StartDateTime' => true,
'StartDateTimeFormatted' => array('postFunction'=>function($row){
global $dateTimeFormatter;
return $dateTimeFormatter->format(strtotime($row['StartDateTime']));
}),
# Left for backwards compatibility. Remove in 1.37
'EndDateTime' => true,
'EndDateTimeFormatted' => array('postFunction'=>function($row){
global $dateTimeFormatter;
return $dateTimeFormatter->format(strtotime($row['EndDateTime']));
}),
'Width' => true,
'Height' => true,
'Length' => true,
'Frames' => true,
'AlarmFrames' => true,
'TotScore' => true,
'AvgScore' => true,
'MaxScore' => true,
),
),
'event' => array(
'permission' => 'Events',
'table' => 'Events',
'limit' => 1,
'selector' => 'Events.Id',
'elements' => array(
'Id' => array( 'sql' => 'Events.Id' ),
'MonitorId' => true,
'MonitorName' => array('sql' => '(SELECT Monitors.Name FROM Monitors WHERE Monitors.Id = Events.MonitorId)'),
'Name' => true,
'Cause' => true,
'DiskSpace' => true,
'Storage' => array('sql' => '(SELECT Storage.Name FROM Storage WHERE Storage.Id=Events.StorageId)'),
'StartDateTime' => true,
'StartDateTimeFormatted' => array('postFunction'=>function($row){
global $dateTimeFormatter;
return $dateTimeFormatter->format(strtotime($row['StartDateTime']));
}),
# Left for backwards compatibility. Remove in 1.37
'EndDateTime' => true,
'EndDateTimeFormatted' => array('postFunction'=>function($row){
global $dateTimeFormatter;
return $dateTimeFormatter->format(strtotime($row['EndDateTime']));
}),
'Width' => true,
'Height' => true,
'Length' => true,
'Frames' => true,
'DefaultVideo' => true,
'AlarmFrames' => true,
'TotScore' => true,
'AvgScore' => true,
'MaxScore' => true,
'Archived' => true,
'Videoed' => true,
'Uploaded' => true,
'Emailed' => true,
'Messaged' => true,
'Executed' => true,
'Notes' => true,
'MinFrameId' => array( 'sql' => '(SELECT min(Frames.FrameId) FROM Frames WHERE EventId=Events.Id)' ),
'MaxFrameId' => array( 'sql' => '(SELECT max(Frames.FrameId) FROM Frames WHERE Events.Id = Frames.EventId)' ),
'MinFrameDelta' => array( 'sql' => '(SELECT min(Frames.Delta) FROM Frames WHERE Events.Id = Frames.EventId)' ),
'MaxFrameDelta' => array( 'sql' => '(SELECT max(Frames.Delta) FROM Frames WHERE Events.Id = Frames.EventId)' ),
),
),
'frames' => array(
'permission' => 'Events',
'table' => 'Frames',
'selector' => 'EventId',
'elements' => array(
'EventId' => true,
'FrameId' => true,
'Type' => true,
'Delta' => true,
// elements is a whitelist, so the event view's cue graph was reading an
// undefined Score off every frame until these were added.
'Score' => true,
'AudioLevel' => true,
),
),
'frame' => array(
'permission' => 'Events',
'table' => 'Frames',
'limit' => 1,
'selector' => array( array( 'table' => 'Events', 'join' => 'Events.Id = Frames.EventId', 'selector'=>'Events.Id' ), 'Frames.FrameId' ),
'elements' => array(
//'Id' => array( 'sql' => 'Frames.FrameId' ),
'FrameId' => true,
'EventId' => true,
'Type' => true,
'TimeStamp' => true,
'TimeStampShort' => array( 'sql' => 'date_format( StartDateTime, \''.MYSQL_FMT_DATETIME_SHORT.'\' )' ),
'Delta' => true,
'Score' => true,
//'Image' => array( 'postFunc' => 'getFrameImage' ),
),
),
'frameimage' => array(
'permission' => 'Events',
'func' => 'getFrameImage'
),
'nearframe' => array(
'permission' => 'Events',
'func' => 'getNearFrame'
),
'nearevents' => array(
'permission' => 'Events',
'func' => 'getNearEvents'
)
);
function collectData() {
global $statusData;
$entity = strtolower(validJsStr($_REQUEST['entity']));
$entitySpec = &$statusData[$entity];
#print_r( $entitySpec );
if (isset($entitySpec['permission'])) {
if (!canView($entitySpec['permission'])) {
ajaxError('Unrecognised action or insufficient permissions for '.$entity.' permission: '.$entitySpec['permission']);
return;
}
}
if ( !empty($entitySpec['func']) ) {
return call_user_func($entitySpec['func']);
}
$data = array();
$postFuncs = array();
$postFunctions = array();
$fieldSql = array();
$joinSql = array();
$groupSql = array();
$values = array();
$elements = &$entitySpec['elements'];
$lc_elements = array_change_key_case($elements);
$id = false;
if ( isset($_REQUEST['id']) )
if ( !is_array($_REQUEST['id']) )
$id = array( validJsStr($_REQUEST['id']) );
else
$id = array_values($_REQUEST['id']);
# Early per-object permission check before processing elements.
# This must happen before the element loop because func/zmu elements
# execute immediately and would bypass the post-SQL canView() check.
if (isset($entitySpec['object']) && $id) {
$object_name = 'ZM\\'.$entitySpec['object'];
$object = new $object_name($id[0]);
if (!$object->Id()) {
ajaxError('Not found: '.$entity.' id '.$id[0]);
return;
}
if (!$object->canView()) {
ajaxError('Insufficient permissions for '.$entity.' id '.$id[0]);
return;
}
}
if ( !isset($_REQUEST['element']) )
$_REQUEST['element'] = array_keys($elements);
else if ( !is_array($_REQUEST['element']) )
$_REQUEST['element'] = array( validJsStr($_REQUEST['element']) );
if ( isset($entitySpec['selector']) ) {
if ( !is_array($entitySpec['selector']) )
$entitySpec['selector'] = array( $entitySpec['selector'] );
foreach( $entitySpec['selector'] as $selector )
if ( is_array( $selector ) && isset($selector['table']) && isset($selector['join']) )
$joinSql[] = 'left join '.$selector['table'].' on '.$selector['join'];
}
foreach ( $_REQUEST['element'] as $element ) {
if ( !($elementData = $lc_elements[strtolower($element)]) ) {
ajaxError('Bad '.validJsStr($_REQUEST['entity']).' element '.$element);
continue;
}
if (isset($elementData['func'])) {
$data[$element] = call_user_func($elementData['func']);
} else if ( isset($elementData['postFunc']) ) {
$postFuncs[$element] = $elementData['postFunc'];
} else if ( isset($elementData['postFunction']) ) {
$postFunctions[$element] = $elementData['postFunction'];
} else if ( isset($elementData['zmu']) ) {
$command = escapeshellcmd(getZmuCommand(' '.$elementData['zmu']));
$data[$element] = exec($command);
} else {
if ( isset($elementData['sql']) )
$fieldSql[] = $elementData['sql'].' as '.$element;
else
$fieldSql[] = '`'.$element.'`';
if ( isset($elementData['table']) && isset($elementData['join']) ) {
$joinSql[] = 'left join '.$elementData['table'].' on '.$elementData['join'];
}
if ( isset($elementData['group']) ) {
$groupSql[] = $elementData['group'];
}
}
} # end foreach element
if (isset($entitySpec['object'])) {
$fieldSql[] = 'Id';
}
if ( count($fieldSql) ) {
$sql = 'SELECT '.join(', ', $fieldSql).' FROM '.$entitySpec['table'];
if ( $joinSql )
$sql .= ' '.join(' ', array_unique($joinSql));
if ( $id && !empty($entitySpec['selector']) ) {
$index = 0;
$where = array();
foreach ( $entitySpec['selector'] as $selIndex => $selector ) {
$selectorParamName = ':selector' . $selIndex;
if ( is_array($selector) ) {
$where[] = $selector['selector'].' = '.$selectorParamName;
$values[$selectorParamName] = validInt($id[$index]);
} else {
$where[] = $selector.' = '.$selectorParamName;
$values[$selectorParamName] = validInt($id[$index]);
}
$index++;
}
$sql .= ' WHERE '.join(' AND ', $where);
}
if ( $groupSql )
$sql .= ' GROUP BY '.join(',', array_unique($groupSql));
if ( !empty($_REQUEST['sort']) ) {
$sort_fields = explode(',', $_REQUEST['sort']);
$order_clauses = array();
foreach ( $sort_fields as $sort_field ) {
if (!preg_match('/^`?(\w+)`?\s*(ASC|DESC)?( NULLS FIRST)?$/i', $sort_field, $matches)) {
ZM\Error('Sort field didn\'t match regexp '.$sort_field);
continue;
}
// Check that the field name is one we are actually selecting.
// $fieldSql entries may be bare (`Name`), backtick-wrapped (`Name`),
// or aliased (expression as Name), so match against all forms.
$field = $matches[1];
$found = false;
foreach ($fieldSql as $f) {
if ($f === $field || $f === '`'.$field.'`' || preg_match('/\bas\s+`?'.$field.'`?$/i', $f)) {
$found = true;
break;
}
}
if (!$found) {
ZM\Error('Sort field '.$field.' from '.$sort_field.' not in SQL Fields: '.join(',', $fieldSql));
continue;
}
$clause = '`'.$field.'`';
if (!empty($matches[2])) {
$clause .= ' '.strtoupper($matches[2]);
}
if (!empty($matches[3])) {
$clause .= ' '.strtoupper(trim($matches[3]));
}
$order_clauses[] = $clause;
} # end foreach sort field
if ($order_clauses) {
$sql .= ' ORDER BY '.join(', ', $order_clauses);
}
} # end if has sort
if ( !empty($entitySpec['limit']) )
$limit = $entitySpec['limit'];
elseif ( !empty($_REQUEST['count']) )
$limit = validInt($_REQUEST['count']);
$limit_offset = '';
if ( !empty($_REQUEST['offset']) )
$limit_offset = validInt($_REQUEST['offset']) . ', ';
if ( !empty($limit) )
$sql .= ' limit '.$limit_offset.$limit;
if ( isset($limit) && ($limit == 1) ) {
if ( $sqlData = dbFetchOne($sql, NULL, $values) ) {
if (isset($entitySpec['object'])) {
ZM\Debug("Have object".$entitySpec['object']);
$object_name = 'ZM\\'.$entitySpec['object'];
$object = new $object_name($sqlData);
ZM\Debug("Canview:".$object->canView());
if (!$object->canView()) {
ajaxError('Insufficient permissions for '.$entity.' id '.$sqlData['Id']);
return;
}
}
foreach ( $postFuncs as $element=>$func )
$sqlData[$element] = call_user_func($func, $sqlData);
foreach ( $postFunctions as $element=>$function )
$sqlData[$element] = $function($sqlData);
$data = array_merge($data, $sqlData);
}
} else {
$count = 0;
foreach ( dbFetchAll($sql, NULL, $values) as $sqlData ) {
if (isset($entitySpec['object'])) {
ZM\Debug("Have object".$entitySpec['object']);
$object_name = 'ZM\\'.$entitySpec['object'];
$object = new $object_name($sqlData);
ZM\Debug("Canview:".$object->canView());
if (!$object->canView()) continue;
}
foreach ( $postFuncs as $element=>$func )
$sqlData[$element] = call_user_func($func, $sqlData);
foreach ( $postFunctions as $element=>$function )
$sqlData[$element] = $function($sqlData);
$data[] = $sqlData;
if ( isset($limit) && ++$count >= $limit )
break;
} # end foreach
} # end if have limit == 1
} else {
ZM\Debug("No fieldSQL");
}
//ZM\Debug(print_r($data, true));
return $data;
}
function formatDateTime($dt) {
return $dateTimeFormatter->format(strtotime($dt));
}
$data = collectData();
if ( !isset($_REQUEST['layout']) ) {
$_REQUEST['layout'] = 'json';
}
switch ( $_REQUEST['layout'] ) {
case 'xml NOT CURRENTLY SUPPORTED' :
header('Content-type: application/xml');
echo('<?xml version="1.0" encoding="iso-8859-1"?>
');
$entity = strtolower($_REQUEST['entity']);
$entity = preg_replace('/[^A-Za-z0-9]/', '', $entity);
echo '<'.$entity.'>
';
foreach ( $data as $key=>$value ) {
$key = strtolower($key);
echo "<$key>".htmlentities($value)."</$key>\n";
}
echo '</'.$entity.">\n";
break;
case 'json' :
{
$response = array( strtolower(validJsStr($_REQUEST['entity'])) => $data );
if ( ZM_OPT_USE_AUTH && (ZM_AUTH_RELAY == 'hashed') ) {
$auth_hash = generateAuthHash(ZM_AUTH_HASH_IPS);
$response['auth'] = $auth_hash;
$response['auth_relay'] = get_auth_relay();
}
if ( isset($_REQUEST['loopback']) )
$response['loopback'] = validJsStr($_REQUEST['loopback']);
#ZM\Warning(print_r($response, true));
ajaxResponse($response);
break;
}
case 'text' :
header('Content-type: text/plain');
echo join(' ', array_values($data));
break;
default:
ZM\Error('Unsupported layout: '.$_REQUEST['layout']);
}
function getFrameImage() {
$eventId = validCardinal($_REQUEST['eid']);
$frameId = validCardinal($_REQUEST['fid']);
$sql = 'SELECT * FROM Frames WHERE EventId = ? AND FrameId = ?';
if ( !($frame = dbFetchOne($sql, NULL, array($eventId, $frameId))) ) {
ZM\Debug("Frame not found for event $eventId frame $frameId");
$frame = array();
$frame['EventId'] = $eventId;
$frame['FrameId'] = $frameId;
$frame['Type'] = 'Virtual';
}
$event = new ZM\Event($frame['EventId']);
$frame['Image'] = $event->getImageSrc($frame, SCALE_BASE);
return $frame;
}
function getNearFrame() {
$eventId = validCardinal($_REQUEST['id'][0]);
$frameId = validCardinal($_REQUEST['id'][1]);
$sql = 'SELECT FrameId FROM Frames WHERE EventId = ? AND FrameId <= ? ORDER BY FrameId DESC LIMIT 1';
if ( !$nearFrameId = dbFetchOne($sql, 'FrameId', array($eventId, $frameId)) ) {
$sql = 'SELECT * FROM Frames WHERE EventId = ? AND FrameId > ? ORDER BY FrameId ASC LIMIT 1';
if ( !$nearFrameId = dbFetchOne($sql, 'FrameId', array($eventId, $frameId)) ) {
return( array() );
}
}
$_REQUEST['entity'] = 'frame';
$_REQUEST['id'][1] = $nearFrameId;
return collectData();
}
function getNearEvents() {
global $user, $sortColumn, $sortOrder;
$eventId = validCardinal($_REQUEST['id']);
$NearEvents = array('EventId'=>$eventId);
$event = dbFetchOne('SELECT * FROM Events WHERE Id=?', NULL, array($eventId));
if ( !$event ) return $NearEvents;
$filter = ZM\Filter::parse($_REQUEST['filter']);
parseSort();
if ( count($user->unviewableMonitorIds()) ) {
$filter = $filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=>'IN', 'val'=>$user->viewableMonitorIds()));
}
$filter_sql = $filter->sql();
# When listing, it may make sense to list them in descending order.
# But when viewing Prev should be timewise earlier and Next should be after.
if ( $sortColumn == 'E.Id' or $sortColumn == 'E.StartDateTime' ) {
$sortOrder = 'ASC';
}
# The sort value of the current event. Read through the same join as the searches so that
# sort columns from Monitors (M.Name) resolve too.
$sortValue = dbFetchOne('SELECT '.$sortColumn.' AS SortValue FROM Events AS E
INNER JOIN Monitors AS M ON E.MonitorId = M.Id WHERE E.Id=?', 'SortValue', array($eventId));
# Events are ordered by ($sortColumn $sortOrder, E.Id ASC). Many events can share a sort value,
# e.g. several monitors starting events in the same second, so Prev/Next must compare the full
# (sort value, Id) tuple. Comparing the sort value alone with >= / <= lets the event after a tie
# pick the one before it as its Next, and playback loops between them forever.
$after = ($sortOrder == 'ASC') ? '>' : '<';
$before = ($sortOrder == 'ASC') ? '<' : '>';
$reverseOrder = ($sortOrder == 'ASC') ? 'DESC' : 'ASC';
$nearEvent = function($cmp, $idCmp, $startCmp, $order, $idOrder) use ($eventId, $event, $filter, $sortColumn, $sortValue) {
$sql = '
SELECT E.Id AS Id, E.StartDateTime AS StartDateTime
FROM Events AS E
INNER JOIN Monitors AS M ON E.MonitorId = M.Id
LEFT JOIN Events_Tags AS ET ON E.Id = ET.EventId
LEFT JOIN Tags AS T ON T.Id = ET.TagId
WHERE ';
if ($sortColumn == 'E.Id') {
$sql .= 'E.Id '.$idCmp.' ?';
$values = [$eventId];
} else {
$sql .= '('.$sortColumn.' '.$cmp.' ? OR ('.$sortColumn.' = ? AND E.Id '.$idCmp.' ?))';
$values = [$sortValue, $sortValue, $eventId];
}
if ($filter->sql()) {
$sql .= ' AND ('.$filter->sql().')';
}
$sql .= ' AND E.StartDateTime '.$startCmp.' ?';
$values[] = $event['StartDateTime'];
$sql .= ' ORDER BY '.$sortColumn.' '.$order;
if ($sortColumn != 'E.Id') {
$sql .= ', E.Id '.$idOrder;
}
$sql .= ' LIMIT 1';
$result = dbQuery($sql, $values);
if (!$result) {
ZM\Error('Failed to load near event using '.$sql);
return false;
}
return dbFetchNext($result);
};
$prevEvent = $nearEvent($before, '<', '<=', $reverseOrder, 'DESC');
$nextEvent = $nearEvent($after, '>', '>=', $sortOrder, 'ASC');
if ( $prevEvent ) {
$NearEvents['PrevEventId'] = $prevEvent['Id'];
$NearEvents['PrevEventStartTime'] = $prevEvent['StartDateTime'];
$NearEvents['PrevEventDefVideoPath'] = getEventDefaultVideoPath($prevEvent['Id']);
} else {
$NearEvents['PrevEventId'] = $NearEvents['PrevEventStartTime'] = $NearEvents['PrevEventDefVideoPath'] = 0;
}
if ( $nextEvent ) {
$NearEvents['NextEventId'] = $nextEvent['Id'];
$NearEvents['NextEventStartTime'] = $nextEvent['StartDateTime'];
$NearEvents['NextEventDefVideoPath'] = getEventDefaultVideoPath($nextEvent['Id']);
} else {
$NearEvents['NextEventId'] = $NearEvents['NextEventStartTime'] = $NearEvents['NextEventDefVideoPath'] = 0;
}
return $NearEvents;
} # end function getNearEvents()
?>