From 18d74ed7aca52c6fad860046c5ec9be739a86e45 Mon Sep 17 00:00:00 2001 From: Simpler1 Date: Sat, 3 Jun 2023 19:27:43 -0400 Subject: [PATCH] (feat): Tags fix(tag): Create tags on mobile chore(tags): Change TagName to Name chore(tags): eslint chore(tags): dbFetchAll to dbQuery for removetag chore(events): eslint (attempt 2) feat(tags): Better handling of keyboard fix(tags): Enter key for creating new tag fix(tags): Don't allow space as a tag name feat(tags): Delete tag if last assignment removed fix(tags): Increase height of dropdown in progress fix(Tags): Use T.Id on the events page dropdown fix(Tags): Remove $availableTags from events.php chore(sql): Formatting sql statements feat(Tags): Working OR on filters and events pages fix(filter): Populate availableTags chore(Tags): code formatting fix(tag): Add tag on create tag Fix(tags): Remove tag from available if last feat(tags): Add zm_update.sql fix(chosen): Undo css width fix(chosen): tags dropdown width fix(tags): dropdown over timeline fix(tags): Full width input fix(events): Refresh table on page show chore(filter): Clean up availableTags chore(event): Clean up available & selected Tags fix(event): Update available tags on remove fix(event): Remove hack for selected tags feat(tags): Blur input after adding tag doc(tags): Initial tags documentation fix(tags): Dark theme dropdown fix(tags): Dark theme for tags on input fix(tags): Dark theme for highlight in dropdown fix(tags): Populate filter tags droplist chore(): Bump zm_update to 1.37.42 chore(tags): Move mobile check to skin.js chore(tags): Comment debug statements fix(tags): Enter key to create tag on mobile Chome chore(tags): Space in 'All Tags' for translation Temporary commit to handle cookie expiration times chore(tags): Remove unnecessary Tag(s) from en_gb chore(): Cleanup unnecessary Error and Debug chore(): Resolve merge conflicts chore(): Address merge conflicts with master --- db/zm_update-1.37.44.sql | 47 ++++ docs/userguide/index.rst | 1 + docs/userguide/tags.rst | 38 +++ scripts/ZoneMinder/lib/ZoneMinder/Filter.pm | 21 +- web/ajax/event.php | 60 ++++- web/ajax/events.php | 77 +++++- web/ajax/status.php | 42 ++- web/ajax/tags.php | 24 ++ web/ajax/watch.php | 25 +- web/includes/Event.php | 1 + web/includes/Event_Data.php | 1 + web/includes/Filter.php | 116 ++++++++- web/includes/FilterTerm.php | 12 +- web/includes/database.php | 4 +- web/skins/classic/css/base/skin.css | 82 ++++++ web/skins/classic/css/dark/skin.css | 16 ++ web/skins/classic/js/skin.js | 9 + web/skins/classic/views/event.php | 7 + web/skins/classic/views/events.php | 8 +- web/skins/classic/views/filter.php | 5 + web/skins/classic/views/js/event.js | 274 +++++++++++++++++--- web/skins/classic/views/js/event.js.php | 3 + web/skins/classic/views/js/events.js | 9 +- web/skins/classic/views/js/filter.js | 15 ++ web/skins/classic/views/js/filter.js.php | 2 + web/skins/classic/views/timeline.php | 60 ++++- web/skins/classic/views/watch.php | 1 + 27 files changed, 901 insertions(+), 59 deletions(-) create mode 100644 db/zm_update-1.37.44.sql create mode 100644 docs/userguide/tags.rst create mode 100644 web/ajax/tags.php diff --git a/db/zm_update-1.37.44.sql b/db/zm_update-1.37.44.sql new file mode 100644 index 000000000..f7daa1936 --- /dev/null +++ b/db/zm_update-1.37.44.sql @@ -0,0 +1,47 @@ +-- +-- This adds Tags +-- + +SELECT 'Checking For Tags Table'; +SET @s = (SELECT IF( + (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.TABLES + WHERE table_name = 'Tags' + AND table_schema = DATABASE() + ) > 0, + "SELECT 'Tags table exists'", + "CREATE TABLE `Tags` ( + `Id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `Name` varchar(64) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL DEFAULT '', + `CreateDate` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `CreatedBy` int(10) unsigned, + `LastAssignedDate` dateTime, + PRIMARY KEY (`Id`), + UNIQUE(`Name`) + ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" +)); + +PREPARE stmt FROM @s; +EXECUTE stmt; + +SELECT 'Checking For Events_Tags Table'; +SET @s = (SELECT IF( + (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.TABLES + WHERE table_name = 'Events_Tags' + AND table_schema = DATABASE() + ) > 0, + "SELECT 'Events_Tags table exists'", + "CREATE TABLE `Events_Tags` ( + `TagId` bigint(20) unsigned NOT NULL, + `EventId` bigint(20) unsigned NOT NULL, + `AssignedDate` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `AssignedBy` int(10) unsigned, + PRIMARY KEY (`TagId`, `EventId`), + CONSTRAINT `Events_Tags_ibfk_1` FOREIGN KEY (`TagId`) REFERENCES `Tags` (`Id`) ON DELETE CASCADE, + CONSTRAINT `Events_Tags_ibfk_2` FOREIGN KEY (`EventId`) REFERENCES `Events` (`Id`) ON DELETE CASCADE + ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" +)); + +PREPARE stmt FROM @s; +EXECUTE stmt; diff --git a/docs/userguide/index.rst b/docs/userguide/index.rst index 905a87292..c7596e5aa 100644 --- a/docs/userguide/index.rst +++ b/docs/userguide/index.rst @@ -11,6 +11,7 @@ User Guide viewmonitors filterevents viewevents + tags options cameracontrol mobile diff --git a/docs/userguide/tags.rst b/docs/userguide/tags.rst new file mode 100644 index 000000000..9d9d91f05 --- /dev/null +++ b/docs/userguide/tags.rst @@ -0,0 +1,38 @@ +Tags +==== + +Tags are a simple quick way to categorize events so that you can identify them easier. + + +Creating New Tags +----------------- +Creating new tags is as easy as typing a word in the tags field (located just above the video). Pressing the space bar, comma, or Enter will create the new tag and add it to the event. + + +Adding Existing Tags to an Event +-------------------------------- +Clicking in the tags field will show a dropdown list of all of the available tags in descending order of when they were last added to an event. + +An existing tag can be added to the event by clicking it from the dropdown or by using the down/up arrow keys to highlight the desired tag and pressing Enter. + + will add the tag most recently added to any event to the current event. + +Typing in the tags field will filter the available tags to the ones that contain the text typed. + +.. note:: + Since you can use the right/left arrows to move between events when the tags field doesn't have focus, you can quickly add the most recent tag with and then move to the next event with Right Arrow. You can also use the Down Arrow to bring up the available tags to add a different tag before pressing the Right Arrow to move to the next event. + + +Removing Tags from an Event +--------------------------- +Pressing the "x" to the right of a tag will remove it from the event. When the tag is removed from the last event, the tag will be deleted from the available tags. + + +Filtering with Tags +=================== +Current Limitations +------------------- +1. Filtering for multiple tags is an OR search (Goal is to make this an AND search) +2. Resulting events only display the tags that were searched (Goal is to display all of the tags on the resulting events) +3. There is no way to search for events that don't have any tag (Goal is to provide search criteria for events with no tag) +4. There is no way to search for events with ONLY the specified tag or tags (Goal is to provide search criteria to search for events with ONLY the specified tag or tags) diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm b/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm index 3498ddae2..13a891d58 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm @@ -143,8 +143,21 @@ sub Sql { } my $filter_expr = ZoneMinder::General::jsonDecode($self->{Query_json}); - my $sql = 'SELECT E.*, unix_timestamp(E.StartDateTime) as Time - FROM Events as E'; + my $sql = ' + SELECT + E.*, + unix_timestamp(E.StartDateTime) + AS Time, + GROUP_CONCAT(T.Name SEPARATOR ", ") + FROM Events + AS E + LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId + LEFT JOIN Tags + AS T + ON T.Id = ET.TagId + '; if ( $filter_expr->{terms} ) { foreach my $term ( @{$filter_expr->{terms}} ) { @@ -164,6 +177,8 @@ sub Sql { if ( $term->{attr} eq 'AlarmedZoneId' ) { $term->{op} = 'EXISTS'; + } elsif ( $term->{attr} eq 'Tags' ) { + $self->{Sql} .= 'T.Name'; } elsif ( $term->{attr} =~ /^Monitor/ ) { $sql = 'SELECT E.*, unix_timestamp(E.StartDateTime) as Time, M.Name as MonitorName FROM Events as E INNER JOIN Monitors as M on M.Id = E.MonitorId'; @@ -368,6 +383,8 @@ sub Sql { my $sort_column = ''; if ( $filter_expr->{sort_field} eq 'Id' ) { $sort_column = 'E.Id'; + } elsif ( $filter_expr->{sort_field} eq 'Tag' ) { + $sort_column = 'T.Name'; } elsif ( $filter_expr->{sort_field} eq 'MonitorName' ) { $sql = 'SELECT E.*, unix_timestamp(E.StartDateTime) as Time, M.Name as MonitorName FROM Events as E INNER JOIN Monitors as M on M.Id = E.MonitorId'; diff --git a/web/ajax/event.php b/web/ajax/event.php index 1c2a003ea..c24d4d2dd 100644 --- a/web/ajax/event.php +++ b/web/ajax/event.php @@ -15,7 +15,26 @@ if ( canView('Events') or canView('Snapshots') ) { } elseif ( empty($_REQUEST['scale']) ) { ajaxError('Video Generation Failure, no scale given'); } else { - $sql = 'SELECT E.*,M.Name AS MonitorName,M.DefaultRate,M.DefaultScale FROM Events AS E INNER JOIN Monitors AS M ON E.MonitorId = M.Id WHERE E.Id = ?'.monitorLimitSql(); + $sql = ' + SELECT + E.*, + M.Name + AS MonitorName,M.DefaultRate,M.DefaultScale, + GROUP_CONCAT(T.Name SEPARATOR ", ") + AS Tags + FROM Events + AS E + INNER JOIN Monitors + AS M + ON E.MonitorId = M.Id + LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId + LEFT JOIN Tags + AS T + ON T.Id = ET.TagId + WHERE + E.Id = ?'.monitorLimitSql(); if ( !($event = dbFetchOne($sql, NULL, array( $_REQUEST['id']))) ) { ajaxError('Video Generation Failure, Unable to load event'); } else { @@ -167,6 +186,45 @@ if ( canEdit('Events') ) { ajaxResponse(array('refreshEvent'=>false, 'refreshParent'=>true)); } break; + case 'getselectedtags' : + $sql = ' + SELECT + T.* + FROM Tags + AS T + INNER JOIN Events_Tags + AS ET + ON ET.TagId = T.Id + WHERE ET.EventId = ? + '; + $values = array($_REQUEST['id']); + $response = dbFetchAll($sql, NULL, $values); + ajaxResponse(array('response'=>$response)); + break; + case 'addtag' : + $sql = 'INSERT INTO Events_Tags (TagId, EventId, AssignedBy) VALUES (?, ?, ?)'; + $values = array($_REQUEST['tid'], $_REQUEST['id'], $user->Id()); + $response = dbFetchAll($sql, NULL, $values); + + $sql = 'UPDATE Tags SET LastAssignedDate = NOW() WHERE Id = ?'; + $values = array($_REQUEST['tid']); + dbFetchAll($sql, NULL, $values); + + ajaxResponse(array('response'=>$response)); + break; + case 'removetag' : + $tagId = $_REQUEST['tid']; + dbQuery('DELETE FROM Events_Tags WHERE TagId = ? AND EventId = ?', array($tagId, $_REQUEST['id'])); + $sql = "SELECT * FROM Events_Tags WHERE TagId = $tagId"; + $rowCount = dbNumRows($sql); + if ($rowCount < 1) { + $sql = 'DELETE FROM Tags WHERE Id = ?'; + $values = array($_REQUEST['tid']); + $response = dbNumRows($sql, $values); + ajaxResponse(array('response'=>$response)); + } + ajaxResponse(); + break; } // end switch action } // end if canEdit('Events') diff --git a/web/ajax/events.php b/web/ajax/events.php index 532e12212..853640627 100644 --- a/web/ajax/events.php +++ b/web/ajax/events.php @@ -32,7 +32,10 @@ require_once('includes/Filter.php'); $filter = isset($_REQUEST['filter']) ? ZM\Filter::parse($_REQUEST['filter']) : new ZM\Filter(); if (count( $user->unviewableMonitorIds())) { $filter = $filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=>'IN', 'val'=>$user->viewableMonitorIds())); + // $filter = $filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=>'IN', 'val'=>'5')); } +// TODO: Why is $user->viewableMonitorIds() returning $user->unviewableMonitorIds() +// Error('$user->viewableMonitorIds(): '.print_r($user->viewableMonitorIds())); if (!empty($_REQUEST['StartDateTime'])) { $filter->addTerm(array('cnj'=>'and', 'attr'=>'StartDateTime', 'op'=> '>=', 'val'=>$_REQUEST['StartDateTime'])); } @@ -42,6 +45,9 @@ if (!empty($_REQUEST['EndDateTime'])) { if (!empty($_REQUEST['MonitorId'])) { $filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=> '=', 'val'=>$_REQUEST['MonitorId'])); } +if (!empty($_REQUEST['Tag'])) { + $filter->addTerm(array('cnj'=>'and', 'attr'=>'Tag', 'op'=>'=', 'val'=>'')); +} // Search contains a user entered string to search on $search = isset($_REQUEST['search']) ? $_REQUEST['search'] : ''; @@ -176,12 +182,14 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim $columns = array('Id', 'MonitorId', 'StorageId', 'Name', 'Cause', 'StartDateTime', 'EndDateTime', 'Length', 'Frames', 'AlarmFrames', 'TotScore', 'AvgScore', 'MaxScore', 'Archived', 'Emailed', 'Notes', 'DiskSpace'); // The names of columns shown in the event view that are NOT dB columns in the database - $col_alt = array('Monitor', 'Storage'); + $col_alt = array('Monitor', 'Tags', 'Storage'); if ( $sort != '' ) { if (!in_array($sort, array_merge($columns, $col_alt))) { ZM\Error('Invalid sort field: ' . $sort); $sort = ''; + } else if ( $sort == 'Tags' ) { + $sort = 'T.Name'; } else if ( $sort == 'Monitor' ) { $sort = 'M.Name'; } else if ($sort == 'EndDateTime') { @@ -197,13 +205,46 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim $values = array(); $likes = array(); + // Error($filter->sql()); $where = $filter->sql()?' WHERE ('.$filter->sql().')' : ''; - $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, - M.Name AS Monitor'; - $sql = 'SELECT ' .$col_str. ' FROM `Events` AS E INNER JOIN Monitors AS M ON E.MonitorId = M.Id'.$where.($sort?' ORDER BY '.$sort.' '.$order:''); + $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, + M.Name + AS Monitor, + GROUP_CONCAT(T.Name SEPARATOR ", ") + AS Tags'; + + $sql = ' + SELECT + ' .$col_str. ' + FROM `Events` + AS E + INNER JOIN Monitors + AS M + ON E.MonitorId = M.Id + LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId + LEFT JOIN Tags + AS T + ON T.Id = ET.TagId + '.$where.' + GROUP BY E.Id + '.($sort?' ORDER BY '.$sort.' '.$order:''); + if ($filter->limit() and !count($filter->post_sql_conditions())) { $sql .= ' LIMIT '.$filter->limit(); } @@ -243,6 +284,8 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim $filtered_rows = null; + ZM\Debug('$advsearch: ' . $advsearch ); + ZM\Debug('$search: ' . $search); if (count($advsearch) or $search != '') { $search_filter = new ZM\Filter(); $search_filter = $search_filter->addTerm(array('cnj'=>'and', 'attr'=>'Id', 'op'=>'IN', 'val'=>$event_ids)); @@ -270,7 +313,24 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim $search_filter = $search_filter->addTerms($terms, array('obr'=>1, 'cbr'=>1, 'op'=>'OR')); } # end if search - $sql = 'SELECT ' .$col_str. ' FROM `Events` AS E INNER JOIN Monitors AS M ON E.MonitorId = M.Id WHERE '.$search_filter->sql().' ORDER BY ' .$sort. ' ' .$order; + $sql = 'SELECT ' .$col_str. ' + FROM `Events` + AS E + INNER JOIN Monitors + AS M + ON E.MonitorId = M.Id + LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId + LEFT JOIN Tags + AS T + ON T.Id = ET.TagId + WHERE + '.$search_filter->sql().' + ORDER BY + ' .$sort. ' + ' .$order; + $filtered_rows = dbFetchAll($sql); ZM\Debug('Have ' . count($filtered_rows) . ' events matching search filter: '.$sql); } else { @@ -303,6 +363,7 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim $row['Archived'] = $row['Archived'] ? translate('Yes') : translate('No'); $row['Emailed'] = $row['Emailed'] ? translate('Yes') : translate('No'); $row['Cause'] = validHtmlStr($row['Cause']); + $row['Tags'] = validHtmlStr($row['Tags']); $row['StartDateTime'] = $dateTimeFormatter->format(strtotime($row['StartDateTime'])); $row['EndDateTime'] = $row['EndDateTime'] ? $dateTimeFormatter->format(strtotime($row['EndDateTime'])) : null; $row['Storage'] = ( $row['StorageId'] and isset($StorageById[$row['StorageId']]) ) ? $StorageById[$row['StorageId']]->Name() : 'Default'; @@ -320,7 +381,7 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim } else { $data['total'] = $data['totalNotFiltered']; } -ZM\Debug("Done"); + ZM\Debug("Done"); return $data; } ?> diff --git a/web/ajax/status.php b/web/ajax/status.php index 72818a1e1..3b3197463 100644 --- a/web/ajax/status.php +++ b/web/ajax/status.php @@ -472,7 +472,25 @@ function getNearEvents() { $sortOrder = 'ASC'; } - $sql = 'SELECT E.Id AS Id, E.StartDateTime AS StartDateTime FROM Events AS E INNER JOIN Monitors AS M ON E.MonitorId = M.Id WHERE '.$sortColumn.' '.($sortOrder=='ASC'?'<=':'>=').' \''.$event[$_REQUEST['sort_field']].'\''; + $sql = ' + SELECT + E.Id + AS Id, + E.StartDateTime + AS StartDateTime + FROM Events + AS E + INNER JOIN Monitors + AS M + ON E.MonitorId = M.Id + LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId + LEFT JOIN Tags + AS T + ON T.Id = ET.TagId + WHERE '.$sortColumn.' + '.($sortOrder=='ASC'?'<=':'>=').' \''.$event[$_REQUEST['sort_field']].'\''; if ($filter->sql()) { $sql .= ' AND ('.$filter->sql().')'; } @@ -490,11 +508,29 @@ function getNearEvents() { $prevEvent = dbFetchNext($result); - $sql = 'SELECT E.Id AS Id, E.StartDateTime AS StartDateTime FROM Events AS E INNER JOIN Monitors AS M ON E.MonitorId = M.Id WHERE '.$sortColumn .' '.($sortOrder=='ASC'?'>=':'<=').' \''.$event[$_REQUEST['sort_field']].'\''; + $sql = ' + SELECT + E.Id + AS Id, + E.StartDateTime + AS StartDateTime + FROM Events + AS E + INNER JOIN Monitors + AS M + ON E.MonitorId = M.Id + LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId + LEFT JOIN Tags + AS T + ON T.Id = ET.TagId + WHERE '.$sortColumn.' + '.($sortOrder=='ASC'?'>=':'<=').' \''.$event[$_REQUEST['sort_field']].'\''; if ($filter->sql()) { $sql .= ' AND ('.$filter->sql().')'; } - $sql .=' AND E.Id>'.$event['Id'] . ' ORDER BY '.$sortColumn.' '.($sortOrder=='ASC'?'ASC':'DESC'); + $sql .= ' AND E.Id>'.$event['Id'] . ' ORDER BY '.$sortColumn.' '.($sortOrder=='ASC'?'ASC':'DESC'); if ( $sortColumn != 'E.Id' ) { # When sorting by starttime, if we have two events with the same starttime (different monitors) then we should sort secondly by Id $sql .= ', E.Id ASC'; diff --git a/web/ajax/tags.php b/web/ajax/tags.php new file mode 100644 index 000000000..11a495d5e --- /dev/null +++ b/web/ajax/tags.php @@ -0,0 +1,24 @@ +$dbFetchResult)); + break; + case 'createtag' : + $sql = 'INSERT INTO Tags (Name, CreatedBy) VALUES (?, ?) RETURNING Id'; + $values = array($_REQUEST['tname'], $user->Id()); + $result = dbFetchAll($sql, NULL, $values); + $r = $result[0]; + + $sql = 'SELECT * FROM Tags WHERE Id = ?'; + $values = array($r['Id']); + $dbFetchResult = dbFetchAll($sql, NULL, $values); + + ajaxResponse(array('response'=>$dbFetchResult)); + break; +} // end switch action + +ajaxError('Unrecognised action '.$_REQUEST['action']); +?> diff --git a/web/ajax/watch.php b/web/ajax/watch.php index 8f966b583..8c5b0667f 100644 --- a/web/ajax/watch.php +++ b/web/ajax/watch.php @@ -47,8 +47,29 @@ $order = (isset($_REQUEST['order']) and (strtolower($_REQUEST['order']) == 'asc' // $where = 'WHERE MonitorId = '.$mid; -$col_str = 'E.*'; -$sql = 'SELECT ' .$col_str. ' FROM `Events` AS E '.$where.' ORDER BY '.$sort.' '.$order. ' LIMIT ?'; + +$col_str = ' +E.*, +T.Name + AS Tags '; + +$sql = ' +SELECT + ' .$col_str. ' +FROM `Events` + AS E +LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId +LEFT JOIN Tags + AS T + ON T.Id = ET.TagId +'.$where.' +ORDER BY +'.$sort.' +'.$order.' +LIMIT ?'; + ZM\Debug('Calling the following sql query: ' .$sql); $rows = dbQuery($sql, array($limit)); diff --git a/web/includes/Event.php b/web/includes/Event.php index e4440e13a..55792d6dd 100644 --- a/web/includes/Event.php +++ b/web/includes/Event.php @@ -14,6 +14,7 @@ class Event extends ZM_Object { 'StorageId' => null, 'SecondaryStorageId' => null, 'Cause' => '', + 'Tags' => array(), 'StartDateTime' => null, 'EndDateTime' => null, 'Width' => null, diff --git a/web/includes/Event_Data.php b/web/includes/Event_Data.php index 3bd3503f2..dfd67a72f 100644 --- a/web/includes/Event_Data.php +++ b/web/includes/Event_Data.php @@ -11,6 +11,7 @@ class Event_Data extends ZM_Object { 'EventId' => null, 'FrameId' => null, 'MonitorId' => null, + 'Tags' => array(), 'TimeStamp' => 0, 'Data' => '', ); diff --git a/web/includes/Filter.php b/web/includes/Filter.php index be64b81db..e54919382 100644 --- a/web/includes/Filter.php +++ b/web/includes/Filter.php @@ -47,12 +47,19 @@ class Filter extends ZM_Object { public $_pre_sql_conditions; public $_post_sql_conditions; protected $_Terms; + public $availableTags = array(); public function sql() { + // Debug('$_Terms: '. $_Terms); + // Debug('$_sql: ' . $_sql); + // Debug('$this->_sql: ' . $this->_sql); if (!isset($this->_sql)) { $this->_sql = ''; foreach ( $this->FilterTerms() as $term ) { + // Error($term->valid()); if ($term->valid()) { + // Debug('$this->_sql: ' . $this->_sql); + // Error($this-cnj); if (!$this->_sql) { if ($term->cnj) unset($term->cnj); } else { @@ -60,10 +67,12 @@ class Filter extends ZM_Object { } $this->_sql .= $term->sql(); } else { - Debug('Term is not valid '.$term->to_string()); + // Debug('Term is not valid '.$term->to_string()); } + // Debug('$term->_sql: ' . $term->_sql); } # end foreach term } + // Debug('$this->_sql: ' . $this->_sql); return $this->_sql; } @@ -117,19 +126,25 @@ class Filter extends ZM_Object { return $this->_post_sql_conditions; } - public function FilterTerms() { + public function FilterTerms() { + // echo '
Terms before: '; print_r($this->Terms); echo '
'; if (!isset($this->Terms)) { $this->Terms = array(); $_terms = $this->terms(); + // echo '
$_terms: '; print_r($_terms); echo '
'; if ($_terms) { for ($i=0; $i < count($_terms); $i++) { + // Error($i); + // Error($_terms[$i]); if (isset($_terms[$i])) { $term = new FilterTerm($this, $_terms[$i], $i); $this->Terms[] = $term; + // Error($this->Terms[]); } } # end foreach term } } + // echo '
Terms after: '; print_r($this->Terms); echo '
'; return $this->Terms; } @@ -204,7 +219,7 @@ class Filter extends ZM_Object { return ZM_Object::_find_one(get_class(), $parameters, $options); } - public function terms( ) { + public function terms() { if ( func_num_args() ) { $Query = $this->Query(); $Query['terms'] = func_get_arg(0); @@ -260,6 +275,7 @@ class Filter extends ZM_Object { $Query['limit'] = func_get_arg(0); $this->Query($Query); } + // Error($this->Query()['limit']); if ( isset( $this->Query()['limit'] ) ) return $this->{'Query'}['limit']; return 0; @@ -414,8 +430,12 @@ class Filter extends ZM_Object { } } if ( !empty($term['attr']) ) { + // Error($term['attr']); $dtAttr = false; switch ( $term['attr']) { + case 'Tags': + $sqlValue = 'T.Name'; + break; case 'Group': $sqlValue = 'M.Id'; case 'Monitor': @@ -586,6 +606,8 @@ class Filter extends ZM_Object { case 'Group': $value = Group::get_group_sql($value); break; + case 'Tags': + // Error($term['attr']); case 'MonitorName': case 'Name': case 'Cause': @@ -719,10 +741,30 @@ class Filter extends ZM_Object { } $where = $this->sql() ? ' WHERE ('.$this->sql().')' : ''; + // $where = ' WHERE ( T.Name = "Bird" )'; $sort = $this->sort_field() ? $this->sort_field() .' '.($this->sort_asc() ? 'ASC' : 'DESC') : ''; - $col_str = 'E.*, M.Name AS Monitor'; - $sql = 'SELECT ' .$col_str. ' FROM `Events` AS E INNER JOIN Monitors AS M ON E.MonitorId = M.Id'.$where.($sort?' ORDER BY '.$sort:''); + $col_str = ' + E.*, + M.Name + AS Monitor'; + + $sql = ' + SELECT + ' .$col_str. ' + FROM `Events` + AS E + INNER JOIN Monitors + AS M + ON E.MonitorId = M.Id + LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId + LEFT JOIN Tags + AS T + ON T.Id = ET.TagId + '.$where.($sort?' ORDER BY '.$sort:''); + if ($this->limit() and !count($this->pre_sql_conditions()) and !count($this->post_sql_conditions())) { $sql .= ' LIMIT '.$this->limit(); } @@ -785,6 +827,7 @@ class Filter extends ZM_Object { 'StorageId' => translate('AttrStorageArea'), 'StorageServerId' => translate('AttrStorageServer'), 'SystemLoad' => translate('AttrSystemLoad'), + 'Tags' => translate('Tags'), 'TotScore' => translate('AttrTotalScore'), ); } @@ -896,6 +939,10 @@ class Filter extends ZM_Object { } } } + // $availableTags = array(); + foreach ( dbFetchAll('SELECT Id, Name FROM Tags ORDER BY LastAssignedDate DESC') AS $tag ) { + $availableTags[$tag['Id']] = validHtmlStr($tag['Name']); + } for ($i=0; $i < count($terms); $i++) { $term = $terms[$i]; @@ -919,6 +966,23 @@ class Filter extends ZM_Object { if ( $term['attr'] == 'Archived' ) { $html .= ''.translate('OpEq').''.PHP_EOL; $html .= ''.htmlSelect("filter[Query][terms][$i][val]", $archiveTypes, $term['val']).''.PHP_EOL; + + + + } else if ( $term['attr'] == 'Tags') { + // Error($term['attr']); + $html .= ''.htmlSelect("filter[Query][terms][$i][op]", $opTypes, $term['op']).''.PHP_EOL; + $options = ['class'=>'chosen', 'multiple'=>'multiple']; + $selected = explode(',', $term['val']); + if (count($selected) == 1 and !$selected[0]) { + $selected = null; + } + $html .= ''.htmlSelect("filter[Query][terms][$i][val]", $availableTags, $selected, $options).''.PHP_EOL; + // ZM\Debug('$availableTags: '.$availableTags); + // ZM\Debug('$selected: '.$selected); + + + } else if ( $term['attr'] == 'DateTime' || $term['attr'] == 'StartDateTime' || $term['attr'] == 'EndDateTime') { $html .= ''.htmlSelect("filter[Query][terms][$i][op]", $opTypes, $term['op']).''.PHP_EOL; $html .= ''.PHP_EOL; @@ -983,6 +1047,7 @@ class Filter extends ZM_Object { '; } # end foreach term + // Error($html); return $html; } # end function widget() @@ -1006,6 +1071,10 @@ class Filter extends ZM_Object { foreach ( $Servers as $server ) { $servers[$server->Id()] = validHtmlStr($server->Name()); } + // $availableTags = array(); + foreach ( dbFetchAll('SELECT Id, Name FROM Tags ORDER BY LastAssignedDate DESC') AS $tag ) { + $availableTags[$tag['Id']] = validHtmlStr($tag['Name']); + } for ($i=0; $i < count($terms); $i++) { $term = $terms[$i]; @@ -1034,6 +1103,39 @@ class Filter extends ZM_Object { if ( $term['attr'] == 'Archived' ) { $html .= htmlSelect("filter[Query][terms][$i][val]", $archiveTypes, $term['val']).PHP_EOL; + + + + } else if ( $term['attr'] == 'Tags' ) { + $selected = explode(',', $term['val']); + // echo '
selected: '; print_r($selected); echo '
'; + if (count($selected) == 1 and !$selected[0]) { + $selected = null; + } + $options = ['class'=>'chosen', 'multiple'=>'multiple', 'data-placeholder'=>translate('All Tags')]; + if (isset($term['cookie'])) { + $options['data-cookie'] = $term['cookie']; + + if (!$selected and isset($_COOKIE[$term['cookie']]) and $_COOKIE[$term['cookie']]) + $selected = explode(',', $_COOKIE[$term['cookie']]); + } + // These echo statements print these variables at the top of the view. + // echo '
availableTags: '; print_r($availableTags); echo '
'; + // echo '
selected: '; print_r($selected); echo '
'; + // echo '
options: '; print_r($options); echo '
'; + + $html .= ''.htmlSelect("filter[Query][terms][$i][val]", $availableTags, $selected, $options).''.PHP_EOL; + // $html .= ''.htmlSelect("filter[Query][terms][$i][val]", array_combine($availableTags,$availableTags), $term['val'], + // $options).''.PHP_EOL; + // $html .= ''.htmlSelect("filter[Query][terms][$i][val]", $availableTags, $term['val'], $options).''.PHP_EOL; + + // Debug doesn't work here. + // Debug('$availableTags: '.$availableTags); + // Debug('$selected: '.$selected); + // Debug('$options: '.$options); + + + } else if ( $term['attr'] == 'DateTime' || $term['attr'] == 'StartDateTime' || $term['attr'] == 'EndDateTime') { $html .= 'Monitor selected: '; print_r($selected); echo ''; if (count($selected) == 1 and !$selected[0]) { $selected = null; } @@ -1145,8 +1248,9 @@ class Filter extends ZM_Object { $html .= ''; } # end foreach term $html .= ''; + // Error($html); return $html; - } # end function widget() + } # end function simple_widget() public function has_term($attr, $op=null) { foreach ($this->terms() as $term) { diff --git a/web/includes/FilterTerm.php b/web/includes/FilterTerm.php index e5b63b798..6fee3bbb0 100644 --- a/web/includes/FilterTerm.php +++ b/web/includes/FilterTerm.php @@ -101,6 +101,7 @@ class FilterTerm { case 'DiskPercent': $value = ''; break; + case 'Tags': case 'MonitorName': case 'Name': case 'Cause': @@ -293,6 +294,8 @@ class FilterTerm { case 'StateId': case 'Archived': return $this->tablename.'.'.$this->attr; + case 'Tags': + return 'T.Id'; default : return $this->tablename.'.'.$this->attr; } @@ -444,6 +447,11 @@ class FilterTerm { Error('Failed evaluating '.$string_to_eval); return false; } + } else if ( $this->attr == 'Tags' ) { + // Debug('TODO: Complete this post_sql_condition for Tags val: ' . $this->val . ' op: ' . $this->op . ' id: ' . $this->id); + // Debug(print_r($this, true)); + // Debug(print_r($event, true)); + return true; } else { Error('testing unsupported post term ' . $this->attr); } @@ -460,7 +468,7 @@ class FilterTerm { } public function is_post_sql() { - if ( $this->attr == 'ExistsInFileSystem' ) { + if ( $this->attr == 'ExistsInFileSystem' || $this->attr == 'Tags') { return true; } return false; @@ -515,6 +523,7 @@ class FilterTerm { 'Notes', 'StateId', 'Archived', + 'Tags', # The following are for snapshots 'CreatedOn', 'Description' @@ -536,6 +545,7 @@ class FilterTerm { return false; break; case 'Archived' : + case 'Tags' : case 'Monitor' : case 'MonitorId' : case 'ServerId' : diff --git a/web/includes/database.php b/web/includes/database.php index e22d14ebf..8568ec4c0 100644 --- a/web/includes/database.php +++ b/web/includes/database.php @@ -224,8 +224,8 @@ function dbFetchNext($result, $col=false) { return false; } -function dbNumRows( $sql ) { - $result = dbQuery($sql); +function dbNumRows($sql, $params=NULL) { + $result = dbQuery($sql, $params); return $result->rowCount(); } diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css index 407c3066f..a12a854be 100644 --- a/web/skins/classic/css/base/skin.css +++ b/web/skins/classic/css/base/skin.css @@ -709,6 +709,7 @@ ul.nav.nav-pills.flex-column { .chosen-container { text-align: left; +min-width: 11em; } .chosen-single, @@ -884,3 +885,84 @@ a.flip { button .material-icons { font-size: 18px; } + +/* input[type="search"]::-webkit-search-cancel-button { + display: none; +} */ + +.tags-container { + display: flex; + flex-wrap: wrap; + border: 1px solid; + border-color: #ccc; + border-radius: 4px; + min-height: 35px; + margin: 0.25rem 1rem 0.25rem 1rem; +} + +.tag { + background-color: #F0F0F0; + border-radius: 12px; + padding: 4px 8px; + margin: 4px; + display: flex; + align-items: center; +} + +.tag-text { + margin-right: 4px; +} + +.tag-remove { + cursor: pointer; + color: red; +} + +.tag-input { + height: 30px; + margin-left: 8px; + border: none; + width: 100%; +} + +.tag-input:focus { + outline: none; +} + +.tag-dropdown { + vertical-align: center; + margin-right: 8px; + display: inline-block; + flex-grow: 2; +} + +.tag-dropdown-content { + display: none; + position: absolute; + background-color: #f9f9f9; + min-width: 160px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 15; + padding: 4px 0; + overflow-y: auto; + max-height: 800px; +} + +.tag-dropdown-item { + cursor: pointer; + padding: 4px 8px; + margin-bottom: 4px; +} + +.tag-dropdown-item:hover { + background-color: #dfdfdf; +} + +.hlight{ + background:#dfdfdf; +} + +.tag-input:focus + .tag-dropdown-content, +.tag-input + .tag-dropdown-content:active { + display: block; +} diff --git a/web/skins/classic/css/dark/skin.css b/web/skins/classic/css/dark/skin.css index 63dea4651..b041a886a 100644 --- a/web/skins/classic/css/dark/skin.css +++ b/web/skins/classic/css/dark/skin.css @@ -230,3 +230,19 @@ ul.nav.nav-pills.flex-column { .thead-highlight { background-color:#485460; } + +.tag { + background-color: #444444; +} + +.tag-dropdown-content { + background-color: #333333; +} + +.tag-dropdown-item:hover { + background-color: #222222; +} + +.hlight{ + background:#444444; +} diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index 6f00dfb81..61a55c0e6 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -1040,6 +1040,15 @@ function post(path, params, method='post') { form.submit(); } +function isMobile() { + var result = false; + // device detection + if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|ipad|iris|kindle|Android|Silk|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(navigator.userAgent) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(navigator.userAgent.substring(0, 4))) { + result = true; + } + return result; +} + const font = new FontFaceObserver('Material Icons', {weight: 400}); font.load().then(function() { $j('.material-icons').css('display', 'inline-block'); diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 9d5738215..4541e0804 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -207,6 +207,13 @@ if ( $Event->Id() and !file_exists($Event->Path()) ) Id() ) { ?> +
+
+ + +
+
+
diff --git a/web/skins/classic/views/events.php b/web/skins/classic/views/events.php index cc8fb82d9..3e8981505 100644 --- a/web/skins/classic/views/events.php +++ b/web/skins/classic/views/events.php @@ -59,7 +59,12 @@ if (!$filter->Id()) { 'val' => $num_terms ? '' : (isset($_COOKIE['eventsEndDateTimeEnd']) ? $_COOKIE['eventsEndDateTimeEnd'] : ''), 'cnj' => 'and', 'cookie'=>'eventsEndDateTimeEnd')); } - $filter->sort_terms(['Group','Monitor','StartDateTime','EndDateTime']); + if (!$filter->has_term('Tags')) { + $filter->addTerm(array('attr' => 'Tags', 'op' => '=', + 'val' => $num_terms ? '' : (isset($_COOKIE['eventsTags']) ? $_COOKIE['eventsTags'] : ''), + 'cnj' => 'and', 'cookie'=>'eventsTags')); + } + $filter->sort_terms(['Group','Monitor','StartDateTime','EndDateTime','Tags']); #$filter->addTerm(array('cnj'=>'and', 'attr'=>'AlarmFrames', 'op'=> '>', 'val'=>'10')); #$filter->addTerm(array('cnj'=>'and', 'attr'=>'StartDateTime', 'op'=> '<=', 'val'=>'')); } @@ -149,6 +154,7 @@ data-min-width="562" + diff --git a/web/skins/classic/views/filter.php b/web/skins/classic/views/filter.php index 93e3c5d05..4d88a2339 100644 --- a/web/skins/classic/views/filter.php +++ b/web/skins/classic/views/filter.php @@ -135,6 +135,10 @@ foreach ( dbFetchAll('SELECT Id, Name, MonitorId FROM Zones ORDER BY lower(`Name } } } +$availableTags = array(); +foreach ( dbFetchAll('SELECT Id, Name FROM Tags ORDER BY LastAssignedDate DESC') AS $tag ) { + $availableTags[$tag['Id']] = validHtmlStr($tag['Name']); +} xhtmlHeaders(__FILE__, translate('EventFilter')); echo getBodyTopHTML(); @@ -194,6 +198,7 @@ $sort_fields = array( 'Id' => translate('AttrId'), 'Name' => translate('AttrName'), 'Cause' => translate('AttrCause'), + 'Tags' => translate('Tags'), 'DiskSpace' => translate('AttrDiskSpace'), 'Notes' => translate('AttrNotes'), 'MonitorName' => translate('AttrMonitorName'), diff --git a/web/skins/classic/views/js/event.js b/web/skins/classic/views/js/event.js index ea193831c..18201462d 100644 --- a/web/skins/classic/views/js/event.js +++ b/web/skins/classic/views/js/event.js @@ -26,41 +26,52 @@ var streamStatus = null; var lastEventId = 0; var zmsBroke = false; //Use alternate navigation if zms has crashed var wasHidden = false; +var availableTags = []; +var selectedTags = []; $j(document).on("keydown", "", function(e) { e = e || window.event; - if ( $j(".modal").is(":visible") ) { - if (e.key === "Enter") { - if ( $j("#deleteConfirm").is(":visible") ) { - $j("#delConfirmBtn").click(); - } else if ( $j("#eventDetailModal").is(":visible") ) { - $j("#eventDetailSaveBtn").click(); - } else if ( $j("#eventRenamelModal").is(":visible") ) { - $j("#eventRenameBtn").click(); - } - } else if (e.key === "Escape") { - $j(".modal").modal('hide'); - } else { - console.log('Modal is visible: key not implemented: ', e.key, ' keyCode: ', e.keyCode); - } - } else { - if (e.key === "ArrowLeft" && !e.altKey) { - prevEvent(); - } else if (e.key === "ArrowRight" && !e.altKey) { - nextEvent(); - } else if (e.key === "Delete") { - if ( $j("#deleteBtn").is(":disabled") == false ) { - $j("#deleteBtn").click(); - } - } else if (e.keyCode === 32) { - // space bar for Play/Pause - if ( $j("#playBtn").is(":visible") ) { - playClicked(); + if (!$j(".tag-input").is(":focus")) { + if ( $j(".modal").is(":visible") ) { + if (e.key === "Enter") { + if ( $j("#deleteConfirm").is(":visible") ) { + $j("#delConfirmBtn").click(); + } else if ( $j("#eventDetailModal").is(":visible") ) { + $j("#eventDetailSaveBtn").click(); + } else if ( $j("#eventRenamelModal").is(":visible") ) { + $j("#eventRenameBtn").click(); + } + } else if (e.key === "Escape") { + $j(".modal").modal('hide'); } else { - pauseClicked(); + console.log('Modal is visible: key not implemented: ', e.key, ' keyCode: ', e.keyCode); } } else { - console.log('Modal is not visible: key not implemented: ', e.key, ' keyCode: ', e.keyCode); + if (e.key === "ArrowLeft" && !e.altKey) { + prevEvent(); + } else if (e.key === "ArrowRight" && !e.altKey) { + nextEvent(); + } else if (e.key === "Delete") { + if ( $j("#deleteBtn").is(":disabled") == false ) { + $j("#deleteBtn").click(); + } + } else if (e.keyCode === 32) { + // space bar for Play/Pause + if ( $j("#playBtn").is(":visible") ) { + playClicked(); + } else { + pauseClicked(); + } + } else if (e.key === "ArrowDown") { + if (e.ctrlKey) { + addTag(availableTags[0]); + } else { + $j("#tagInput").focus(); + showDropdown(); + } + } else { + console.log('Modal is not visible: key not implemented: ', e.key, ' keyCode: ', e.keyCode); + } } } }); @@ -1046,6 +1057,9 @@ function onStatsResize(vidWidth) { } function initPage() { + getAvailableTags(); + getSelectedTags(); + // Load the event stats getStat(); @@ -1259,6 +1273,85 @@ function initPage() { } }); document.addEventListener('fullscreenchange', fullscreenChangeEvent); + + if (isMobile()) { // Mobile + // Event listener for adding tags when Space or Comma key is pressed on mobile devices + // Mobile Firefox is consistent with Desktop Firefox and Desktop Chrome supporting event.key for space and comma. + // Mobile Chrome always returns Unidentified for event.key for space and comma. + $j('#tagInput').on('input', function(event) { + var key = this.value.substr(-1).charCodeAt(0); + if (key === 32 || key === 44) { // Space or Comma + const tagInput = $j(this); + const tagValue = tagInput.val().slice(0, -1).trim(); + addOrCreateTag(tagValue); + event.preventDefault(); // Prevent the key from being entered in the input field + } + }); + // Event listener for adding tags when Enter key is pressed on mobile devices + // All mobile and desktop browsers don't pick up on Enter as 'input'. + // Mobile Chrome 'input' doesn't pick up "Next" button as Enter. + $j('#tagInput').on('keydown', function(event) { + var key = event.key; + if (key === "Enter") { // Enter + const tagInput = $j(this); + const tagValue = tagInput.val().trim(); + addOrCreateTag(tagValue); + event.preventDefault(); // Prevent the key from being entered in the input field + } + }); + } else { // Desktop + // Event listener for adding tags when Enter key is pressed or highlighting available tag when up/down arrows are pressed + $j('#tagInput').on('keydown', function(event) { + event = event || window.event; + var $hlight = $j('div.tag-dropdown-item.hlight'); + var $div = $j('div.tag-dropdown-item'); + if (event.key === "ArrowDown") { + if (event.ctrlKey) { + addTag(availableTags[0]); + } else if ($div.is(":visible")) { + $hlight.removeClass('hlight').next().addClass('hlight'); + if ($hlight.next().length == 0) { + $div.eq(0).addClass('hlight'); + } + } else { + showDropdown(); + } + } else if (event.key === "ArrowUp") { + $hlight.removeClass('hlight').prev().addClass('hlight'); + if ($hlight.prev().length == 0) { + $div.eq(-1).addClass('hlight'); + } + } else if (event.key === "Enter") { + var tagValue = $hlight.text(); + if (!tagValue) { + const tagInput = $j(this); + tagValue = tagInput.val().trim(); + } + addOrCreateTag(tagValue); + } else if (event.key === " " || event.key === ",") { + const tagInput = $j(this); + const tagValue = tagInput.val().trim(); + addOrCreateTag(tagValue); + event.preventDefault(); // Prevent the key from being entered in the input field + } else if (event.key === "Escape") { + $j("#tagInput").blur(); + } + }); + } + + // Event listener for typing in the tag input + $j('#tagInput').on('input', showDropdown); + + // Event listener for clicking in the tag input + $j('#tagInput').on('focus', showDropdown); + + // Event listener for removing tags + $j('.tags-container').on('click', '.tag-remove', function() { + const tagElement = $j(this).closest('.tag'); + const tag = tagElement.data('tag'); + removeTag(tag); + }); + streamPlay(); if ( parseInt(ZM_OPT_USE_GEOLOCATION) && parseFloat(eventData.Latitude) && parseFloat(eventData.Longitude)) { @@ -1287,6 +1380,129 @@ function initPage() { } // end if ZM_OPT_USE_GEOLOCATION } // end initPage +function addOrCreateTag(tagValue) { + const tagNames = availableTags.map((t) => t.Name.toLowerCase()); + const index = tagNames.indexOf(tagValue.toLowerCase()); + if (index > -1) { + addTag(availableTags[index]); + $j('.tag-dropdown-content').hide(); + } else if (tagValue.trim().length > 0) { + createTag(tagValue); + } +} + +function clickTag() { + const tagName = $j(this).text(); + const selectedTag = availableTags.find((tag) => tag.Name === tagName); + addTag(selectedTag); +} + +function showDropdown() { + const dropdownContent = $j('.tag-dropdown-content'); + dropdownContent.empty(); + const input = $j('#tagInput').val().trim(); + + var matchingTags = []; + if (availableTags) { + matchingTags = availableTags.filter(function(tag) { + var isMatch = tag.Name.toLowerCase().includes(input.toLowerCase()); + return isMatch && !isDup(tag.Name); + }); + } + + matchingTags.forEach(function(tag) { + const dropdownItem = $j('
', {class: 'tag-dropdown-item', text: tag.Name}); + dropdownItem.appendTo(dropdownContent); // Append the element to the dropdown content + }); + + if (matchingTags.length > 0) { + $j('.tag-dropdown-content').off('click'); + $j('.tag-dropdown-content').on('click', '.tag-dropdown-item', clickTag); + $j('.tag-dropdown-content').show(); + } else { + $j('.tag-dropdown-content').hide(); + } +} + +function isDup(tagName) { + return $j('.tag-text').filter(function() { + var elemText = $j(this).text(); + return elemText === tagName; + }).length != 0; +} + +function formatTag(tag) { + const tagName = tag.Name; + const tagElement = $j('
', {class: 'tag'}); + tagElement.data('tag', tag); + tagElement.append($j('', {class: 'tag-text', text: tagName})); + tagElement.append($j('', {class: 'tag-remove', text: '\u00D7'})); + $j('.tag-dropdown').before(tagElement); +} + +function addTag(tag) { + if (tag.Name.trim() !== '' && !isDup(tag.Name)) { + $j.getJSON(thisUrl + '?request=event&action=addtag&tid=' + tag.Id + '&id=' + eventData.Id) + .done(function(data) { + formatTag(tag); + selectedTags.push(tag); + + // Move the added tag to the front(top) of the availableTags array + const index = availableTags.map((t) => t.Id).indexOf(tag.Id); + availableTags.splice(0, 0, availableTags.splice(index, 1)[0]); + }) + .fail(logAjaxFail); + } else { + $j('.tag-dropdown-content').hide(); + } + $j('#tagInput').val(''); + $j('#tagInput').blur(); +} + +function removeTag(tag) { + $j.getJSON(thisUrl + '?request=event&action=removetag&tid=' + tag.Id + '&id=' + eventData.Id) + .done(function(data) { + $j('.tag-text').filter(function() { + return $j(this).text() === tag.Name; + }).parent().remove(); + if (data.response > 0) { + getAvailableTags(); + } + }) + .fail(logAjaxFail); +} + +function createTag(tagName) { + $j.getJSON(thisUrl + '?request=tags&action=createtag&tname=' + tagName) + .done(function(data) { + if (data.response.length > 0) { + var tag = data.response[0]; + if (availableTags) { + availableTags.splice(0, 0, tag); + } + addTag(tag); + } + }) + .fail(logAjaxFail); +} + +function getAvailableTags() { + $j.getJSON(thisUrl + '?request=tags&action=getavailabletags') + .done(function(data) { + availableTags = data.response; + }) + .fail(logAjaxFail); +} + +function getSelectedTags() { + $j.getJSON(thisUrl + '?request=event&action=getselectedtags&id=' + eventData.Id) + .done(function(data) { + selectedTags = data.response; + selectedTags.forEach((tag) => formatTag(tag)); + }) + .fail(logAjaxFail); +} + var toggleZonesButton = document.getElementById('toggleZonesButton'); if (toggleZonesButton) toggleZonesButton.addEventListener('click', toggleZones); diff --git a/web/skins/classic/views/js/event.js.php b/web/skins/classic/views/js/event.js.php index 26ad92355..263252a02 100644 --- a/web/skins/classic/views/js/event.js.php +++ b/web/skins/classic/views/js/event.js.php @@ -25,6 +25,7 @@ var eventData = { MonitorId: 'MonitorId() ?>', MonitorName: 'Name()) ?>', Cause: 'Cause()) ?>', + Notes: `Notes()?>`, Width: 'Width() ?>', Height: 'Height() ?>', @@ -57,6 +58,8 @@ var eventDataStrings = { MonitorId: '', MonitorName: '', Cause: '', + + Notes: '', StartDateTimeFormatted: '', EndDateTimeFormatted: '', diff --git a/web/skins/classic/views/js/events.js b/web/skins/classic/views/js/events.js index b778e39e4..2d788c86c 100644 --- a/web/skins/classic/views/js/events.js +++ b/web/skins/classic/views/js/events.js @@ -220,7 +220,7 @@ function initPage() { // Hide these columns on first run when no cookie is saved if (!getCookie('zmEventsTable.bs.table.columns')) { - table.bootstrapTable('hideColumn', 'Archived'); + // table.bootstrapTable('hideColumn', 'Archived'); table.bootstrapTable('hideColumn', 'Emailed'); } @@ -420,6 +420,11 @@ function initPage() { } }); + window.onpageshow = function(evt) { + console.log('Refreshing table'); + table.bootstrapTable('refresh'); + }; + table.bootstrapTable('resetSearch'); // The table is initially given a hidden style, so now that we are done rendering, show it table.show(); @@ -429,10 +434,12 @@ function filterEvents() { filterQuery = ''; $j('#fieldsTable input').each(function(index) { const el = $j(this); + console.log('input index: '+index+' this: '+encodeURIComponent(el.val())); filterQuery += '&'+encodeURIComponent(el.attr('name'))+'='+encodeURIComponent(el.val()); }); $j('#fieldsTable select').each(function(index) { const el = $j(this); + console.log('select index: '+index+' this: '+encodeURIComponent(el.val())); filterQuery += '&'+encodeURIComponent(el.attr('name'))+'='+encodeURIComponent(el.val()); }); console.log(filterQuery); diff --git a/web/skins/classic/views/js/filter.js b/web/skins/classic/views/js/filter.js index bab9956a9..d33b36f3d 100644 --- a/web/skins/classic/views/js/filter.js +++ b/web/skins/classic/views/js/filter.js @@ -291,6 +291,13 @@ function parseRows(rows) { }); var monitorVal = inputTds.eq(4).children().val(); inputTds.eq(4).html(monitorSelect).children().val(monitorVal).chosen({width: '101%'}); + } else if ( attr == 'Tags' ) { // Tags + var tagSelect = $j('').attr('name', queryPrefix + rowNum + '][val]').attr('id', queryPrefix + rowNum + '][val]'); + for (var key in availableTags) { + tagSelect.append(''); + }; + var tagVal = inputTds.eq(4).children().val(); + inputTds.eq(4).html(tagSelect).children().val(tagVal).chosen({width: '101%'}); } else if ( attr == 'ExistsInFileSystem' ) { var select = $j('').attr('name', queryPrefix + rowNum + '][val]').attr('id', queryPrefix + rowNum + '][val]'); for ( var booleanVal in booleanValues ) { @@ -400,6 +407,14 @@ function manageModalBtns(id) { } } +// function getAvailableTags() { +// $j.getJSON(thisUrl + '?request=tags&action=getavailabletags') +// .done(function(data) { +// return data.response; +// }) +// .fail(logAjaxFail); +// } + function initPage() { updateButtons($j('#executeButton')[0]); $j('#Id').chosen(); diff --git a/web/skins/classic/views/js/filter.js.php b/web/skins/classic/views/js/filter.js.php index 7acf039fd..7736f3e2c 100644 --- a/web/skins/classic/views/js/filter.js.php +++ b/web/skins/classic/views/js/filter.js.php @@ -9,6 +9,7 @@ global $servers; global $storageareas; global $monitors; + global $availableTags; global $zones; global $booleanValues; global $filter; @@ -27,6 +28,7 @@ const states = ; const servers = ; const storageareas = ; const monitors = ; +const availableTags = ; const sorted_monitor_ids = ; const zones = ; const booleanValues = ; diff --git a/web/skins/classic/views/timeline.php b/web/skins/classic/views/timeline.php index 8e19e4621..09b67cbf7 100644 --- a/web/skins/classic/views/timeline.php +++ b/web/skins/classic/views/timeline.php @@ -130,9 +130,63 @@ $chart = array( $monitors = array(); # The as E, and joining with Monitors is required for the filterSQL filters. -$rangeSql = 'SELECT min(E.StartDateTime) AS MinTime, max(E.EndDateTime) AS MaxTime FROM Events AS E INNER JOIN Monitors AS M ON (E.MonitorId = M.Id) WHERE NOT isnull(E.StartDateTime) AND NOT isnull(E.EndDateTime)'; -$eventsSql = 'SELECT E.* FROM Events AS E INNER JOIN Monitors AS M ON (E.MonitorId = M.Id) WHERE NOT isnull(StartDateTime)'; -$eventIdsSql = 'SELECT E.Id FROM Events AS E INNER JOIN Monitors AS M ON (E.MonitorId = M.Id) WHERE NOT isnull(StartDateTime)'; +$rangeSql = ' +SELECT + min(E.StartDateTime) + AS MinTime, + max(E.EndDateTime) + AS MaxTime, + GROUP_CONCAT(T.Name SEPARATOR ", ") + AS Tags +FROM Events + AS E +INNER JOIN Monitors + AS M + ON (E.MonitorId = M.Id) +LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId +LEFT JOIN Tags + AS T + ON T.Id = ET.TagId +WHERE NOT isnull(E.StartDateTime) + AND NOT isnull(E.EndDateTime)'; + +$eventsSql = ' +SELECT + E.*, + GROUP_CONCAT(T.Name SEPARATOR ", ") + AS Tags +FROM Events + AS E +INNER JOIN Monitors + AS M + ON (E.MonitorId = M.Id) +LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId +LEFT JOIN Tags + AS T + ON T.Id = ET.TagId +WHERE NOT isnull(StartDateTime)'; + +$eventIdsSql = ' +SELECT + E.Id, + GROUP_CONCAT(T.Name SEPARATOR ", ") + AS TagsFROM Events + AS E +INNER JOIN Monitors + AS M + ON (E.MonitorId = M.Id) +LEFT JOIN Events_Tags + AS ET + ON E.Id = ET.EventId +LEFT JOIN Tags + AS T + ON T.Id = ET.TagId +WHERE NOT isnull(StartDateTime)'; + $eventsValues = array(); if ( count($user->unviewableMonitorIds()) ) { diff --git a/web/skins/classic/views/watch.php b/web/skins/classic/views/watch.php index 569d9e19b..8528469aa 100644 --- a/web/skins/classic/views/watch.php +++ b/web/skins/classic/views/watch.php @@ -392,6 +392,7 @@ if ( canView('Events') && ($monitor->Type() != 'WebSite') ) { +