mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-03 16:05:24 -04:00
Merge branch 'master' of https://github.com/AJ0070/zoneminder into fix/4750
This commit is contained in:
commit
1ba3169de7
78 files changed
+1684
-633
No files matched your search
@@ -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
|
||||
+24
-1
@@ -116,9 +116,32 @@ FOR EACH ROW
|
||||
|
||||
drop procedure if exists update_storage_stats//
|
||||
|
||||
/* ============================================================================
|
||||
* 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]
|
||||
*
|
||||
* Writers that follow this order (and must continue to):
|
||||
* - event_update_trigger (AFTER UPDATE on Events)
|
||||
* - event_delete_trigger (BEFORE DELETE on Events)
|
||||
* - 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
|
||||
* 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//
|
||||
|
||||
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;
|
||||
|
||||
+10
-9
@@ -529,7 +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 `Logs_Component_Level_TimeKey_Id_idx` ON `Logs` (`Component`, `Level`, `TimeKey`, `Id`);
|
||||
|
||||
--
|
||||
-- Table structure for table `Manufacturers`
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -7,3 +7,74 @@ UPDATE Monitors SET DefaultScale = 'fit_to_width' WHERE DefaultScale = 'fit_to';
|
||||
|
||||
ALTER TABLE MonitorPresets MODIFY DefaultScale VARCHAR(16) NOT NULL default '0';
|
||||
UPDATE MonitorPresets SET DefaultScale = 'fit_to_width' WHERE DefaultScale = 'fit_to';
|
||||
|
||||
-- This updates a 1.39.9 database to 1.39.10
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
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.
|
||||
--
|
||||
-- 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;
|
||||
|
||||
--
|
||||
-- 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;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -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 listing is refreshed in the log window',
|
||||
help => q`
|
||||
This option determines how often the list 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 listing is refreshed in the log window',
|
||||
help => q`
|
||||
This option determines how often the list 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 listing is refreshed in the log window',
|
||||
help => q`
|
||||
This option determines how often the list in the log window is refreshed.
|
||||
0 - completely disables refreshing.
|
||||
`,
|
||||
type => $types{integer},
|
||||
category => 'lowband',
|
||||
},
|
||||
{
|
||||
name => 'ZM_WEB_L_CAN_STREAM',
|
||||
default => 'auto',
|
||||
|
||||
@@ -263,15 +263,42 @@ sub _sql_with_bind_values {
|
||||
}
|
||||
|
||||
# Basic execution of $dbh->do but with some pretty logging of the sql on error.
|
||||
# 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 $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;
|
||||
|
||||
# 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;
|
||||
}
|
||||
Error('Failed '._sql_with_bind_values($sql, @params).' : '.$dbh->errstr());
|
||||
last;
|
||||
}
|
||||
# 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, @_)." : $rows rows affected");
|
||||
}
|
||||
Debug('Succeeded '._sql_with_bind_values($sql, @params)." : $rows rows affected");
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -395,28 +395,74 @@ sub delete {
|
||||
|
||||
my $in_transaction = $ZoneMinder::Database::dbh->{AutoCommit} ? 0 : 1;
|
||||
|
||||
$ZoneMinder::Database::dbh->begin_work() if ! $in_transaction;
|
||||
# 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
|
||||
# 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 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) {
|
||||
$attempt++;
|
||||
if (!$in_transaction) {
|
||||
# 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();
|
||||
}
|
||||
|
||||
# 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;
|
||||
my $errstr = '';
|
||||
foreach my $sql (
|
||||
'DELETE FROM Stats WHERE EventId=?',
|
||||
'DELETE FROM Event_Data WHERE EventId=?',
|
||||
'DELETE FROM Frames WHERE EventId=?',
|
||||
'DELETE FROM Events WHERE Id=?',
|
||||
) {
|
||||
ZoneMinder::Database::zmDbDo($sql, $$event{Id});
|
||||
$err = $ZoneMinder::Database::dbh->err() // 0;
|
||||
if ($err) {
|
||||
# Capture before rollback, which can clear errstr on some drivers.
|
||||
$errstr = $ZoneMinder::Database::dbh->errstr() // '';
|
||||
last;
|
||||
}
|
||||
}
|
||||
|
||||
# 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) { # 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");
|
||||
select(undef, undef, undef, 0.05 * (1 << $attempt) + rand(0.05));
|
||||
}
|
||||
|
||||
my $storage = $event->Storage();
|
||||
if ($event->DiskSpace() and $storage->Id()) {
|
||||
|
||||
+241
-51
@@ -926,64 +926,254 @@ 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.
|
||||
#
|
||||
# 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
|
||||
# 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);
|
||||
|
||||
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 {
|
||||
$ok{events} = 1;
|
||||
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;
|
||||
}
|
||||
$ok{$key} = 1;
|
||||
for my $r (@$rows) {
|
||||
$agg{$r->[0]}{$key.'_c'} = $r->[1];
|
||||
$agg{$r->[0]}{$key.'_s'} = $r->[2];
|
||||
}
|
||||
}
|
||||
|
||||
# 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->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;
|
||||
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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
# 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_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_pieces,
|
||||
["${col}Events=?", "${col}Events<=>?", $key.'_c'],
|
||||
["${col}EventDiskSpace=?", "${col}EventDiskSpace<=>?", $key.'_s'];
|
||||
}
|
||||
|
||||
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");
|
||||
if (@set_pieces) {
|
||||
my $sql = 'UPDATE Event_Summaries SET '.
|
||||
join(', ', map { $_->[0] } @set_pieces).
|
||||
' WHERE MonitorId=? AND '.
|
||||
join(' AND ', map { $_->[1] } @set_pieces);
|
||||
|
||||
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");
|
||||
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.
|
||||
# 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]" ne "$target[$i]") { $needs_update = 1; last; }
|
||||
}
|
||||
if (!$needs_update) { $update_skipped++; next; }
|
||||
$update_attempted++;
|
||||
my $rv = ZoneMinder::Database::zmDbDo(
|
||||
$sql,
|
||||
@target, $mid, @cas
|
||||
);
|
||||
if (!defined $rv) {
|
||||
$update_failed++;
|
||||
} elsif ($rv == 0) {
|
||||
$update_deferred++;
|
||||
}
|
||||
}
|
||||
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 UPDATE(s) failed, %d deferred',
|
||||
join(',', @skipped_aggs) || 'none',
|
||||
$enumerate_ok ? 'ok' : 'failed',
|
||||
$update_failed, $update_attempted, $update_deferred
|
||||
));
|
||||
}
|
||||
} else {
|
||||
Error('zmaudit: every Event_Summaries aggregate SELECT failed; skipping resync');
|
||||
}
|
||||
}
|
||||
|
||||
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. 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 $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] } @$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;
|
||||
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). 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(
|
||||
'UPDATE Storage SET DiskSpace=? WHERE Id=? AND DiskSpace <=> ?',
|
||||
$target, $sid, $current
|
||||
);
|
||||
if (!defined $rv) {
|
||||
$failed++;
|
||||
} elsif ($rv == 0) {
|
||||
# CAS lost: concurrent writer adjusted DiskSpace. Leave theirs.
|
||||
$deferred++;
|
||||
}
|
||||
}
|
||||
if (!$failed) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Error('zmaudit Storage DiskSpace resync did not complete') if !$storage_done;
|
||||
}
|
||||
|
||||
sleep($Config{ZM_AUDIT_CHECK_INTERVAL}) if $continuous;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
+153
-8
@@ -87,17 +87,162 @@ 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.
|
||||
#
|
||||
# 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;
|
||||
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. 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();
|
||||
|
||||
$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;
|
||||
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'],
|
||||
['Events_Week', '1 week'],
|
||||
['Events_Month', '1 month'],
|
||||
) {
|
||||
my ($table, $interval) = @$bucket;
|
||||
my $rows = $dbh->selectall_arrayref(
|
||||
"SELECT EventId, MonitorId FROM $table WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL $interval)"
|
||||
);
|
||||
$err = $dbh->err() // 0;
|
||||
if ($err) { $errstr = $dbh->errstr() // ''; last; }
|
||||
next if !$rows or !@$rows;
|
||||
my @event_ids = map { $_->[0] } @$rows;
|
||||
$touched_monitors{$_->[1]} = 1 for @$rows;
|
||||
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; }
|
||||
}
|
||||
|
||||
$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;
|
||||
# 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);
|
||||
|
||||
$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;
|
||||
# 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'],
|
||||
['Events_Week', 'w'],
|
||||
['Events_Month', 'm'],
|
||||
) {
|
||||
my ($table, $key) = @$bucket;
|
||||
my $rows = $dbh->selectall_arrayref(
|
||||
"SELECT MonitorId, COUNT(*), COALESCE(SUM(DiskSpace), 0) FROM $table".
|
||||
" WHERE MonitorId IN ($placeholders) GROUP BY MonitorId",
|
||||
undef, @mids
|
||||
);
|
||||
$err = $dbh->err() // 0;
|
||||
if ($err) { $errstr = $dbh->errstr() // ''; last; }
|
||||
for my $r (@$rows) {
|
||||
$agg{$r->[0]}{$key.'_c'} = $r->[1];
|
||||
$agg{$r->[0]}{$key.'_s'} = $r->[2];
|
||||
}
|
||||
}
|
||||
|
||||
# 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 (@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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$err) {
|
||||
$dbh->commit();
|
||||
last;
|
||||
}
|
||||
|
||||
$dbh->rollback();
|
||||
if ($err != 1213 or $attempt >= $max_attempts) { # 1213 = ER_LOCK_DEADLOCK
|
||||
Error("Event_Summaries prune+resync gave up after $attempt attempt(s): ".($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} ) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+9
-3
@@ -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(),
|
||||
@@ -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);
|
||||
@@ -812,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
|
||||
|
||||
|
||||
+26
-9
@@ -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,8 +478,9 @@ 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;
|
||||
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;
|
||||
@@ -489,10 +492,14 @@ void EventStream::processCommand(const CmdMsg *msg) {
|
||||
}
|
||||
case CMD_STOP :
|
||||
Debug(1, "Got STOP command");
|
||||
stopped = true;
|
||||
paused = false;
|
||||
step = 0;
|
||||
send_twice = false;
|
||||
break;
|
||||
case CMD_FASTFWD : {
|
||||
Debug(1, "Got FAST FWD command");
|
||||
stopped = false;
|
||||
paused = false;
|
||||
// Set play rate
|
||||
switch (replay_rate) {
|
||||
@@ -517,6 +524,7 @@ void EventStream::processCommand(const CmdMsg *msg) {
|
||||
break;
|
||||
}
|
||||
case CMD_SLOWFWD : {
|
||||
stopped = false;
|
||||
paused = true;
|
||||
replay_rate = ZM_RATE_BASE;
|
||||
step = 1;
|
||||
@@ -526,6 +534,7 @@ void EventStream::processCommand(const CmdMsg *msg) {
|
||||
break;
|
||||
}
|
||||
case CMD_SLOWREV : {
|
||||
stopped = false;
|
||||
paused = true;
|
||||
replay_rate = ZM_RATE_BASE;
|
||||
step = -1;
|
||||
@@ -535,6 +544,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 +703,7 @@ void EventStream::processCommand(const CmdMsg *msg) {
|
||||
int zoom;
|
||||
int scale;
|
||||
bool paused;
|
||||
bool stopped;
|
||||
} status_data = {};
|
||||
|
||||
{
|
||||
@@ -707,6 +718,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 +731,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 +1050,11 @@ void EventStream::runStream() {
|
||||
send_frame = false;
|
||||
TimePoint::duration time_since_last_send = now - last_frame_sent;
|
||||
|
||||
if (!paused) {
|
||||
if (stopped) {
|
||||
// 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);
|
||||
// If we are streaming and this frame is due to be sent
|
||||
@@ -1069,7 +1086,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];
|
||||
@@ -1117,7 +1134,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];
|
||||
@@ -1180,14 +1197,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
|
||||
// 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;
|
||||
} // end if !paused
|
||||
if (!stopped) curr_frame_id += step;
|
||||
} // end if !paused && !stopped
|
||||
} // end scope for mutex lock
|
||||
|
||||
if (type == STREAM_SINGLE) {
|
||||
|
||||
@@ -2892,6 +2892,21 @@ bool Monitor::applyDeinterlacing(std::shared_ptr<ZMPacket> &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;
|
||||
|
||||
|
||||
@@ -767,6 +767,13 @@ class Monitor : public std::enable_shared_from_this<Monitor> {
|
||||
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;
|
||||
}
|
||||
|
||||
+39
-24
@@ -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) {
|
||||
|
||||
@@ -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,19 +108,24 @@ void MonitorStream::processCommand(const CmdMsg *msg) {
|
||||
break;
|
||||
case CMD_VARPLAY :
|
||||
Debug(1, "Got VARPLAY command");
|
||||
stopped = false;
|
||||
if (paused) {
|
||||
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");
|
||||
paused = true;
|
||||
stopped = true;
|
||||
paused = false;
|
||||
delayed = false;
|
||||
step = 0;
|
||||
send_twice = false;
|
||||
break;
|
||||
case CMD_FASTFWD :
|
||||
Debug(1, "Got FAST FWD command");
|
||||
stopped = false;
|
||||
if (paused) {
|
||||
paused = false;
|
||||
delayed = true;
|
||||
@@ -156,6 +163,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 +171,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 +179,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 +266,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 +310,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 +318,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,
|
||||
@@ -635,6 +648,12 @@ void MonitorStream::runStream() {
|
||||
std::this_thread::sleep_for(MAX_SLEEP);
|
||||
continue;
|
||||
}
|
||||
if (stopped) {
|
||||
// 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();
|
||||
|
||||
@@ -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__)
|
||||
ip = (void *)(uc->uc_mcontext.mc_gpregs.gp_elr);
|
||||
#else
|
||||
ip = (void *)(uc->uc_mcontext.pc);
|
||||
#endif
|
||||
#elif defined(__arm__)
|
||||
#if defined(__FreeBSD__)
|
||||
ip = (void *)(uc->uc_mcontext.__gregs[_REG_PC]);
|
||||
#else
|
||||
ip = (void *)(uc->uc_mcontext.arm_pc);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// Print the fault address and instruction pointer
|
||||
|
||||
@@ -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 {
|
||||
@@ -75,7 +78,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 +95,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 +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 + VARPLAY_RATE_OFFSET) as network-byte-order uint16.
|
||||
CMD_VARPLAY,
|
||||
CMD_GET_IMAGE,
|
||||
CMD_QUIT,
|
||||
@@ -125,6 +139,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,7 +203,9 @@ class StreamBase {
|
||||
sd(-1),
|
||||
lock_fd(0),
|
||||
paused(false),
|
||||
stopped(false),
|
||||
step(0),
|
||||
send_twice(false),
|
||||
maxfps(DEFAULT_MAXFPS),
|
||||
base_fps(0.0),
|
||||
effective_fps(0.0),
|
||||
|
||||
+137
-100
@@ -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),
|
||||
@@ -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()};
|
||||
@@ -84,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
|
||||
@@ -521,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;
|
||||
}
|
||||
}
|
||||
@@ -562,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;
|
||||
}
|
||||
@@ -691,49 +692,11 @@ 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);
|
||||
}
|
||||
//delete pkt;
|
||||
}
|
||||
}
|
||||
|
||||
if (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
|
||||
@@ -1551,26 +1514,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<double>(pkt->dts - last_fragment_start_dts_)
|
||||
duration = static_cast<double>(this_keyframe_dts - last_fragment_start_dts_)
|
||||
* video_out_stream->time_base.num
|
||||
/ video_out_stream->time_base.den;
|
||||
}
|
||||
@@ -1580,49 +1548,118 @@ 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<double>(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;
|
||||
|
||||
// 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");
|
||||
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.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];
|
||||
if (fread(mfro, 1, 16, fp) == 16) {
|
||||
uint32_t box_size = (static_cast<uint32_t>(mfro[0]) << 24)
|
||||
| (static_cast<uint32_t>(mfro[1]) << 16)
|
||||
| (static_cast<uint32_t>(mfro[2]) << 8)
|
||||
| static_cast<uint32_t>(mfro[3]);
|
||||
if (box_size == 16
|
||||
&& mfro[4] == 'm' && mfro[5] == 'f' && mfro[6] == 'r' && mfro[7] == 'o') {
|
||||
uint32_t mfra_size = (static_cast<uint32_t>(mfro[12]) << 24)
|
||||
| (static_cast<uint32_t>(mfro[13]) << 16)
|
||||
| (static_cast<uint32_t>(mfro[14]) << 8)
|
||||
| static_cast<uint32_t>(mfro[15]);
|
||||
if (mfra_size > 0 && static_cast<int64_t>(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<double>(
|
||||
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)
|
||||
|
||||
+18
-4
@@ -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
|
||||
@@ -93,11 +96,17 @@ class VideoStore {
|
||||
size_t reorder_queue_size;
|
||||
std::map<int, std::list<std::shared_ptr<ZMPacket>>> 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<Fragment> 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 +133,11 @@ class VideoStore {
|
||||
const std::vector<Fragment> &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)
|
||||
|
||||
+4
-3
@@ -862,9 +862,10 @@ bool Zone::ParsePercentagePolygon(const char *poly_string, unsigned int width, u
|
||||
int32 px_x = static_cast<int32>(std::lround(pct_x * mon_w / 100.0));
|
||||
int32 px_y = static_cast<int32>(std::lround(pct_y * mon_h / 100.0));
|
||||
|
||||
// Clamp to monitor bounds
|
||||
px_x = std::clamp(px_x, static_cast<int32>(0), static_cast<int32>(width));
|
||||
px_y = std::clamp(px_y, static_cast<int32>(0), static_cast<int32>(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<int32>(0), static_cast<int32>(width) - 1);
|
||||
px_y = std::clamp(px_y, static_cast<int32>(0), static_cast<int32>(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);
|
||||
|
||||
+17
-1
@@ -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 <img src=nph-zms?...>
|
||||
// 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) ) {
|
||||
|
||||
+20
-20
@@ -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<int>(width));
|
||||
REQUIRE(v.x_ < static_cast<int>(width));
|
||||
REQUIRE(v.y_ >= 0);
|
||||
REQUIRE(v.y_ <= static_cast<int>(height));
|
||||
REQUIRE(v.y_ < static_cast<int>(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<int>(width), 0));
|
||||
REQUIRE(verts[2] == Vector2(static_cast<int>(width), static_cast<int>(height)));
|
||||
REQUIRE(verts[1] == Vector2(static_cast<int>(width) - 1, 0));
|
||||
REQUIRE(verts[2] == Vector2(static_cast<int>(width) - 1, static_cast<int>(height) - 1));
|
||||
}
|
||||
|
||||
// --- Auto-detect format tests ---
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.39.9
|
||||
1.39.10
|
||||
@@ -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 '.validHtmlStr($_REQUEST['action']).' or insufficient permissions for user '.validHtmlStr($user->Username()));
|
||||
?>
|
||||
+41
-21
@@ -58,7 +58,9 @@ ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissio
|
||||
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,
|
||||
@@ -92,34 +94,46 @@ 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.
|
||||
// getFilterSelection() reads $_REQUEST first, then the zmFilter_* cookie.
|
||||
$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' => 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 request filters to SQL
|
||||
// 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']);
|
||||
$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_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);
|
||||
@@ -203,12 +217,15 @@ function queryRequest() {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply MonitorId filter
|
||||
// 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']);
|
||||
$filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) {
|
||||
return in_array($monitor['Id'], $monitor_ids);
|
||||
});
|
||||
$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);
|
||||
@@ -390,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';
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@
|
||||
|
||||
// Device view actions
|
||||
if ( !canEdit('Devices') ) {
|
||||
ajaxError('Insufficient permissions for user '.$user->Username());
|
||||
ajaxError('Insufficient permissions for user '.validHtmlStr($user->Username()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ if ( $action == 'device' ) {
|
||||
}
|
||||
ajaxResponse();
|
||||
} else {
|
||||
ajaxError('Unrecognised action '.$_REQUEST['action']);
|
||||
ajaxError('Unrecognised action '.validHtmlStr($_REQUEST['action']));
|
||||
} // end if action
|
||||
|
||||
?>
|
||||
@@ -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.'"');
|
||||
}
|
||||
|
||||
?>
|
||||
+11
-6
@@ -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 {
|
||||
@@ -109,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 = [];
|
||||
@@ -144,7 +149,7 @@ if ( canView('Events') or canView('Snapshots') ) {
|
||||
ajaxResponse(array(
|
||||
'exportFile'=>$exportFile,
|
||||
'exportFormat'=>$exportFormat,
|
||||
'connkey'=>(isset($_REQUEST['connkey'])?$_REQUEST['connkey']:'')
|
||||
'connkey'=>$exportConnkey
|
||||
));
|
||||
|
||||
} else {
|
||||
@@ -227,5 +232,5 @@ if ( canEdit('Events') ) {
|
||||
} // end switch action
|
||||
} // end if canEdit('Events')
|
||||
|
||||
ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissions for user '.$user->Username());
|
||||
ajaxError('Unrecognised action '.validHtmlStr($_REQUEST['action']).' or insufficient permissions for user '.validHtmlStr($user->Username()));
|
||||
?>
|
||||
+23
-9
@@ -8,7 +8,7 @@ $data = array();
|
||||
//
|
||||
|
||||
if (!canView('Events'))
|
||||
$message = 'Insufficient permissions for user '.$user->Username().'<br/>';
|
||||
$message = 'Insufficient permissions for user '.validHtmlStr($user->Username()).'<br/>';
|
||||
|
||||
if (empty($_REQUEST['task'])) {
|
||||
$message = 'Must specify a task<br/>';
|
||||
@@ -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 for user '.validHtmlStr($user->Username()));
|
||||
return;
|
||||
}
|
||||
foreach ($eids as $eid) archiveRequest($task, $eid);
|
||||
break;
|
||||
case 'delete' :
|
||||
if (!canEdit('Events')) {
|
||||
ajaxError('Insufficient permissions for user '.$user->Username());
|
||||
ajaxError('Insufficient permissions for user '.validHtmlStr($user->Username()));
|
||||
return;
|
||||
}
|
||||
foreach ($eids as $eid) {
|
||||
@@ -128,7 +128,7 @@ switch ($task) {
|
||||
$data = queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $limit);
|
||||
break;
|
||||
default :
|
||||
ZM\Fatal("Unrecognised task '$task'");
|
||||
ajaxError("Unrecognised task '".validHtmlStr($task)."'");
|
||||
} // end switch task
|
||||
|
||||
ajaxResponse($data);
|
||||
@@ -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';
|
||||
|
||||
@@ -242,7 +255,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)) {
|
||||
|
||||
+55
-13
@@ -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 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 for user '.$user->Username();
|
||||
$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 for user '.$user->Username();
|
||||
$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,7 +35,7 @@ if (!isset($_REQUEST['task'])) {
|
||||
}
|
||||
} else {
|
||||
// Only the query and create tasks are supported at the moment
|
||||
$message = 'Unrecognised task '.$_REQUEST['task'];
|
||||
$message = 'Unrecognised task '.validHtmlStr($_REQUEST['task']);
|
||||
}
|
||||
|
||||
if ($message) {
|
||||
@@ -90,8 +90,12 @@ 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');
|
||||
$columnsContext = $columns;
|
||||
// The names of columns shown in the log view that are NOT dB columns in the database
|
||||
$col_alt = array('DateTime', 'Server');
|
||||
|
||||
@@ -99,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);
|
||||
@@ -108,7 +113,14 @@ 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($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();
|
||||
@@ -145,24 +157,33 @@ 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'];
|
||||
zm_session_start();
|
||||
$_SESSION['zmLogComponent'] = $_REQUEST['Component'];
|
||||
session_write_close();
|
||||
$query['values'][] = $requestComponent;
|
||||
}
|
||||
|
||||
if (!empty($_REQUEST['ServerId'])) {
|
||||
if ($where) $where .= ' AND ';
|
||||
$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 = (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 ';
|
||||
$where .= ' Level = ?';
|
||||
$query['values'][] = $level_codes[$L];
|
||||
}
|
||||
|
||||
if (!empty($_REQUEST['StartDateTime'])) {
|
||||
$start_time = strtotime($_REQUEST['StartDateTime']);
|
||||
if ($start_time) {
|
||||
@@ -183,16 +204,37 @@ function queryRequest() {
|
||||
ZM\Warning("Unable to parse EndDateTime ".$_REQUEST['EndDateTime']. " into a timestamp");
|
||||
}
|
||||
}
|
||||
|
||||
zm_session_start();
|
||||
$_SESSION['zmLogComponent'] = $requestComponent;
|
||||
$_SESSION['zmLogFilterLevel'] = isset($level_codes[$L]) ? $L : '';
|
||||
session_write_close();
|
||||
|
||||
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']);
|
||||
$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 ?, ?';
|
||||
if ($nameMainQuery !== '' && $nameSubQuery !== '') { # Optimized query
|
||||
$query['sql'] = '
|
||||
SELECT ' .$col_str_context. '
|
||||
FROM `' .$table. '` ' .$nameMainQuery. '
|
||||
JOIN (
|
||||
SELECT Id
|
||||
FROM `'.$table.'` '.$where. '
|
||||
ORDER BY ' .$sort. ' ' .$order. '
|
||||
LIMIT ?, ?
|
||||
) AS ' .$nameSubQuery. '
|
||||
ON ' .$nameMainQuery. '.Id=' .$nameSubQuery. '.Id
|
||||
ORDER BY ' .$nameMainQuery. '.' .$sort. ' ' .$order;
|
||||
} else {
|
||||
$query['sql'] = 'SELECT ' .$col_str. ' FROM `' .$table. '` ' .$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ?';
|
||||
}
|
||||
|
||||
array_push($query['values'], $offset, $limit);
|
||||
|
||||
$rows = array();
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
@@ -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' ) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1089,7 +1089,7 @@ class Filter extends ZM_Object {
|
||||
// This displays filters from the events page.
|
||||
//
|
||||
public function simple_widget() {
|
||||
$html = '<div id="fieldsTable" class="filterTable">';
|
||||
$html = '<div id="fieldsTable" class="filterTable controlHeader">';
|
||||
$terms = $this->terms();
|
||||
$attrTypes = $this->attrTypes();
|
||||
$opTypes = $this->opTypes();
|
||||
|
||||
@@ -14,7 +14,7 @@ class Report extends ZM_Object {
|
||||
'EndDateTime' => null,
|
||||
'Interval' => '86400',
|
||||
'CreatedBy' => null,
|
||||
);
|
||||
);
|
||||
|
||||
public static function find( $parameters = array(), $options = array() ) {
|
||||
return ZM_Object::_find(self::class, $parameters, $options);
|
||||
|
||||
+33
-10
@@ -171,29 +171,46 @@ 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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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 (isset($_SESSION['username'])) {
|
||||
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.
|
||||
$sql .= ' AND Username=?';
|
||||
array_push($values, $_SESSION['username']);
|
||||
if (ZM_CASE_INSENSITIVE_USERNAMES) {
|
||||
$sql .= ' AND LOWER(Username)=LOWER(?)';
|
||||
} else {
|
||||
$sql .= ' AND Username=?';
|
||||
}
|
||||
array_push($values, $filterUser);
|
||||
}
|
||||
|
||||
foreach (dbFetchAll($sql, NULL, $values) as $user) {
|
||||
$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 +223,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 +231,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 +241,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 ?? '')."' sessionUser='".($sessionUser ?? '')."' 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'");
|
||||
|
||||
@@ -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 = '';
|
||||
@@ -105,17 +105,33 @@ function downloadEvents(
|
||||
$maxTimeSecs = -1;
|
||||
$maxTime = '';
|
||||
foreach ($events_by_monitor_id[$mid] as $event) {
|
||||
$filePath = findVideoEventFile($event, "mp4");
|
||||
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();
|
||||
}
|
||||
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;
|
||||
|
||||
$fileName = basename($filePath);
|
||||
if (strpos($fileName, 'incomplete') !== false && !$endSecs) $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='.$mid);
|
||||
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);
|
||||
@@ -152,6 +168,11 @@ function downloadEvents(
|
||||
}
|
||||
} # end foreach monitor
|
||||
|
||||
if (count($exportFileList) === 0) {
|
||||
ZM\Warning('No events were found for export.');
|
||||
return false;
|
||||
}
|
||||
|
||||
generateFileList($exportFormat, $exportStructure, $archive_path, $exportCompressed, $export_dir, $export_root, $exportFileList);
|
||||
|
||||
chdir(DIR_EXPORTS_DOWNLOAD);
|
||||
|
||||
@@ -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
|
||||
@@ -2505,4 +2525,29 @@ if (!function_exists('mb_lcfirst')) { // Available in PHP >= 8.4
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
function findVideoEventFile ($Event, $ext="*") {
|
||||
$dir = $Event->Path();
|
||||
$eventDefaultVideo = to_string($Event->DefaultVideo());
|
||||
$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 === '') {
|
||||
# By default, we search for files with any extension, such as mp4, mkv, or webm.
|
||||
# Look for the final renamed first, then incomplete.
|
||||
# Incomplete files may exist as either incomplete.<container> or incomplete.<codec>.<container>.
|
||||
$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];
|
||||
}
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
?>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -90,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
|
||||
@@ -202,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;
|
||||
@@ -247,6 +250,7 @@ function EventStream(config) {
|
||||
}
|
||||
this.started = false;
|
||||
this.connKey = null;
|
||||
this.stopped = false;
|
||||
this.streamCmdParms.connkey = null;
|
||||
|
||||
// Delay before restarting — exponential backoff
|
||||
@@ -457,10 +461,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) {
|
||||
|
||||
+51
-4
@@ -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});
|
||||
@@ -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 <img> 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
|
||||
@@ -1051,7 +1093,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));
|
||||
}
|
||||
};
|
||||
@@ -1271,7 +1313,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);
|
||||
|
||||
@@ -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.<br><br>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:',
|
||||
@@ -240,6 +241,14 @@ $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?',
|
||||
'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',
|
||||
|
||||
@@ -253,6 +253,7 @@ $SLANG = array(
|
||||
'ConfiguredFor' => 'настроен на',
|
||||
'ConfirmAction' => 'Подтвердите действие',
|
||||
'ConfirmDeleteControl' => 'Внимание! Удаление элемента управления приведет к тому, что все мониторы, которые его используют, станут неуправляемыми.<br><br>Вы уверены, что хотите удалить?',
|
||||
'ConfirmDeleteGroups' => 'Вы действительно хотите удалить выбранные группы?',
|
||||
'ConfirmDeleteDevices' => 'Вы действительно хотите удалить выбранные устройства?',
|
||||
'ConfirmDeleteEvents' => 'Вы действительно хотите удалить выбранные события?',
|
||||
'ConfirmDeleteLayout' => 'Вы действительно хотите удалить текущий шаблон?',
|
||||
@@ -1009,6 +1010,14 @@ $SLANG = array(
|
||||
'ConfirmDeleteTitle' => 'Подтвердите удаление',
|
||||
'ConfirmDeleteUserTitle'=> 'Подтвердите удаление пользователя',
|
||||
'ConfirmDeleteUser' => 'Вы уверены, что хотите удалить выбранных пользователей?',
|
||||
'ConfirmDeleteServerTitle'=> 'Подтвердите удаление сервера',
|
||||
'ConfirmDeleteServer' => 'Вы уверены, что хотите удалить выбранные серверы?',
|
||||
'ConfirmDeleteReport' => 'Вы уверены, что хотите удалить выбранные отчеты?',
|
||||
'ConfirmDeleteStorageTitle'=> 'Подтвердите удаление хранилища',
|
||||
'ConfirmDeleteStorage' => 'Вы уверены, что хотите удалить выбранные хранилища?',
|
||||
'ConfirmDeleteRoleTitle'=> 'Подтвердите удаление ролей',
|
||||
'ConfirmDeleteRole' => 'Вы уверены, что хотите удалить выбранные роли?',
|
||||
'ConfirmDeleteSnapshots'=> 'Вы уверены, что хотите удалить выбранные снапшоты?',
|
||||
'Continuous' => 'Непрерывный',
|
||||
'ONVIF_Alarm_Text' => 'Текст сигнала тревоги ONVIF', //added 18/07/2022
|
||||
'None' => 'Нет',
|
||||
|
||||
@@ -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{
|
||||
@@ -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 */
|
||||
@@ -381,17 +385,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-word;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -120,6 +120,7 @@ height: 100%;
|
||||
}
|
||||
.eventStats {
|
||||
padding-left: 0;
|
||||
z-index: 1;
|
||||
/* margin-right: 5px; */
|
||||
}
|
||||
|
||||
|
||||
@@ -74,10 +74,15 @@ tr td input[type="radio"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#content {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
body.sticky #content {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
nav ul.nav {
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
@@ -89,11 +94,27 @@ ul.form > li {
|
||||
padding-left: 300px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
ul.form > li > label:first-child {
|
||||
width: 290px;
|
||||
margin-left: -300px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
nav #pills-tab a.nav-link {
|
||||
margin-top: -0.5em;
|
||||
margin-bottom: -0.5em;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ tr.log-war td {
|
||||
}
|
||||
|
||||
tr.log-dbg td {
|
||||
color: #666666;
|
||||
color: #aaaaaa;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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', 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 (%)
|
||||
@@ -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', 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 (%)
|
||||
@@ -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', 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 (%)
|
||||
|
||||
@@ -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;
|
||||
@@ -663,6 +666,37 @@ 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 {
|
||||
const form = document.querySelector('form[name="'+formName+'"]');
|
||||
if (form) {
|
||||
if (currentView == 'groups') form.elements['action'].value = 'delete';
|
||||
submitThisForm(form);
|
||||
} else {
|
||||
console.warn(`Form with name=${formName} not found.`);
|
||||
}
|
||||
}
|
||||
}, {once: true});
|
||||
})
|
||||
.fail(logAjaxFail);
|
||||
}
|
||||
|
||||
window.addEventListener( 'DOMContentLoaded', checkSize );
|
||||
|
||||
function convertLabelFormat(LabelFormat, monitorName) {
|
||||
|
||||
@@ -10,7 +10,7 @@ require_once('includes/User_Role.php');
|
||||
<div class="col button-block">
|
||||
<div id="contentButtons">
|
||||
<?php echo makeButton('?view=role&rid=0', 'AddNewRole', $canEdit); ?>
|
||||
<button type="submit" class="btn-danger" name="deleteBtn" value="Delete" disabled="disabled"><?php echo translate('Delete') ?></button>
|
||||
<button type="button" class="btn-danger" name="deleteBtn" value="Delete" data-on-click-this="DeleteRole" disabled="disabled"><?php echo translate('Delete') ?></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-scroll-table">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="col button-block">
|
||||
<div id="contentButtons">
|
||||
<button type="button" id="NewServerBtn" value="<?php echo translate('AddNewServer') ?>" disabled="disabled"><?php echo translate('AddNewServer') ?></button>
|
||||
<button type="submit" class="btn-danger" name="deleteBtn" value="Delete" disabled="disabled"><?php echo translate('Delete') ?></button>
|
||||
<button type="button" class="btn-danger" name="deleteBtn" value="Delete" data-on-click-this="DeleteServer" disabled="disabled"><?php echo translate('Delete') ?></button>
|
||||
</div>
|
||||
</div> <!-- .col -->
|
||||
<div class="wrapper-scroll-table">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="col button-block">
|
||||
<div id="contentButtons">
|
||||
<button type="button" id="NewStorageBtn" value="<?php echo translate('AddNewStorage') ?>" disabled="disabled"><?php echo translate('AddNewStorage') ?></button>
|
||||
<button type="submit" class="btn-danger" name="deleteBtn" value="Delete" disabled="disabled"><?php echo translate('Delete') ?></button>
|
||||
<button type="button" class="btn-danger" name="deleteBtn" value="Delete" data-on-click-this="DeleteStorage" disabled="disabled"><?php echo translate('Delete') ?></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-scroll-table">
|
||||
|
||||
@@ -294,7 +294,7 @@ echo $navbar ?>
|
||||
);
|
||||
parseFilter($filter);
|
||||
$eventsLink = canView('Events') ? '?view='.ZM_WEB_EVENTS_VIEW.'&page=1'.$filter['querystring'] : '';
|
||||
echo '<th data-sortable="true" data-field="'.$i.'Events" class="colEvents"'
|
||||
echo '<th data-sortable="true" data-field="'.$i.'Events" class="colEvents col'.$i.'Events"'
|
||||
.'>'
|
||||
.htmlspecialchars($eventCounts[$i]['title'])
|
||||
.'</th>'.PHP_EOL;
|
||||
|
||||
@@ -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()) )
|
||||
<button id="editBtn" class="btn btn-normal" data-toggle="tooltip" data-placement="top" title="<?php echo translate('Edit') ?>" disabled><i class="fa fa-pencil"></i></button>
|
||||
<button id="exportBtn" class="btn btn-normal" data-toggle="tooltip" data-placement="top" title="<?php echo translate('Export') ?>"><i class="fa fa-external-link"></i></button>
|
||||
<a id="downloadBtn" class="btn btn-normal" href="<?php echo $Event->getStreamSrc(array('mode'=>'mp4'),'&')?>"
|
||||
title="<?php echo translate('Download'). ' ' . (($Event->DefaultVideo() === 'index.m3u8') ? translate('video') . " " . translate('file') : $Event->DefaultVideo()) ?>"
|
||||
title="<?php echo translate('Download'). ' ' . $Event->DefaultVideo() ?>"
|
||||
download
|
||||
<?php echo $Event->DefaultVideo() ? '' : 'style="display:none;"' ?>
|
||||
><i class="fa fa-download"></i></a>
|
||||
@@ -354,9 +353,12 @@ if (file_exists($Event->Path().'/objdetect.jpg')) {
|
||||
<div id="zoompan" class="zoompan">
|
||||
<?php
|
||||
if ($video_tag) {
|
||||
// Use HLS byte-range playback if m3u8 manifest exists on disk
|
||||
$has_hls = (($codec == 'MP4HLS') || str_ends_with($Event->DefaultVideo(), '.m3u8'))
|
||||
&& file_exists($Event->Path() . '/index.m3u8');
|
||||
// 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.
|
||||
$has_hls = file_exists($Event->Path() . '/index.m3u8')
|
||||
&& (($codec == 'MP4HLS') || ($codec == 'auto'));
|
||||
if ($has_hls) {
|
||||
$Server = $Event->Server();
|
||||
$hlsSrc = $Server->PathToIndex() . '?view=view_hls&eid=' . $Event->Id();
|
||||
@@ -402,7 +404,11 @@ if ($video_tag) {
|
||||
autoplay: true,
|
||||
preload: 'auto',
|
||||
playbackRates: rates,
|
||||
liveui: <?php echo $has_hls && !$Event->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
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ getBodyTopHTML();
|
||||
<i class="material-icons md-18">add_circle</i>
|
||||
<span class="text"><?php echo translate('New') ?></span>
|
||||
</button>
|
||||
<button type="button" name="deleteBtn" value="Delete" data-on-click-this="deleteGroup" disabled="disabled">
|
||||
<button type="button" class="btn-danger" name="deleteBtn" value="Delete" data-on-click-this="deleteGroup" disabled="disabled">
|
||||
<i class="material-icons md-18">delete</i>
|
||||
<span class="text"><?php echo translate('Delete') ?></span>
|
||||
</button>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 col<Period>Events
|
||||
// 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'] + '<br/><div class="small text-nowrap text-muted">' +
|
||||
footer[period + 'EventDiskSpace'] + '</div>';
|
||||
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('<a href="?view=' + ZM_WEB_EVENTS_VIEW + footer[period + 'FilterQuery'] + '">' +
|
||||
contentHtml + '</a>');
|
||||
}
|
||||
var contentHtml = footer[period + 'Events'] + '<br/><div class="small text-nowrap text-muted">' +
|
||||
footer[period + 'EventDiskSpace'] + '</div>';
|
||||
|
||||
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('<a href="?view=' + ZM_WEB_EVENTS_VIEW + footer[period + 'FilterQuery'] + '">' +
|
||||
contentHtml + '</a>');
|
||||
}
|
||||
} else {
|
||||
target.html(contentHtml);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,6 +74,10 @@ function updateFooter(footer) {
|
||||
|
||||
// Called by bootstrap-table to retrieve monitor data
|
||||
function ajaxRequest(params) {
|
||||
if (document.visibilityState == 'hidden') {
|
||||
table.bootstrapTable('hideLoading');
|
||||
return;
|
||||
}
|
||||
if (ajax) ajax.abort();
|
||||
|
||||
// Get filter selections from the form and add to params.data
|
||||
@@ -562,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({
|
||||
|
||||
@@ -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) {
|
||||
tableControlCaps.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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,6 +38,10 @@ var params =
|
||||
|
||||
// Called by bootstrap-table to retrieve zm event data
|
||||
function ajaxRequest(params) {
|
||||
if (document.visibilityState == 'hidden') {
|
||||
table.bootstrapTable('hideLoading');
|
||||
return;
|
||||
}
|
||||
if (params.data && params.data.filter) {
|
||||
params.data.advsearch = params.data.filter;
|
||||
delete params.data.filter;
|
||||
|
||||
@@ -3,6 +3,10 @@ var table = $j('#framesTable');
|
||||
|
||||
// Called by bootstrap-table to retrieve zm frame data
|
||||
function ajaxRequest(params) {
|
||||
if (document.visibilityState == 'hidden') {
|
||||
table.bootstrapTable('hideLoading');
|
||||
return;
|
||||
}
|
||||
if ( params.data && params.data.filter ) {
|
||||
params.data.advsearch = params.data.filter;
|
||||
delete params.data.filter;
|
||||
|
||||
@@ -33,17 +33,15 @@ function editGroup( element ) {
|
||||
}
|
||||
|
||||
function deleteGroup(element) {
|
||||
const form = element.form;
|
||||
form.elements['action'].value = 'delete';
|
||||
form.submit();
|
||||
getDelConfirmModal('ConfirmDeleteGroups', 'Delete', 'groupsForm');
|
||||
}
|
||||
|
||||
function configureButtons(element) {
|
||||
if (canEdit.Groups) {
|
||||
configureDeleteButton(element);
|
||||
} else {
|
||||
const form = element.form;
|
||||
if (element.checked) {
|
||||
form.deleteBtn.disabled = (element.value == 0);
|
||||
}
|
||||
if (form) form.deleteBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ var params =
|
||||
|
||||
// Called by bootstrap-table to retrieve zm log data
|
||||
function ajaxRequest(params) {
|
||||
if (document.visibilityState == 'hidden') {
|
||||
table.bootstrapTable('hideLoading');
|
||||
return;
|
||||
}
|
||||
if ($j('#filterServerId').val()) {
|
||||
params.data.ServerId = $j('#filterServerId').val();
|
||||
}
|
||||
@@ -50,9 +54,10 @@ function ajaxRequest(params) {
|
||||
data: params.data,
|
||||
timeout: 0,
|
||||
success: function(data) {
|
||||
if (!data.rows.length) {
|
||||
// If page is > 1, bt infinitely loops
|
||||
if (!data.rows.length && data.total > 0) {
|
||||
// The requested page is out of range; reset to page 1.
|
||||
table.bootstrapTable('selectPage', 1);
|
||||
return;
|
||||
}
|
||||
// rearrange the result into what bootstrap-table expects
|
||||
params.success({
|
||||
@@ -89,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) ? 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);
|
||||
@@ -108,10 +113,18 @@ 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('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();
|
||||
$j('#clearLogsConfirm').modal('hide');
|
||||
});
|
||||
}
|
||||
@@ -134,7 +147,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) {
|
||||
@@ -147,7 +159,6 @@ function deleteLogs(log_ids) {
|
||||
},
|
||||
error: function(jqxhr) {
|
||||
logAjaxFail(jqxhr);
|
||||
$j('#clearLogsConfirm').modal('hide');
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
@@ -200,6 +211,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
|
||||
@@ -212,6 +224,7 @@ function initPage() {
|
||||
deleteLogs(getIdSelections());
|
||||
})
|
||||
.fail(function(jqXHR) {
|
||||
manageClearButtonAvailability();
|
||||
console.log('error getting clearlogsconfirm', jqXHR);
|
||||
logAjaxFail(jqXHR);
|
||||
});
|
||||
@@ -227,6 +240,7 @@ function initPage() {
|
||||
$j('#clearLogsConfirm').modal('show');
|
||||
})
|
||||
.fail(function(jqXHR) {
|
||||
manageClearButtonAvailability();
|
||||
console.log('error getting clearlogsconfirm', jqXHR);
|
||||
logAjaxFail(jqXHR);
|
||||
});
|
||||
@@ -239,12 +253,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')
|
||||
@@ -255,6 +267,18 @@ 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();
|
||||
});
|
||||
@@ -67,27 +67,20 @@ function sortMenuItems(button) {
|
||||
button.classList.toggle('btn-success');
|
||||
}
|
||||
|
||||
// Load the Delete Confirmation Modal HTML via Ajax call
|
||||
function getDelConfirmModal(key, title) {
|
||||
$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="userForm"]'));
|
||||
});
|
||||
})
|
||||
.fail(logAjaxFail);
|
||||
function DeleteUser() {
|
||||
getDelConfirmModal('ConfirmDeleteUser', 'ConfirmDeleteUserTitle', 'userForm');
|
||||
}
|
||||
|
||||
function DeleteUser() {
|
||||
getDelConfirmModal('ConfirmDeleteUser', 'ConfirmDeleteUserTitle');
|
||||
function DeleteServer() {
|
||||
getDelConfirmModal('ConfirmDeleteServer', 'ConfirmDeleteServerTitle', 'serversForm');
|
||||
}
|
||||
|
||||
function DeleteStorage() {
|
||||
getDelConfirmModal('ConfirmDeleteStorage', 'ConfirmDeleteStorageTitle', 'storageForm');
|
||||
}
|
||||
|
||||
function DeleteRole() {
|
||||
getDelConfirmModal('ConfirmDeleteRole', 'ConfirmDeleteRoleTitle', 'roleForm');
|
||||
}
|
||||
|
||||
function initPage() {
|
||||
|
||||
@@ -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,24 @@ 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');
|
||||
window.alert('Failed to delete report.');
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
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 +53,7 @@ function initPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
$j('#deleteConfirm').modal('show');
|
||||
getDelConfirmModal('ConfirmDeleteReport');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ var params =
|
||||
|
||||
// Called by bootstrap-table to retrieve zm event data
|
||||
function ajaxRequest(params) {
|
||||
if (document.visibilityState == 'hidden') {
|
||||
table.bootstrapTable('hideLoading');
|
||||
return;
|
||||
}
|
||||
if (params.data && params.data.filter) {
|
||||
params.data.advsearch = params.data.filter;
|
||||
delete params.data.filter;
|
||||
@@ -71,37 +75,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 +100,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 +112,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 +156,7 @@ function initPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
$j('#deleteConfirm').modal('show');
|
||||
getDelConfirmModal('ConfirmDeleteReport');
|
||||
});
|
||||
|
||||
table.bootstrapTable('resetSearch');
|
||||
|
||||
@@ -31,6 +31,10 @@ var params =
|
||||
|
||||
// Called by bootstrap-table to retrieve zm event data
|
||||
function ajaxRequest(params) {
|
||||
if (document.visibilityState == 'hidden') {
|
||||
table.bootstrapTable('hideLoading');
|
||||
return;
|
||||
}
|
||||
if (ajax) ajax.abort();
|
||||
|
||||
if ( params.data && params.data.filter ) {
|
||||
@@ -87,43 +91,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 +137,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 +256,7 @@ function initPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
$j('#deleteConfirm').modal('show');
|
||||
getDelConfirmModal('ConfirmDeleteSnapshots');
|
||||
});
|
||||
|
||||
// Update table links each time after new data is loaded
|
||||
|
||||
@@ -58,6 +58,10 @@ var params =
|
||||
|
||||
// Called by bootstrap-table to retrieve zm event data
|
||||
function ajaxRequest(params) {
|
||||
if (document.visibilityState == 'hidden') {
|
||||
eventListTable.bootstrapTable('hideLoading');
|
||||
return;
|
||||
}
|
||||
// Maintain legacy behavior by statically setting these parameters
|
||||
const data = params.data;
|
||||
data.order = 'desc';
|
||||
|
||||
@@ -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']);
|
||||
@@ -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 '<span class="term-value-wrapper">';
|
||||
echo htmlSelect('filterLevel', $levels,
|
||||
(isset($_SESSION['ZM_LOG_FILTER_LEVEL']) ? $_SESSION['ZM_LOG_FILTER_LEVEL'] : ''),
|
||||
echo htmlSelect('filterLevel', $levels, $selectedLevel,
|
||||
array('data-on-change'=>'filterLog', 'id'=>'filterLevel', 'class'=>'chosen'));
|
||||
#array('class'=>'form-control chosen', 'data-on-change'=>'filterLog'));
|
||||
echo '</span>';
|
||||
@@ -145,10 +146,10 @@ echo '</span>';
|
||||
data-maintain-meta-data="true"
|
||||
data-buttons-class="btn btn-normal"
|
||||
data-show-jump-to="true"
|
||||
data-auto-refresh="true"
|
||||
data-auto-refresh="<?php echo ((int)ZM_WEB_REFRESH_LOGS === 0) ? 'false' : 'true'?>"
|
||||
data-auto-refresh-silent="true"
|
||||
data-show-refresh="true"
|
||||
data-auto-refresh-interval="30"
|
||||
<?php echo ((int)ZM_WEB_REFRESH_LOGS !== 0) ? 'data-auto-refresh-interval="' . (int)ZM_WEB_REFRESH_LOGS .'"' : ''?>
|
||||
<?php if (canEdit('System')) { ?>
|
||||
data-click-to-select="true"
|
||||
<?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
|
||||
|
||||
@@ -41,14 +41,14 @@ getBodyTopHTML();
|
||||
<div id="page">
|
||||
<div id="content">
|
||||
<div id="inner-content">
|
||||
<form name="report" id="reportForm" method="post" action="?view=report&id=<?php echo $report_id ?>">
|
||||
<form name="report" id="reportForm" method="post" action="?view=report&id=<?php echo $report_id ?>" data-report_id="<?php echo $report_id ?>">
|
||||
<!-- Toolbar button placement and styling handled by bootstrap-tables -->
|
||||
<div id="toolbar">
|
||||
<button id="backBtn" type="button" class="btn btn-normal" data-toggle="tooltip" data-placement="top" title="<?php echo translate('Back') ?>" disabled><i class="fa fa-arrow-left"></i></button>
|
||||
<!--<button id="filterBtn" class="btn btn-normal" data-toggle="tooltip" data-placement="top" title="<?php echo translate('Filter') ?>"><i class="fa fa-filter"></i></button>-->
|
||||
<!--<button id="exportBtn" class="btn btn-normal" data-toggle="tooltip" data-placement="top" title="<?php echo translate('Export') ?>" disabled><i class="fa fa-external-link"></i></button>-->
|
||||
<button id="saveBtn" name="action" value="save" type="submit" class="btn btn-normal" data-toggle="tooltip" data-placement="top" title="<?php echo translate('Save') ?>"><i class="fa fa-save"></i></button>
|
||||
<button id="deleteBtn" name="action" value="delete" type="submit" class="btn btn-danger" data-toggle="tooltip" data-placement="top" title="<?php echo translate('Delete') ?>" disabled><i class="fa fa-trash"></i></button>
|
||||
<button id="deleteBtn" type="button" class="btn btn-danger" data-toggle="tooltip" data-placement="top" title="<?php echo translate('Delete') ?>" disabled><i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
<table class="major table table-sm">
|
||||
<tbody>
|
||||
@@ -94,7 +94,14 @@ var events = Array();
|
||||
|
||||
<?php
|
||||
require_once('includes/Filter.php');
|
||||
if (!$report->FilterId()) return;
|
||||
if (!$report->FilterId()) {
|
||||
echo '
|
||||
</script>
|
||||
</div>
|
||||
'.PHP_EOL;
|
||||
xhtmlFooter();
|
||||
return;
|
||||
}
|
||||
|
||||
$filter = new ZM\Filter($report->FilterId());
|
||||
if (count($user->unviewableMonitorIds())) {
|
||||
@@ -180,6 +187,6 @@ new Chart(document.getElementById("bar-chart"), {
|
||||
}
|
||||
});
|
||||
*/
|
||||
</script>
|
||||
</script>
|
||||
</div>
|
||||
<?php xhtmlFooter() ?>
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user