diff options
-rw-r--r-- | apis/clientlog.inc.php | 8 | ||||
-rw-r--r-- | inc/database.inc.php | 94 | ||||
-rw-r--r-- | inc/util.inc.php | 47 | ||||
-rw-r--r-- | modules-available/statistics/api.inc.php | 73 | ||||
-rw-r--r-- | modules-available/statistics/hooks/config-tgz.inc.php | 32 | ||||
-rw-r--r-- | modules-available/statistics/inc/devicetype.inc.php | 6 | ||||
-rw-r--r-- | modules-available/statistics/install.inc.php | 55 | ||||
-rw-r--r-- | modules-available/statistics/page.inc.php | 70 | ||||
-rw-r--r-- | modules-available/statistics/templates/machine-main.html | 30 | ||||
-rw-r--r-- | modules-available/statistics/templates/projector-list.html | 21 |
10 files changed, 416 insertions, 20 deletions
diff --git a/apis/clientlog.inc.php b/apis/clientlog.inc.php index 81a7dbf7..b68e4632 100644 --- a/apis/clientlog.inc.php +++ b/apis/clientlog.inc.php @@ -10,7 +10,11 @@ if (empty($_POST['type'])) die('Missing options.'); $type = mb_strtolower($_POST['type']); if ($type{0} === '~' || $type{0} === '.') { - require 'modules/statistics/api.inc.php'; + if (Module::isAvailable('statistics')) { + require 'modules/statistics/api.inc.php'; + } } else { - require 'modules/syslog/api.inc.php'; + if (Module::isAvailable('syslog')) { + require 'modules/syslog/api.inc.php'; + } } diff --git a/inc/database.inc.php b/inc/database.inc.php index 4a5821f4..ff98f5ee 100644 --- a/inc/database.inc.php +++ b/inc/database.inc.php @@ -132,4 +132,98 @@ class Database return self::$dbh->prepare($query); } + /** + * Insert row into table, returning the generated key. + * This requires the table to have an AUTO_INCREMENT column and + * usually requires the given $uniqueValues to span across a UNIQUE index. + * The code first tries to SELECT the key for the given values without + * inserting first. This means this function is best used for cases + * where you expect that the entry already exists in the table, so + * only one SELECT will run. For all the entries that do not exist, + * an INSERT or INSERT IGNORE is run, depending on whether $additionalValues + * is empty or not. Another reason we don't run the INSERT (IGNORE) first + * is that it will increase the AUTO_INCREMENT value on InnoDB, even when + * no INSERT took place. So if you expect a lot of collisions you might + * use this function to prevent your A_I value from counting up too + * quickly. + * Other than that, this is just a dumb version of running INSERT and then + * getting the LAST_INSERT_ID(), or doing a query for the existing ID in + * case of a key collision. + * + * @param string $table table to insert into + * @param string $aiKey name of the AUTO_INCREMENT column + * @param array $uniqueValues assoc array containing columnName => value mapping + * @param array $additionalValues assoc array containing columnName => value mapping + * @return int[] list of AUTO_INCREMENT values matching the list of $values + */ + public static function insertIgnore($table, $aiKey, $uniqueValues, $additionalValues = false) + { + // Sanity checks + if (array_key_exists($aiKey, $uniqueValues)) { + Util::traceError("$aiKey must not be in \$uniqueValues"); + } + if (is_array($additionalValues) && array_key_exists($aiKey, $additionalValues)) { + Util::traceError("$aiKey must not be in \$additionalValues"); + } + // Simple SELECT first + $selectSql = 'SELECT ' . $aiKey . ' FROM ' . $table . ' WHERE 1'; + foreach ($uniqueValues as $key => $value) { + $selectSql .= ' AND ' . $key . ' = :' . $key; + } + $selectSql .= ' LIMIT 1'; + $res = self::queryFirst($selectSql, $uniqueValues); + if ($res !== false) { + // Exists + if (!empty($additionalValues)) { + // Simulate ON DUPLICATE KEY UPDATE ... + $updateSql = 'UPDATE ' . $table . ' SET '; + $first = true; + foreach ($additionalValues as $key => $value) { + if ($first) { + $first = false; + } else { + $updateSql .= ', '; + } + $updateSql .= $key . ' = :' . $key; + } + $updateSql .= ' WHERE ' . $aiKey . ' = :' . $aiKey; + $additionalValues[$aiKey] = $res[$aiKey]; + Database::exec($updateSql, $additionalValues); + } + return $res[$aiKey]; + } + // Does not exist: + if (empty($additionalValues)) { + $combined =& $uniqueValues; + } else { + $combined = $uniqueValues + $additionalValues; + } + // Aight, try INSERT or INSERT IGNORE + $insertSql = 'INTO ' . $table . ' (' . implode(', ', array_keys($combined)) + . ') VALUES (:' . implode(', :', array_keys($combined)) . ')'; + if (empty($additionalValues)) { + // Simple INSERT IGNORE + $insertSql = 'INSERT IGNORE ' . $insertSql; + } else { + // INSERT ... ON DUPLICATE (in case we have a race) + $insertSql = 'INSERT ' . $insertSql . ' ON DUPLICATE KEY UPDATE '; + $first = true; + foreach ($additionalValues as $key => $value) { + if ($first) { + $first = false; + } else { + $insertSql .= ', '; + } + $insertSql .= $key . ' = VALUES(' . $key . ')'; + } + } + self::exec($insertSql, $combined); + // Insert done, retrieve key again + $res = self::queryFirst($selectSql, $uniqueValues); + if ($res === false) { + Util::traceError('Could not find value in table ' . $table . ' that was just inserted'); + } + return $res[$aiKey]; + } + } diff --git a/inc/util.inc.php b/inc/util.inc.php index f5e10ebc..5d1a4563 100644 --- a/inc/util.inc.php +++ b/inc/util.inc.php @@ -21,6 +21,11 @@ class Util exit(1); } Header('HTTP/1.1 500 Internal Server Error'); + if (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'html') === false ) { + Header('Content-Type: text/plain; charset=utf-8'); + echo 'API ERROR: ', $message, "\n", self::formatBacktracePlain(debug_backtrace()); + exit(0); + } Header('Content-Type: text/html; charset=utf-8'); echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><style>', "\n", ".arg { color: red; background: white; }\n", @@ -79,19 +84,38 @@ SADFACE; exit(0); } + private static function formatArgument($arg, $expandArray = true) + { + if (is_string($arg)) { + $arg = "'$arg'"; + } elseif (is_object($arg)) { + $arg = 'instanceof ' . get_class($arg); + } elseif (is_array($arg)) { + if ($expandArray && count($arg) < 20) { + $expanded = ''; + foreach ($arg as $key => $value) { + if (!empty($expanded)) { + $expanded .= ', '; + } + $expanded .= $key . ': ' . self::formatArgument($value, false); + if (strlen($expanded) > 200) + break; + } + if (strlen($expanded) <= 200) + return '[' . $expanded . ']'; + } + $arg = 'Array(' . count($arg) . ')'; + } + return $arg; + } + public static function formatBacktraceHtml($trace, $escape = true) { $output = ''; foreach ($trace as $idx => $line) { $args = array(); foreach ($line['args'] as $arg) { - if (is_string($arg)) { - $arg = "'$arg'"; - } elseif (is_object($arg)) { - $arg = 'instanceof ' . get_class($arg); - } elseif (is_array($arg)) { - $arg = 'Array(' . count($arg) . ')'; - } + $arg = self::formatArgument($arg); $args[] = '<span class="arg">' . htmlspecialchars($arg) . '</span>'; } $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT); @@ -111,14 +135,7 @@ SADFACE; foreach ($trace as $idx => $line) { $args = array(); foreach ($line['args'] as $arg) { - if (is_string($arg)) { - $arg = "'$arg'"; - } elseif (is_object($arg)) { - $arg = 'instanceof ' . get_class($arg); - } elseif (is_array($arg)) { - $arg = 'Array(' . count($arg) . ')'; - } - $args[] = $arg; + $args[] = self::formatArgument($arg); } $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT); $args = implode(', ', $args); diff --git a/modules-available/statistics/api.inc.php b/modules-available/statistics/api.inc.php index 2ac6e782..126c6e91 100644 --- a/modules-available/statistics/api.inc.php +++ b/modules-available/statistics/api.inc.php @@ -201,6 +201,79 @@ if ($type{0} === '~') { } } Database::exec('UPDATE machine SET logintime = 0, lastseen = UNIX_TIMESTAMP(), lastboot = 0 WHERE machineuuid = :uuid', array('uuid' => $uuid)); + } elseif ($type === '~screens') { + $screens = Request::post('screen', false, 'array'); + if (is_array($screens)) { + // `devicetype`, `devicename`, `subid`, `machineuuid` + // Make sure all screens are in the general hardware table + $hwids = array(); + foreach ($screens as $port => $screen) { + if (!array_key_exists('name', $screen)) + continue; + if (array_key_exists($screen['name'], $hwids)) { + $hwid = $hwids[$screen['name']]; + } else { + $hwid = (int)Database::insertIgnore('statistic_hw', 'hwid', + array('hwtype' => DeviceType::SCREEN, 'hwname' => $screen['name'])); + $hwids[$screen['name']] = $hwid; + } + // Now add new entries + $machinehwid = Database::insertIgnore('machine_x_hw', 'machinehwid', array( + 'hwid' => $hwid, + 'machineuuid' => $uuid, + 'devpath' => $port, + ), array('disconnecttime' => 0)); + $validProps = array(); + if (count($screen) > 1) { + // Screen has additional properties (resolution, size, etc.) + unset($screen['name']); + foreach ($screen as $key => $value) { + if (!preg_match('/^[a-zA-Z0-9][\x21-\x7e]{0,15}$/', $key)) { + echo "No matsch '$key'\n"; + continue; // Ignore evil key names + } + $validProps[] = $key; + Database::exec("INSERT INTO machine_x_hw_prop (machinehwid, prop, value)" + . " VALUES (:id, :key, :value) ON DUPLICATE KEY UPDATE value = VALUES(value)", array( + 'id' => $machinehwid, + 'key' => $key, + 'value' => $value, + )); + } + } + // Purge properties that might have existed in the past + if (empty($validProps)) { + Database::exec("DELETE FROM machine_x_hw_prop WHERE machinehwid = :machinehwid AND prop NOT LIKE '@%'", + array('machinehwid' => $machinehwid)); + } else { + $qs = '?' . str_repeat(',?', count($validProps) - 1); + array_unshift($validProps, $machinehwid); + Database::exec("DELETE FROM machine_x_hw_prop" + . " WHERE machinehwid = ? AND prop NOT LIKE '@%' AND prop NOT IN ($qs)", + $validProps); + } + } + // Remove/disable stale entries + if (empty($hwids)) { + // No screens connected at all, purge all screen entries for this machine + Database::exec("UPDATE machine_x_hw x, statistic_hw h" + . " SET x.disconnecttime = UNIX_TIMESTAMP()" + . " WHERE x.machineuuid = :uuid AND x.hwid = h.hwid AND h.hwtype = :type AND x.disconnecttime = 0", + array('uuid' => $uuid, 'type' => DeviceType::SCREEN)); + } else { + // Some screens connected, make sure old entries get removed + $params = array_values($hwids); + array_unshift($params, $uuid); + array_unshift($params, DeviceType::SCREEN); + $qs = '?' . str_repeat(',?', count($hwids) - 1); + Database::exec("UPDATE machine_x_hw x, statistic_hw h" + . " SET x.disconnecttime = UNIX_TIMESTAMP()" + . " WHERE h.hwid = x.hwid AND x.disconnecttime = 0 AND h.hwtype = ? AND x.machineuuid = ? AND x.hwid NOT IN ($qs)", $params); + + } + } + } else { + die("INVALID ACTION '$type'"); } die("OK. (RESULT=0)\n"); } diff --git a/modules-available/statistics/hooks/config-tgz.inc.php b/modules-available/statistics/hooks/config-tgz.inc.php new file mode 100644 index 00000000..1272a94f --- /dev/null +++ b/modules-available/statistics/hooks/config-tgz.inc.php @@ -0,0 +1,32 @@ +<?php + +$res = Database::simpleQuery('SELECT h.hwname FROM statistic_hw h' + . " INNER JOIN statistic_hw_prop p ON (h.hwid = p.hwid AND p.prop = :projector)" + . " WHERE h.hwtype = :screen ORDER BY h.hwname ASC", array( + 'projector' => 'projector', + 'screen' => DeviceType::SCREEN, +)); + +$content = ''; +while ($row = $res->fetch(PDO::FETCH_ASSOC)) { + $content .= $row['hwname'] . "=beamer\n"; +} + +if (!empty($content)) { + $tmpfile = '/tmp/bwlp-' . md5($content) . '.tar'; + if (!is_file($tmpfile) || !is_readable($tmpfile) || filemtime($tmpfile) + 86400 < time()) { + if (file_exists($tmpfile)) { + unlink($tmpfile); + } + try { + $a = new PharData($tmpfile); + $a->addFromString("/opt/openslx/beamergui/beamer.conf", $content); + $file = $tmpfile; + } catch (Exception $e) { + EventLog::failure('Could not include beamer.conf in config.tgz', (string)$e); + unlink($tmpfile); + } + } elseif (is_file($tmpfile) && is_readable($tmpfile)) { + $file = $tmpfile; + } +} diff --git a/modules-available/statistics/inc/devicetype.inc.php b/modules-available/statistics/inc/devicetype.inc.php new file mode 100644 index 00000000..41ee237d --- /dev/null +++ b/modules-available/statistics/inc/devicetype.inc.php @@ -0,0 +1,6 @@ +<?php + +class DeviceType +{ + const SCREEN = 'SCREEN'; +} diff --git a/modules-available/statistics/install.inc.php b/modules-available/statistics/install.inc.php index 7baf046e..5d8ce1bc 100644 --- a/modules-available/statistics/install.inc.php +++ b/modules-available/statistics/install.inc.php @@ -12,7 +12,7 @@ $res[] = tableCreate('statistic', " `dateline` int(10) unsigned NOT NULL, `typeid` varchar(30) NOT NULL, `clientip` varchar(40) NOT NULL, - `machineuuid` varchar(36) CHARACTER SET ascii DEFAULT NULL, + `machineuuid` char(36) CHARACTER SET ascii DEFAULT NULL, `username` varchar(30) NOT NULL, `data` varchar(255) NOT NULL, PRIMARY KEY (`logid`), @@ -24,7 +24,7 @@ $res[] = tableCreate('statistic', " // Main table containing all known clients -$res[] = tableCreate('machine', " +$res[] = $machineCreate = tableCreate('machine', " `machineuuid` char(36) CHARACTER SET ascii NOT NULL, `fixedlocationid` int(11) DEFAULT NULL COMMENT 'Manually set location (e.g. roomplanner)', `subnetlocationid` int(11) DEFAULT NULL COMMENT 'Automatically determined location (e.g. from subnet match), @@ -61,6 +61,40 @@ $res[] = tableCreate('machine', " KEY `systemmodel` (`systemmodel`) "); +$res[] = $machineHwCreate = tableCreate('machine_x_hw', " + `machinehwid` int(10) unsigned NOT NULL AUTO_INCREMENT, + `hwid` int(10) unsigned NOT NULL, + `machineuuid` char(36) CHARACTER SET ascii NOT NULL, + `devpath` char(50) CHARACTER SET ascii NOT NULL, + `disconnecttime` int(10) unsigned NOT NULL COMMENT 'time the device was not connected to the pc anymore for the first time, 0 if it is connected', + PRIMARY KEY (`machinehwid`), + UNIQUE KEY `hwid` (`hwid`,`machineuuid`,`devpath`), + KEY `machineuuid` (`machineuuid`,`hwid`), + KEY `disconnecttime` (`disconnecttime`) + "); + +$res[] = tableCreate('machine_x_hw_prop', " + `machinehwid` int(10) unsigned NOT NULL, + `prop` char(16) CHARACTER SET ascii NOT NULL, + `value` varchar(500) NOT NULL, + PRIMARY KEY (`machinehwid`,`prop`) +"); + +$res[] = tableCreate('statistic_hw', " + `hwid` int(10) unsigned NOT NULL AUTO_INCREMENT, + `hwtype` char(11) CHARACTER SET ascii NOT NULL, + `hwname` varchar(200) NOT NULL, + PRIMARY KEY (`hwid`), + UNIQUE KEY `hwtype` (`hwtype`,`hwname`) +"); + +$res[] = tableCreate('statistic_hw_prop', " + `hwid` int(10) unsigned NOT NULL, + `prop` char(16) CHARACTER SET ascii NOT NULL, + `value` varchar(500) NOT NULL, + PRIMARY KEY (`hwid`,`prop`) +"); + // PCI-ID cache $res[] = tableCreate('pciid', " @@ -71,7 +105,8 @@ $res[] = tableCreate('pciid', " PRIMARY KEY (`category`,`id`) "); -if (in_array(UPDATE_DONE, $res)) { +// need trigger? +if ($machineCreate === UPDATE_DONE) { $addTrigger = true; } @@ -165,6 +200,20 @@ if ($addTrigger) { } } +if ($machineHwCreate === UPDATE_DONE) { + $ret = true; + $ret = Database::exec('ALTER TABLE `machine_x_hw` + ADD CONSTRAINT `machine_x_hw_ibfk_1` FOREIGN KEY (`hwid`) REFERENCES `statistic_hw` (`hwid`) ON DELETE CASCADE, + ADD CONSTRAINT `machine_x_hw_ibfk_2` FOREIGN KEY (`machineuuid`) REFERENCES `machine` (`machineuuid`) ON DELETE CASCADE') && $ret; + $ret = Database::exec('ALTER TABLE `machine_x_hw_prop` + ADD CONSTRAINT `machine_x_hw_prop_ibfk_1` FOREIGN KEY (`machinehwid`) REFERENCES `machine_x_hw` (`machinehwid`) ON DELETE CASCADE') && $ret; + $ret = Database::exec('ALTER TABLE `statistic_hw_prop` + ADD CONSTRAINT `statistic_hw_prop_ibfk_1` FOREIGN KEY (`hwid`) REFERENCES `statistic_hw` (`hwid`) ON DELETE CASCADE') && $ret; + if ($ret === false) { + finalResponse(UPDATE_FAILED, 'Adding foreign key machineuuid to hardware* failed: ' . Database::lastError()); + } +} + // Create response if (in_array(UPDATE_DONE, $res)) { diff --git a/modules-available/statistics/page.inc.php b/modules-available/statistics/page.inc.php index 87c8e7c4..5ad8bc20 100644 --- a/modules-available/statistics/page.inc.php +++ b/modules-available/statistics/page.inc.php @@ -119,6 +119,53 @@ class Page_Statistics extends Page /* TODO ... */ } + /* + * TODO: Move to separate unit... hardware configurator? + */ + + protected function handleProjector($action) + { + $hwid = Request::post('hwid', false, 'int'); + if ($hwid === false) { + Util::traceError('Param hwid missing'); + } + if ($action === 'addprojector') { + Database::exec('INSERT INTO statistic_hw_prop (hwid, prop, value)' + . ' VALUES (:hwid, :prop, :value)', array( + 'hwid' => $hwid, + 'prop' => 'projector', + 'value' => 'true', + )); + } else { + Database::exec('DELETE FROM statistic_hw_prop WHERE hwid = :hwid AND prop = :prop', array( + 'hwid' => $hwid, + 'prop' => 'projector', + )); + } + if (Module::isAvailable('sysconfig')) { + ConfigTgz::rebuildAllConfigs(); + } + Util::redirect('?do=statistics&show=projectors'); + } + + protected function showProjectors() + { + $res = Database::simpleQuery('SELECT h.hwname, h.hwid FROM statistic_hw h' + . " INNER JOIN statistic_hw_prop p ON (h.hwid = p.hwid AND p.prop = :projector)" + . " WHERE h.hwtype = :screen ORDER BY h.hwname ASC", array( + 'projector' => 'projector', + 'screen' => DeviceType::SCREEN, + )); + $data = array( + 'projectors' => $res->fetchAll(PDO::FETCH_ASSOC) + ); + Render::addTemplate('projector-list', $data); + } + + /* + * End TODO + */ + protected function doPreprocess() { $this->initConstants(); @@ -140,6 +187,8 @@ class Page_Statistics extends Page )); Message::addSuccess('notes-saved'); Util::redirect('?do=Statistics&uuid=' . $uuid); + } elseif ($action === 'addprojector' || $action === 'delprojector') { + $this->handleProjector($action); } // Fix online state of machines that crashed -- TODO: Make cronjob for this Database::exec("UPDATE machine SET lastboot = 0 WHERE lastseen < UNIX_TIMESTAMP() - 610"); @@ -174,6 +223,9 @@ class Page_Statistics extends Page Render::closeTag('div'); $this->showMachineList($filterSet); return; + } elseif ($show === 'projectors') { + $this->showProjectors(); + return; } Render::openTag('div', array('class' => 'row')); $this->showFilter('stat', $filterSet); @@ -723,6 +775,24 @@ class Page_Statistics extends Page } $client['locations'] = $output; } + // Screens TODO Move everything else to hw table instead of blob parsing above + // `devicetype`, `devicename`, `subid`, `machineuuid` + $res = Database::simpleQuery("SELECT m.hwid, h.hwname, m.devpath AS connector, m.disconnecttime," + . " p.value AS resolution, q.prop AS projector FROM machine_x_hw m" + . " INNER JOIN statistic_hw h ON (m.hwid = h.hwid AND h.hwtype = :screen)" + . " LEFT JOIN machine_x_hw_prop p ON (m.machinehwid = p.machinehwid AND p.prop = 'resolution')" + . " LEFT JOIN statistic_hw_prop q ON (m.hwid = q.hwid AND q.prop = 'projector')" + . " WHERE m.machineuuid = :uuid", + array('screen' => DeviceType::SCREEN, 'uuid' => $uuid)); + $client['screens'] = array(); + $ports = array(); + while ($row = $res->fetch(PDO::FETCH_ASSOC)) { + if ($row['disconnecttime'] != 0) + continue; + $ports[] = $row['connector']; + $client['screens'][] = $row; + } + array_multisort($ports, SORT_ASC, $client['screens']); // Throw output at user Render::addTemplate('machine-main', $client); // Sessions diff --git a/modules-available/statistics/templates/machine-main.html b/modules-available/statistics/templates/machine-main.html index 0b333a27..56454e78 100644 --- a/modules-available/statistics/templates/machine-main.html +++ b/modules-available/statistics/templates/machine-main.html @@ -130,6 +130,36 @@ <td class="text-nowrap">{{lang_64bitSupport}}</td> <td>{{kvmstate}}</td> </tr> + <tr> + <td class="text-nowrap">{{lang_screens}}</td> + <td> + <form method="post" action="?do=statistics" id="delprojector"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="action" value="delprojector"> + </form> + <form method="post" action="?do=statistics" id="addprojector"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="action" value="addprojector"> + </form> + {{#screens}} + <div class="small"> + <div class="pull-right btn-group btn-group-xs"> + {{#projector}} + <span class="btn btn-default">{{lang_projector}}</span> + <button form="delprojector" type="submit" name="hwid" value="{{hwid}}" + class="btn btn-danger"><span class="glyphicon glyphicon-remove"></span></button> + {{/projector}} + {{^projector}} + <button form="addprojector" type="submit" name="hwid" value="{{hwid}}" + class="btn btn-success"><span class="glyphicon glyphicon-plus"></span> {{lang_projector}}</button> + {{/projector}} + </div> + {{connector}}: <b>{{hwname}}</b> {{resolution}} + <div class="clearfix"></div> + </div> + {{/screens}} + </td> + </tr> </table> <h4>{{lang_devices}}</h4> {{#lspci1}} diff --git a/modules-available/statistics/templates/projector-list.html b/modules-available/statistics/templates/projector-list.html new file mode 100644 index 00000000..bc9ecdbd --- /dev/null +++ b/modules-available/statistics/templates/projector-list.html @@ -0,0 +1,21 @@ +<div class="panel panel-default"> + <div class="panel-heading">{{lang_projectors}}</div> + <div class="panel-body"> + <form method="post" action="?do=statistics" id="delprojector"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="action" value="delprojector"> + <p>{{lang_thoseAreProjectors}}</p> + {{#projectors}} + <div> + <button type="submit" name="hwid" value="{{hwid}}" class="btn btn-danger"> + <span class="glyphicon glyphicon-remove"></span> + </button> + {{hwname}} + </div> + {{/projectors}} + {{^projectors}} + <div class="alert alert-info">{{lang_noProjectorsDefined}}</div> + {{/projectors}} + </form> + </div> +</div>
\ No newline at end of file |