diff --git a/.github/workflows/depsreview.yaml b/.github/workflows/depsreview.yaml index ae5ae5989..02467c39a 100644 --- a/.github/workflows/depsreview.yaml +++ b/.github/workflows/depsreview.yaml @@ -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 diff --git a/db/triggers.sql b/db/triggers.sql index b6e64ee11..8b622a522 100644 --- a/db/triggers.sql +++ b/db/triggers.sql @@ -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; diff --git a/db/zm_create.sql.in b/db/zm_create.sql.in index 938162823..b63d68544 100644 --- a/db/zm_create.sql.in +++ b/db/zm_create.sql.in @@ -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 ( diff --git a/db/zm_update-1.39.10.sql b/db/zm_update-1.39.10.sql index ed74899af..e22dd7c5c 100644 --- a/db/zm_update-1.39.10.sql +++ b/db/zm_update-1.39.10.sql @@ -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; diff --git a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in index f7e7a64a8..492601db9 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in +++ b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in @@ -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', diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm index cebaa50ef..45bc7d966 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Database.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Database.pm @@ -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; } diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm index b1b5ec50f..e3c52ea4b 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm @@ -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()) { diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index 66d815886..a4929844d 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -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; }; diff --git a/scripts/zmfilter.pl.in b/scripts/zmfilter.pl.in index 39e5e29dd..ad6eb072e 100644 --- a/scripts/zmfilter.pl.in +++ b/scripts/zmfilter.pl.in @@ -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 diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index 2301e7f22..0265c4674 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -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} ) { diff --git a/src/zm_decoder_thread.cpp b/src/zm_decoder_thread.cpp index 6b08efd39..e5b6e61d9 100644 --- a/src/zm_decoder_thread.cpp +++ b/src/zm_decoder_thread.cpp @@ -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(); } diff --git a/src/zm_event.cpp b/src/zm_event.cpp index d941d5565..7838b72af 100644 --- a/src/zm_event.cpp +++ b/src/zm_event.cpp @@ -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 diff --git a/src/zm_eventstream.cpp b/src/zm_eventstream.cpp index 75e24cb96..ea7d5ace0 100644 --- a/src/zm_eventstream.cpp +++ b/src/zm_eventstream.cpp @@ -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) { diff --git a/src/zm_monitor.cpp b/src/zm_monitor.cpp index 8bb33f4e2..89cc72b64 100644 --- a/src/zm_monitor.cpp +++ b/src/zm_monitor.cpp @@ -2892,6 +2892,21 @@ bool Monitor::applyDeinterlacing(std::shared_ptr &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; diff --git a/src/zm_monitor.h b/src/zm_monitor.h index d4daf6db2..4778d25e9 100644 --- a/src/zm_monitor.h +++ b/src/zm_monitor.h @@ -767,6 +767,13 @@ class Monitor : public std::enable_shared_from_this { 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; } diff --git a/src/zm_monitor_onvif.cpp b/src/zm_monitor_onvif.cpp index 8413979c7..fd92c79f7 100644 --- a/src/zm_monitor_onvif.cpp +++ b/src/zm_monitor_onvif.cpp @@ -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) { diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index 68532864c..48ba5420f 100644 --- a/src/zm_monitorstream.cpp +++ b/src/zm_monitorstream.cpp @@ -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(); diff --git a/src/zm_signal.cpp b/src/zm_signal.cpp index e4586b95b..a2926a260 100644 --- a/src/zm_signal.cpp +++ b/src/zm_signal.cpp @@ -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 diff --git a/src/zm_stream.h b/src/zm_stream.h index 0422d8cc1..8c794ca00 100644 --- a/src/zm_stream.h +++ b/src/zm_stream.h @@ -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), diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index acdc9d231..4b4c239af 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -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(pkt->dts - last_fragment_start_dts_) + duration = static_cast(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(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(mfro[0]) << 24) + | (static_cast(mfro[1]) << 16) + | (static_cast(mfro[2]) << 8) + | static_cast(mfro[3]); + if (box_size == 16 + && mfro[4] == 'm' && mfro[5] == 'f' && mfro[6] == 'r' && mfro[7] == 'o') { + uint32_t mfra_size = (static_cast(mfro[12]) << 24) + | (static_cast(mfro[13]) << 16) + | (static_cast(mfro[14]) << 8) + | static_cast(mfro[15]); + if (mfra_size > 0 && static_cast(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( + 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) diff --git a/src/zm_videostore.h b/src/zm_videostore.h index 855227e88..d6e3a7749 100644 --- a/src/zm_videostore.h +++ b/src/zm_videostore.h @@ -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>> 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 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 &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) diff --git a/src/zm_zone.cpp b/src/zm_zone.cpp index 47e390030..ca810b75e 100644 --- a/src/zm_zone.cpp +++ b/src/zm_zone.cpp @@ -862,9 +862,10 @@ bool Zone::ParsePercentagePolygon(const char *poly_string, unsigned int width, u int32 px_x = static_cast(std::lround(pct_x * mon_w / 100.0)); int32 px_y = static_cast(std::lround(pct_y * mon_h / 100.0)); - // Clamp to monitor bounds - px_x = std::clamp(px_x, static_cast(0), static_cast(width)); - px_y = std::clamp(px_y, static_cast(0), static_cast(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(0), static_cast(width) - 1); + px_y = std::clamp(px_y, static_cast(0), static_cast(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); diff --git a/src/zms.cpp b/src/zms.cpp index d2e7d64f4..a8331bc5b 100644 --- a/src/zms.cpp +++ b/src/zms.cpp @@ -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 + // 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) ) { diff --git a/tests/zm_zone.cpp b/tests/zm_zone.cpp index a56d0cd45..42d6375e1 100644 --- a/tests/zm_zone.cpp +++ b/tests/zm_zone.cpp @@ -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(width)); + REQUIRE(v.x_ < static_cast(width)); REQUIRE(v.y_ >= 0); - REQUIRE(v.y_ <= static_cast(height)); + REQUIRE(v.y_ < static_cast(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(width), 0)); - REQUIRE(verts[2] == Vector2(static_cast(width), static_cast(height))); + REQUIRE(verts[1] == Vector2(static_cast(width) - 1, 0)); + REQUIRE(verts[2] == Vector2(static_cast(width) - 1, static_cast(height) - 1)); } // --- Auto-detect format tests --- diff --git a/version.txt b/version.txt index 2a293729c..104191edb 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.39.9 +1.39.10 diff --git a/web/ajax/add_monitors.php b/web/ajax/add_monitors.php index 07365e70f..0e466bcca 100644 --- a/web/ajax/add_monitors.php +++ b/web/ajax/add_monitors.php @@ -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())); ?> diff --git a/web/ajax/console.php b/web/ajax/console.php index aca7d36e8..e7d57ea4e 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -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'; diff --git a/web/ajax/device.php b/web/ajax/device.php index 35ca410ec..0277f77ae 100644 --- a/web/ajax/device.php +++ b/web/ajax/device.php @@ -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 ?> diff --git a/web/ajax/devices.php b/web/ajax/devices.php index 66b80124d..a6876b0fa 100644 --- a/web/ajax/devices.php +++ b/web/ajax/devices.php @@ -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.'"'); } ?> diff --git a/web/ajax/event.php b/web/ajax/event.php index 6b449f70c..c74ef90dc 100644 --- a/web/ajax/event.php +++ b/web/ajax/event.php @@ -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())); ?> diff --git a/web/ajax/events.php b/web/ajax/events.php index efd681999..64e2834fa 100644 --- a/web/ajax/events.php +++ b/web/ajax/events.php @@ -8,7 +8,7 @@ $data = array(); // if (!canView('Events')) - $message = 'Insufficient permissions for user '.$user->Username().'
'; + $message = 'Insufficient permissions for user '.validHtmlStr($user->Username()).'
'; if (empty($_REQUEST['task'])) { $message = 'Must specify a task
'; @@ -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)) { diff --git a/web/ajax/log.php b/web/ajax/log.php index 117cc82d9..83ef4645d 100644 --- a/web/ajax/log.php +++ b/web/ajax/log.php @@ -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(); diff --git a/web/ajax/stream.php b/web/ajax/stream.php index 6f8c77593..408174f12 100644 --- a/web/ajax/stream.php +++ b/web/ajax/stream.php @@ -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); diff --git a/web/api/app/Controller/AppController.php b/web/api/app/Controller/AppController.php index a36d22b96..81d1d1b39 100644 --- a/web/api/app/Controller/AppController.php +++ b/web/api/app/Controller/AppController.php @@ -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' ) { diff --git a/web/api/app/Controller/EventsController.php b/web/api/app/Controller/EventsController.php index cf81de629..de8265d3f 100644 --- a/web/api/app/Controller/EventsController.php +++ b/web/api/app/Controller/EventsController.php @@ -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; diff --git a/web/api/app/Model/Event.php b/web/api/app/Model/Event.php index 4949ac9d8..ee62acb7d 100644 --- a/web/api/app/Model/Event.php +++ b/web/api/app/Model/Event.php @@ -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 diff --git a/web/includes/Filter.php b/web/includes/Filter.php index ad7efabf6..8639504ce 100644 --- a/web/includes/Filter.php +++ b/web/includes/Filter.php @@ -1089,7 +1089,7 @@ class Filter extends ZM_Object { // This displays filters from the events page. // public function simple_widget() { - $html = '
'; + $html = '
'; $terms = $this->terms(); $attrTypes = $this->attrTypes(); $opTypes = $this->opTypes(); diff --git a/web/includes/Report.php b/web/includes/Report.php index d24aeeeb0..a728ac2c8 100644 --- a/web/includes/Report.php +++ b/web/includes/Report.php @@ -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); diff --git a/web/includes/auth.php b/web/includes/auth.php index 8bdbd4f08..a1d45c74b 100644 --- a/web/includes/auth.php +++ b/web/includes/auth.php @@ -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'"); diff --git a/web/includes/download_functions.php b/web/includes/download_functions.php index d369a92a3..c02174846 100644 --- a/web/includes/download_functions.php +++ b/web/includes/download_functions.php @@ -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); diff --git a/web/includes/functions.php b/web/includes/functions.php index 331934f13..4d5ad44a3 100644 --- a/web/includes/functions.php +++ b/web/includes/functions.php @@ -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. or incomplete... + $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; +} ?> diff --git a/web/includes/session.php b/web/includes/session.php index 996ae8d20..3139336af 100644 --- a/web/includes/session.php +++ b/web/includes/session.php @@ -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 diff --git a/web/js/EventStream.js b/web/js/EventStream.js index 24dc64ced..c9072ea24 100644 --- a/web/js/EventStream.js +++ b/web/js/EventStream.js @@ -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) { diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index baed62744..821e7ef58 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -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 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); diff --git a/web/lang/en_gb.php b/web/lang/en_gb.php index 82bd30cf3..3d00ba7e8 100644 --- a/web/lang/en_gb.php +++ b/web/lang/en_gb.php @@ -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.

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', diff --git a/web/lang/ru_ru.php b/web/lang/ru_ru.php index 4a8874d66..1250c862a 100644 --- a/web/lang/ru_ru.php +++ b/web/lang/ru_ru.php @@ -253,6 +253,7 @@ $SLANG = array( 'ConfiguredFor' => 'настроен на', 'ConfirmAction' => 'Подтвердите действие', 'ConfirmDeleteControl' => 'Внимание! Удаление элемента управления приведет к тому, что все мониторы, которые его используют, станут неуправляемыми.

Вы уверены, что хотите удалить?', + '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' => 'Нет', diff --git a/web/skins/classic/css/base/sidebar.css b/web/skins/classic/css/base/sidebar.css index f65c7e4b0..3d45caaed 100644 --- a/web/skins/classic/css/base/sidebar.css +++ b/web/skins/classic/css/base/sidebar.css @@ -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; } diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css index dbe0c5ed8..a366e233c 100644 --- a/web/skins/classic/css/base/skin.css +++ b/web/skins/classic/css/base/skin.css @@ -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; diff --git a/web/skins/classic/css/base/views/event.css b/web/skins/classic/css/base/views/event.css index 70ee82eb5..9253d72ca 100644 --- a/web/skins/classic/css/base/views/event.css +++ b/web/skins/classic/css/base/views/event.css @@ -120,6 +120,7 @@ height: 100%; } .eventStats { padding-left: 0; + z-index: 1; /* margin-right: 5px; */ } diff --git a/web/skins/classic/css/base/views/monitor.css b/web/skins/classic/css/base/views/monitor.css index 20a951392..699b833ed 100644 --- a/web/skins/classic/css/base/views/monitor.css +++ b/web/skins/classic/css/base/views/monitor.css @@ -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; } diff --git a/web/skins/classic/css/dark/views/log.css b/web/skins/classic/css/dark/views/log.css index ac4d7ded9..98d1577e3 100644 --- a/web/skins/classic/css/dark/views/log.css +++ b/web/skins/classic/css/dark/views/log.css @@ -39,7 +39,7 @@ tr.log-war td { } tr.log-dbg td { - color: #666666; + color: #aaaaaa; font-style: italic; } diff --git a/web/skins/classic/css/dark/views/monitor.css b/web/skins/classic/css/dark/views/monitor.css index cf15ee368..5dbc281aa 100644 --- a/web/skins/classic/css/dark/views/monitor.css +++ b/web/skins/classic/css/dark/views/monitor.css @@ -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); +} diff --git a/web/skins/classic/includes/config.php b/web/skins/classic/includes/config.php index 43bf6a632..09b3cfdc6 100644 --- a/web/skins/classic/includes/config.php +++ b/web/skins/classic/includes/config.php @@ -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 (%) diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index b979bf263..d27cbc5cf 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -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) { diff --git a/web/skins/classic/views/_options_roles.php b/web/skins/classic/views/_options_roles.php index 3f391f861..604669b9c 100644 --- a/web/skins/classic/views/_options_roles.php +++ b/web/skins/classic/views/_options_roles.php @@ -10,7 +10,7 @@ require_once('includes/User_Role.php');
- +
diff --git a/web/skins/classic/views/_options_servers.php b/web/skins/classic/views/_options_servers.php index 601599f10..0a4133397 100644 --- a/web/skins/classic/views/_options_servers.php +++ b/web/skins/classic/views/_options_servers.php @@ -7,7 +7,7 @@
- +
diff --git a/web/skins/classic/views/_options_storage.php b/web/skins/classic/views/_options_storage.php index 37233b729..1ee8e361a 100644 --- a/web/skins/classic/views/_options_storage.php +++ b/web/skins/classic/views/_options_storage.php @@ -7,7 +7,7 @@
- +
diff --git a/web/skins/classic/views/console.php b/web/skins/classic/views/console.php index 57e49ad53..8ac013361 100644 --- a/web/skins/classic/views/console.php +++ b/web/skins/classic/views/console.php @@ -294,7 +294,7 @@ echo $navbar ?> ); parseFilter($filter); $eventsLink = canView('Events') ? '?view='.ZM_WEB_EVENTS_VIEW.'&page=1'.$filter['querystring'] : ''; - echo '' .htmlspecialchars($eventCounts[$i]['title']) .''.PHP_EOL; diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 3b753fff4..1a2979faf 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -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()) ) DefaultVideo()) ?>" + title="DefaultVideo() ?>" download DefaultVideo() ? '' : 'style="display:none;"' ?> > @@ -354,9 +353,12 @@ if (file_exists($Event->Path().'/objdetect.jpg')) {
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: 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 } diff --git a/web/skins/classic/views/groups.php b/web/skins/classic/views/groups.php index 019814bd8..e4ce61a86 100644 --- a/web/skins/classic/views/groups.php +++ b/web/skins/classic/views/groups.php @@ -61,7 +61,7 @@ getBodyTopHTML(); add_circle - diff --git a/web/skins/classic/views/js/add_monitors.js b/web/skins/classic/views/js/add_monitors.js index d317be61e..8be64791f 100644 --- a/web/skins/classic/views/js/add_monitors.js +++ b/web/skins/classic/views/js/add_monitors.js @@ -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) { diff --git a/web/skins/classic/views/js/console.js b/web/skins/classic/views/js/console.js index cafb90f9b..2eb0f06bd 100644 --- a/web/skins/classic/views/js/console.js +++ b/web/skins/classic/views/js/console.js @@ -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 colEvents + // 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'] + '
' + - footer[period + 'EventDiskSpace'] + '
'; + 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('' + - contentHtml + ''); - } + var contentHtml = footer[period + 'Events'] + '
' + + footer[period + 'EventDiskSpace'] + '
'; + + 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('' + + contentHtml + ''); } + } 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({ diff --git a/web/skins/classic/views/js/controlcaps.js b/web/skins/classic/views/js/controlcaps.js index c9cfb945d..cf8a8aa2c 100644 --- a/web/skins/classic/views/js/controlcaps.js +++ b/web/skins/classic/views/js/controlcaps.js @@ -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'); diff --git a/web/skins/classic/views/js/devices.js b/web/skins/classic/views/js/devices.js index 0190f8672..8ad2e98b4 100644 --- a/web/skins/classic/views/js/devices.js +++ b/web/skins/classic/views/js/devices.js @@ -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 diff --git a/web/skins/classic/views/js/events.js b/web/skins/classic/views/js/events.js index 055df91d3..2ba102f8e 100644 --- a/web/skins/classic/views/js/events.js +++ b/web/skins/classic/views/js/events.js @@ -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; diff --git a/web/skins/classic/views/js/frames.js b/web/skins/classic/views/js/frames.js index 3e8517fd9..914984e07 100644 --- a/web/skins/classic/views/js/frames.js +++ b/web/skins/classic/views/js/frames.js @@ -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; diff --git a/web/skins/classic/views/js/groups.js b/web/skins/classic/views/js/groups.js index e7846d57e..8119da01a 100644 --- a/web/skins/classic/views/js/groups.js +++ b/web/skins/classic/views/js/groups.js @@ -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; } } diff --git a/web/skins/classic/views/js/log.js b/web/skins/classic/views/js/log.js index 3a118ddbd..a054a1124 100644 --- a/web/skins/classic/views/js/log.js +++ b/web/skins/classic/views/js/log.js @@ -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(); }); diff --git a/web/skins/classic/views/js/options.js b/web/skins/classic/views/js/options.js index dd71bae3f..ee51786d1 100644 --- a/web/skins/classic/views/js/options.js +++ b/web/skins/classic/views/js/options.js @@ -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() { diff --git a/web/skins/classic/views/js/report.js b/web/skins/classic/views/js/report.js index d6a81bcb4..f0e2962f6 100644 --- a/web/skins/classic/views/js/report.js +++ b/web/skins/classic/views/js/report.js @@ -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'); }); } diff --git a/web/skins/classic/views/js/reports.js b/web/skins/classic/views/js/reports.js index 900f059a7..d78ff3ad5 100644 --- a/web/skins/classic/views/js/reports.js +++ b/web/skins/classic/views/js/reports.js @@ -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'); diff --git a/web/skins/classic/views/js/snapshots.js b/web/skins/classic/views/js/snapshots.js index bdba87db6..4544b48e7 100644 --- a/web/skins/classic/views/js/snapshots.js +++ b/web/skins/classic/views/js/snapshots.js @@ -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 diff --git a/web/skins/classic/views/js/watch.js b/web/skins/classic/views/js/watch.js index c6262237c..5c10aece6 100644 --- a/web/skins/classic/views/js/watch.js +++ b/web/skins/classic/views/js/watch.js @@ -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'; diff --git a/web/skins/classic/views/log.php b/web/skins/classic/views/log.php index 72743762e..0cb9b561d 100644 --- a/web/skins/classic/views/log.php +++ b/web/skins/classic/views/log.php @@ -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 ''; -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 ''; @@ -145,10 +146,10 @@ echo ''; data-maintain-meta-data="true" data-buttons-class="btn btn-normal" data-show-jump-to="true" - data-auto-refresh="true" + data-auto-refresh="" data-auto-refresh-silent="true" data-show-refresh="true" - data-auto-refresh-interval="30" + data-click-to-select="true" diff --git a/web/skins/classic/views/montagereview.php b/web/skins/classic/views/montagereview.php index 0aacb5c41..20d0eee23 100644 --- a/web/skins/classic/views/montagereview.php +++ b/web/skins/classic/views/montagereview.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 diff --git a/web/skins/classic/views/report.php b/web/skins/classic/views/report.php index ff871fc1c..aefd1144d 100644 --- a/web/skins/classic/views/report.php +++ b/web/skins/classic/views/report.php @@ -41,14 +41,14 @@ getBodyTopHTML();
-
+
- +
@@ -94,7 +94,14 @@ var events = Array(); FilterId()) return; +if (!$report->FilterId()) { + echo ' + + + '.PHP_EOL; + xhtmlFooter(); + return; +} $filter = new ZM\Filter($report->FilterId()); if (count($user->unviewableMonitorIds())) { @@ -180,6 +187,6 @@ new Chart(document.getElementById("bar-chart"), { } }); */ - + diff --git a/web/views/view_hls.php b/web/views/view_hls.php index 9502b2f24..8b5c63a59 100644 --- a/web/views/view_hls.php +++ b/web/views/view_hls.php @@ -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 ); diff --git a/web/views/view_video.php b/web/views/view_video.php index dff454f44..ea825e2a3 100644 --- a/web/views/view_video.php +++ b/web/views/view_video.php @@ -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;