summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimon Rettberg2017-03-02 14:27:00 +0100
committerSimon Rettberg2017-03-02 14:27:00 +0100
commit7b198f93c1848589501ff8e41809412aa7ff72f9 (patch)
tree8e53c4a42d02b629835b2c597921bdbcf45d66ce
parent[systemstatus] Add lighttpd error log (diff)
parent[statistics_reporting] fixed column selector order (diff)
downloadslx-admin-7b198f93c1848589501ff8e41809412aa7ff72f9.tar.gz
slx-admin-7b198f93c1848589501ff8e41809412aa7ff72f9.tar.xz
slx-admin-7b198f93c1848589501ff8e41809412aa7ff72f9.zip
Merge branch 'statistics_reporting'
-rw-r--r--config.php.example2
-rw-r--r--inc/util.inc.php52
-rw-r--r--modules-available/js_stupidtable/clientscript.js30
-rw-r--r--modules-available/js_stupidtable/config.json1
-rw-r--r--modules-available/statistics_reporting/config.json4
-rw-r--r--modules-available/statistics_reporting/hooks/cron.inc.php23
-rw-r--r--modules-available/statistics_reporting/hooks/main-warning.inc.php5
-rw-r--r--modules-available/statistics_reporting/inc/getdata.inc.php147
-rw-r--r--modules-available/statistics_reporting/inc/queries.inc.php217
-rw-r--r--modules-available/statistics_reporting/inc/remotereport.inc.php84
-rw-r--r--modules-available/statistics_reporting/lang/de/messages.json4
-rw-r--r--modules-available/statistics_reporting/lang/de/module.json10
-rw-r--r--modules-available/statistics_reporting/lang/de/template-tags.json22
-rw-r--r--modules-available/statistics_reporting/lang/en/messages.json4
-rw-r--r--modules-available/statistics_reporting/lang/en/module.json10
-rw-r--r--modules-available/statistics_reporting/lang/en/template-tags.json22
-rw-r--r--modules-available/statistics_reporting/lang/pt/template-tags.json3
-rw-r--r--modules-available/statistics_reporting/page.inc.php279
-rw-r--r--modules-available/statistics_reporting/style.css42
-rw-r--r--modules-available/statistics_reporting/templates/columnChooser.html179
-rw-r--r--modules-available/statistics_reporting/templates/table-client.html30
-rw-r--r--modules-available/statistics_reporting/templates/table-location.html24
-rw-r--r--modules-available/statistics_reporting/templates/table-total.html22
-rw-r--r--modules-available/statistics_reporting/templates/table-user.html16
-rw-r--r--modules-available/statistics_reporting/templates/table-vm.html19
25 files changed, 1251 insertions, 0 deletions
diff --git a/config.php.example b/config.php.example
index 82fd1b77..41949fc8 100644
--- a/config.php.example
+++ b/config.php.example
@@ -30,6 +30,8 @@ define('CONFIG_PROXY_CONF', '/opt/openslx/proxy/config');
define('CONFIG_DOZMOD_URL', 'http://127.0.0.1:9080');
define('CONFIG_DOZMOD_EXPIRE', 60);
+define('CONFIG_REPORTING_URL', 'https://bwlp-masterserver.ruf.uni-freiburg.de/rpc/');
+
// Sort order for menu
// Optional - if missing, will be sorted by module id (internal name)
// Here it is also possible to assign a module to a different category,
diff --git a/inc/util.inc.php b/inc/util.inc.php
index 671028ed..d454d18d 100644
--- a/inc/util.inc.php
+++ b/inc/util.inc.php
@@ -365,4 +365,56 @@ SADFACE;
exit(0);
}
+ /**
+ * Return a binary string of given length, containing
+ * random bytes. If $secure is given, only methods of
+ * obtaining cryptographically strong random bytes will
+ * be used, otherwise, weaker methods might be used.
+ *
+ * @param int $length number of bytes to return
+ * @param bool $secure true = only use strong random sources
+ * @return string|bool string of requested length, false on error
+ */
+ public static function randomBytes($length, $secure)
+ {
+ if (function_exists('random_bytes')) {
+ return random_bytes($length);
+ }
+ if ($secure) {
+ if (function_exists('openssl_random_pseudo_bytes')) {
+ $bytes = openssl_random_pseudo_bytes($length, $ok);
+ if ($bytes !== false && $ok) {
+ return $bytes;
+ }
+ }
+ $file = '/dev/random';
+ } else {
+ $file = '/dev/urandom';
+ }
+ $fh = @fopen($file, 'r');
+ if ($fh !== false) {
+ $bytes = fread($fh, $length);
+ while ($bytes !== false && strlen($bytes) < $length) {
+ $new = fread($fh, $length - strlen($bytes));
+ if ($new === false) {
+ $bytes = false;
+ break;
+ }
+ $bytes .= $new;
+ }
+ fclose($fh);
+ if ($bytes !== false) {
+ return $bytes;
+ }
+ }
+ if ($secure) {
+ return false;
+ }
+ $bytes = '';
+ while ($length > 0) {
+ $bytes .= chr(mt_rand(0, 255));
+ }
+ return $bytes;
+ }
+
}
diff --git a/modules-available/js_stupidtable/clientscript.js b/modules-available/js_stupidtable/clientscript.js
new file mode 100644
index 00000000..bfbc9112
--- /dev/null
+++ b/modules-available/js_stupidtable/clientscript.js
@@ -0,0 +1,30 @@
+/*
+ Stupid jQuery table plugin.
+
+ https://github.com/joequery/Stupid-Table-Plugin
+
+ Copyright (c) 2012 Joseph McCullough
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+ */
+
+(function(c){c.fn.stupidtable=function(b){return this.each(function(){var a=c(this);b=b||{};b=c.extend({},c.fn.stupidtable.default_sort_fns,b);a.data("sortFns",b);a.on("click.stupidtable","thead th",function(){c(this).stupidsort()})})};c.fn.stupidsort=function(b){var a=c(this),g=0,f=c.fn.stupidtable.dir,e=a.closest("table"),k=a.data("sort")||null;if(null!==k){a.parents("tr").find("th").slice(0,c(this).index()).each(function(){var a=c(this).attr("colspan")||1;g+=parseInt(a,10)});var d;1==arguments.length?
+ d=b:(d=b||a.data("sort-default")||f.ASC,a.data("sort-dir")&&(d=a.data("sort-dir")===f.ASC?f.DESC:f.ASC));if(a.data("sort-dir")!==d)return a.data("sort-dir",d),e.trigger("beforetablesort",{column:g,direction:d}),e.css("display"),setTimeout(function(){var b=[],l=e.data("sortFns")[k],h=e.children("tbody").children("tr");h.each(function(a,d){var e=c(d).children().eq(g),f=e.data("sort-value");"undefined"===typeof f&&(f=e.text(),e.data("sort-value",f));b.push([f,d])});b.sort(function(a,b){return l(a[0],
+ b[0])});d!=f.ASC&&b.reverse();h=c.map(b,function(a){return a[1]});e.children("tbody").append(h);e.find("th").data("sort-dir",null).removeClass("sorting-desc sorting-asc");a.data("sort-dir",d).addClass("sorting-"+d);e.trigger("aftertablesort",{column:g,direction:d});e.css("display")},10),a}};c.fn.updateSortVal=function(b){var a=c(this);a.is("[data-sort-value]")&&a.attr("data-sort-value",b);a.data("sort-value",b);return a};c.fn.stupidtable.dir={ASC:"asc",DESC:"desc"};c.fn.stupidtable.default_sort_fns=
+ {"int":function(b,a){return parseInt(b,10)-parseInt(a,10)},"float":function(b,a){return parseFloat(b)-parseFloat(a)},string:function(b,a){return b.toString().localeCompare(a.toString())},"string-ins":function(b,a){b=b.toString().toLocaleLowerCase();a=a.toString().toLocaleLowerCase();return b.localeCompare(a)}}})(jQuery);
diff --git a/modules-available/js_stupidtable/config.json b/modules-available/js_stupidtable/config.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/modules-available/js_stupidtable/config.json
@@ -0,0 +1 @@
+{} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/config.json b/modules-available/statistics_reporting/config.json
new file mode 100644
index 00000000..f9627cdb
--- /dev/null
+++ b/modules-available/statistics_reporting/config.json
@@ -0,0 +1,4 @@
+{
+ "category": "main.content",
+ "dependencies": [ "statistics", "locations", "js_stupidtable", "js_jqueryui" ]
+}
diff --git a/modules-available/statistics_reporting/hooks/cron.inc.php b/modules-available/statistics_reporting/hooks/cron.inc.php
new file mode 100644
index 00000000..a48f74c2
--- /dev/null
+++ b/modules-available/statistics_reporting/hooks/cron.inc.php
@@ -0,0 +1,23 @@
+<?php
+
+if (RemoteReport::isReportingEnabled()) {
+ $nextReporting = RemoteReport::getReportingTimestamp();
+
+ // It's time to generate a new report
+ if ($nextReporting <= time()) {
+ RemoteReport::writeNextReportingTimestamp();
+
+ $from = strtotime("-7 days", $nextReporting);
+ $to = $nextReporting;
+
+ $statisticsReport = json_encode(RemoteReport::generateReport($from, $to));
+
+ $params = array("action" => "statistics", "data" => $statisticsReport);
+
+ $result = Download::asStringPost(CONFIG_REPORTING_URL, $params, 30, $code);
+
+ if ($code != 200) {
+ EventLog::warning("Statistics Reporting failed: " . $code, $result);
+ }
+ }
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/hooks/main-warning.inc.php b/modules-available/statistics_reporting/hooks/main-warning.inc.php
new file mode 100644
index 00000000..33381c9f
--- /dev/null
+++ b/modules-available/statistics_reporting/hooks/main-warning.inc.php
@@ -0,0 +1,5 @@
+<?php
+
+if (!RemoteReport::isReportingEnabled()) {
+ Message::addInfo('statistics_reporting.remote-report-disabled', true);
+}
diff --git a/modules-available/statistics_reporting/inc/getdata.inc.php b/modules-available/statistics_reporting/inc/getdata.inc.php
new file mode 100644
index 00000000..f65ee868
--- /dev/null
+++ b/modules-available/statistics_reporting/inc/getdata.inc.php
@@ -0,0 +1,147 @@
+<?php
+
+define('GETDATA_ANONYMOUS', 1);
+define('GETDATA_PRINTABLE', 2);
+
+class GetData
+{
+ public static $from;
+ public static $to;
+ public static $lowerTimeBound = 0;
+ public static $upperTimeBound = 24;
+ public static $salt;
+
+ // total
+ public static function total($flags = 0) {
+ $printable = 0 !== ($flags & GETDATA_PRINTABLE);
+ // total time online, average time online, total number of logins
+ $res = Queries::getOverallStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
+ $row = $res->fetch(PDO::FETCH_ASSOC);
+ $data = array('totalTime' => $row['sum'], 'medianSessionLength' => self::calcMedian($row['median']), 'longSessions' => $row['longSessions'], 'shortSessions' => $row['shortSessions']);
+
+ //total time offline
+ $res = Queries::getTotalOfflineStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
+ $row = $res->fetch(PDO::FETCH_ASSOC);
+ $data['totalOffTime'] = $row['timeOff'];
+
+ if ($printable) {
+ $data["totalTime_s"] = self::formatSeconds($data["totalTime"]);
+ $data["medianSessionLength_s"] = self::formatSeconds($data["medianSessionLength"]);
+ $data["totalOffTime_s"] = self::formatSeconds($data["totalOffTime"]);
+ }
+
+ return $data;
+ }
+
+ // per location
+ public static function perLocation($flags = 0) {
+ $anonymize = 0 !== ($flags & GETDATA_ANONYMOUS);
+ $printable = 0 !== ($flags & GETDATA_PRINTABLE);
+ $res = Queries::getLocationStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
+ $data = array();
+ while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $median = self::calcMedian(self::calcMedian($row['medianSessionLength']));
+ $entry = array(
+ 'location' => ($anonymize ? $row['locHash'] : $row['locName']),
+ 'totalTime' => $row['timeSum'],
+ 'medianSessionLength' => $median,
+ 'totalOffTime' => $row['offlineSum'],
+ 'longSessions' => $row['longSessions'],
+ 'shortSessions' => $row['shortSessions']
+ );
+ if (!$anonymize) {
+ $entry['locationId'] = $row['locId'];
+ }
+ if ($printable) {
+ $entry['totalTime_s'] = self::formatSeconds($row['timeSum']);
+ $entry['medianSessionLength_s'] = self::formatSeconds($median);
+ $entry['totalOffTime_s'] = self::formatSeconds($row['offlineSum']);
+ }
+ $data[] = $entry;
+ }
+ return $data;
+ }
+
+ // per client
+ public static function perClient($flags = 0) {
+ $anonymize = 0 !== ($flags & GETDATA_ANONYMOUS);
+ $printable = 0 !== ($flags & GETDATA_PRINTABLE);
+ $res = Queries::getClientStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
+ $data = array();
+ while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $median = self::calcMedian(self::calcMedian($row['medianSessionLength']));
+ $entry = array(
+ 'hostname' => ($anonymize ? $row['clientHash'] : $row['clientName']),
+ 'totalTime' => $row['timeSum'],
+ 'medianSessionLength' => $median,
+ 'totalOffTime' => $row['offlineSum'],
+ 'lastStart' => $row['lastStart'],
+ 'lastLogout' => $row['lastLogout'],
+ 'longSessions' => $row['longSessions'],
+ 'shortSessions' => $row['shortSessions'],
+ 'location' => ($anonymize ? $row['locHash'] : $row['locName']),
+ );
+ if (!$anonymize) {
+ $entry['locationId'] = $row['locId'];
+ }
+ if ($printable) {
+ $entry['totalTime_s'] = self::formatSeconds($row['timeSum']);
+ $entry['medianSessionLength_s'] = self::formatSeconds($median);
+ $entry['totalOffTime_s'] = self::formatSeconds($row['offlineSum']);
+ $entry['lastStart_s'] = $row['lastStart'] == 0 ? "" : date(DATE_ISO8601, $row['lastStart']);
+ $entry['lastLogout_s'] = $row['lastLogout'] == 0 ? "" : date(DATE_ISO8601, $row['lastLogout']);
+ }
+ $data[] = $entry;
+ }
+ return $data;
+ }
+
+ // per user
+ public static function perUser($flags = 0) {
+ $anonymize = 0 !== ($flags & GETDATA_ANONYMOUS);
+ $res = Queries::getUserStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
+ $data = array();
+ $user = $anonymize ? 'userHash' : 'name';
+ while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $data[] = array('user' => $row[$user], 'sessions' => $row['count']);
+ }
+ return $data;
+ }
+
+
+ // per vm
+ public static function perVM($flags = 0) {
+ $anonymize = 0 !== ($flags & GETDATA_ANONYMOUS);
+ $res = Queries::getVMStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
+ $data = array();
+ $vm = $anonymize ? 'vmHash' : 'name';
+ while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $data[] = array('vm' => $row[$vm], 'sessions' => $row['count']);
+ }
+ return $data;
+ }
+
+
+
+ // Format $seconds into ".d .h .m .s" format (day, hour, minute, second)
+ private static function formatSeconds($seconds)
+ {
+ return sprintf('%dd, %02d:%02d:%02d', $seconds / (3600*24), ($seconds % (3600*24)) / 3600, ($seconds%3600) / 60, $seconds%60);
+ }
+
+ // Calculate Median
+ private static function calcMedian($string) {
+ $arr = explode(",", $string);
+ sort($arr, SORT_NUMERIC);
+ $count = count($arr); //total numbers in array
+ $middleval = floor(($count-1)/2); // find the middle value, or the lowest middle value
+ if($count % 2) { // odd number, middle is the median
+ $median = $arr[(int) $middleval];
+ } else { // even number, calculate avg of 2 medians
+ $low = $arr[(int) $middleval];
+ $high = $arr[(int) $middleval+1];
+ $median = (($low+$high)/2);
+ }
+ return round($median);
+ }
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/inc/queries.inc.php b/modules-available/statistics_reporting/inc/queries.inc.php
new file mode 100644
index 00000000..3e944c92
--- /dev/null
+++ b/modules-available/statistics_reporting/inc/queries.inc.php
@@ -0,0 +1,217 @@
+<?php
+
+
+class Queries
+{
+
+ // Client Data: Name, Time Online, Median Time Online, Time Offline, last start, last logout, Last Time Booted, Number of Sessions > 60Sec, Number of Sessions < 60Sec, name of location, id of location (anonymized), machine uuid (anonymized)
+ public static function getClientStatistics($from, $to, $lowerTimeBound = 0, $upperTimeBound = 24, $excludeToday = false) {
+ $notassigned = Dictionary::translate('notAssigned', true);
+ Database::exec("SET SESSION group_concat_max_len = 1000000000");
+ $res = Database::simpleQuery("SELECT name AS clientName, timeSum, medianSessionLength, offlineSum, IFNULL(lastStart, 0) as lastStart, IFNULL(lastLogout, 0) as lastLogout, longSessions, shortSessions, locId, locName, MD5(CONCAT(locId, :salt)) AS locHash, MD5(CONCAT(t1.uuid, :salt)) AS clientHash FROM (
+ SELECT machine.machineuuid AS 'uuid', SUM(CAST(sessionTable.length AS UNSIGNED)) AS 'timeSum', GROUP_CONCAT(sessionTable.length) AS 'medianSessionLength', SUM(sessionTable.length >= 60) AS 'longSessions', SUM(sessionTable.length < 60) AS 'shortSessions', MAX(sessionTable.endInBound) AS 'lastLogout'
+ FROM ".self::getBoundedTableQueryString('~session-length', $from, $to, $lowerTimeBound, $upperTimeBound)." sessionTable
+ INNER JOIN machine ON sessionTable.machineuuid = machine.machineuuid
+ GROUP BY machine.machineuuid
+ ) t1
+ RIGHT JOIN (
+ SELECT machine.hostname AS 'name', machine.machineuuid AS 'uuid', SUM(CAST(offlineTable.length AS UNSIGNED)) AS 'offlineSum', MAX(offlineTable.endInBound) AS 'lastStart', IFNULL(location.locationname, '$notassigned') AS 'locName', location.locationid AS 'locId'
+ FROM ".self::getBoundedTableQueryString('~offline-length', $from, $to, $lowerTimeBound, $upperTimeBound)." offlineTable
+ INNER JOIN machine ON offlineTable.machineuuid = machine.machineuuid
+ LEFT JOIN location ON machine.locationid = location.locationid
+ GROUP BY machine.machineuuid
+ ) t2
+ ON t1.uuid = t2.uuid", array("salt" => GetData::$salt));
+
+ return $res;
+ }
+
+ // Location Data: Name, ID (anonymized), Time Online, Median Time Online, Time Offline, Number of Sessions > 60Sec, Number of Sessions < 60Sec
+ public static function getLocationStatistics($from, $to, $lowerTimeBound = 0, $upperTimeBound = 24, $excludeToday = false) {
+ $notassigned = Dictionary::translate('notAssigned', true);
+ Database::exec("SET SESSION group_concat_max_len = 1000000000");
+ $res = Database::simpleQuery("SELECT t1.locId, locName AS locName, MD5(CONCAT(t1.locId, :salt)) AS locHash, timeSum, medianSessionLength, offlineSum, longSessions, shortSessions FROM (
+ SELECT location.locationid AS 'locId', SUM(CAST(sessionTable.length AS UNSIGNED)) AS 'timeSum', GROUP_CONCAT(sessionTable.length) AS 'medianSessionLength', SUM(sessionTable.length >= 60) AS 'longSessions', SUM(sessionTable.length < 60) AS 'shortSessions'
+ FROM ".self::getBoundedTableQueryString('~session-length', $from, $to, $lowerTimeBound, $upperTimeBound)." sessionTable
+ INNER JOIN machine ON sessionTable.machineuuid = machine.machineuuid
+ LEFT JOIN location ON machine.locationid = location.locationid
+ GROUP BY machine.locationid
+ ) t1
+ RIGHT JOIN (
+ SELECT IFNULL(location.locationname, '$notassigned') AS 'locName', location.locationid AS 'locId', SUM(CAST(offlineTable.length AS UNSIGNED)) AS 'offlineSum'
+ FROM ".self::getBoundedTableQueryString('~offline-length', $from, $to, $lowerTimeBound, $upperTimeBound)." offlineTable
+ INNER JOIN machine ON offlineTable.machineuuid = machine.machineuuid
+ LEFT JOIN location ON machine.locationid = location.locationid
+ GROUP BY machine.locationid
+ ) t2
+ ON t1.locId = t2.locId", array("salt" => GetData::$salt));
+ return $res;
+ }
+
+ // User Data: Name, Name(anonymized), Number of Logins
+ public static function getUserStatistics($from, $to, $lowerTimeBound = 0, $upperTimeBound = 24) {
+ $res = Database::simpleQuery("SELECT username AS name, IF(username = 'anonymous', 'anonymous', md5(CONCAT(username, :salt))) AS userHash, COUNT(*) AS 'count'
+ FROM statistic
+ WHERE typeid='.vmchooser-session-name' AND dateline >= $from and dateline <= $to
+ AND FROM_UNIXTIME(dateline, '%H') >= $lowerTimeBound AND FROM_UNIXTIME(dateline, '%H') < $upperTimeBound
+ GROUP BY username
+ ORDER BY 2 DESC", array("salt" => GetData::$salt));
+ return $res;
+ }
+
+ // Virtual Machine Data: Name, Number of Usages
+ public static function getVMStatistics($from, $to, $lowerTimeBound = 0, $upperTimeBound = 24) {
+ $res = Database::simpleQuery("SELECT data AS name, MD5(CONCAT(data, :salt)) AS vmHash, COUNT(*) AS 'count'
+ FROM statistic
+ WHERE typeid='.vmchooser-session-name' AND dateline >= $from and dateline <= $to
+ AND FROM_UNIXTIME(dateline, '%H') >= $lowerTimeBound AND FROM_UNIXTIME(dateline, '%H') < $upperTimeBound
+ GROUP BY data
+ ORDER BY 2 DESC", array("salt" => GetData::$salt));
+ return $res;
+ }
+
+ //Total Data: Time Online, Median Time Online, Number of Sessions > 60Sec, Number of Sessions < 60Sec
+ public static function getOverallStatistics ($from, $to, $lowerTimeBound = 0, $upperTimeBound = 24) {
+ Database::exec("SET SESSION group_concat_max_len = 1000000000");
+ $res = Database::simpleQuery("SELECT SUM(CAST(sessionTable.length AS UNSIGNED)) AS sum, GROUP_CONCAT(sessionTable.length) AS median, SUM(sessionTable.length >= 60) AS longSessions, SUM(sessionTable.length < 60) AS shortSessions
+ FROM ".self::getBoundedTableQueryString('~session-length', $from, $to, $lowerTimeBound, $upperTimeBound)." sessionTable");
+ return $res;
+ }
+
+ // Total Data(2): Time Offline
+ public static function getTotalOfflineStatistics($from, $to, $lowerTimeBound = 0, $upperTimeBound = 24) {
+ $res = Database::simpleQuery("SELECT SUM(CAST(offlineTable.length AS UNSIGNED)) AS timeOff
+ FROM ".self::getBoundedTableQueryString('~offline-length', $from, $to, $lowerTimeBound, $upperTimeBound)." offlineTable");
+ return $res;
+ }
+
+ // query string which provides table with time-cutoff and time-bounds
+ private static function getBoundedTableQueryString($typeid, $from, $to, $lowerTimeBound, $upperTimeBound)
+ {
+ // get Clients that are currently oflfine (the offline time is not yet recorded in the statistic table)
+ $union = $typeid == '~offline-length' ?
+ "union
+ select CAST(IF(lastseen < $from, $from, lastseen) as UNSIGNED) as start, $to as end,
+ '~offline-length' as typeid, machineuuid, 'machine'
+ from machine where lastseen <= $to and UNIX_TIMESTAMP() - lastseen >= 600" : "";
+
+
+ $lowerFormat = "'%y-%m-%d $lowerTimeBound:00:00'";
+ $upperFormat = "'%y-%m-%d ".($upperTimeBound-1).":59:59'";
+ $queryString = "
+ select
+
+ # The whole length of the session/offline time.
+ (end-start
+
+ # Now the time that is not within the daily time bounds is subtracted.
+ # This includes the time before the first daily bound, the time after the last daily bound
+ # and the time between the daily bounds (if a session/offline time spans multiple days)
+
+ # Time before the first daily bound is subtracted.
+ - IF(
+ start > startUpper,
+ UNIX_TIMESTAMP(FROM_UNIXTIME(start, $lowerFormat) + INTERVAL 1 DAY) - start,
+ IF(
+ start < startLower,
+ startLower - start,
+ 0
+ )
+ )
+
+ # Time after the last daily bound is subtracted.
+ - IF(
+ end > endUpper,
+ end - (endUpper + 1),
+ IF(
+ end < endLower,
+ end - (UNIX_TIMESTAMP(FROM_UNIXTIME(end, $upperFormat) - INTERVAL 1 DAY) + 1),
+ 0
+ )
+ )
+
+ # Time between the daily bounds is subtracted.
+ - ( daysDiff - 2
+ + IF(start <= startUpper, 1, 0)
+ + IF(end >= endLower, 1, 0)
+ ) * ((24 - ($upperTimeBound - $lowerTimeBound)) * 3600)
+
+ # If the session crossed a clock change (to/from daylight saving time), the last subtraction may have subtracted
+ # one hour too much or too little. This IF will correct this.
+ - IF(
+ innerStart < innerEnd,
+ IF(
+ timeDiff = 1 AND ($lowerTimeBound >= 2 OR $upperTimeBound <= 2),
+ 3600,
+ IF(timeDiff = -1 AND ($lowerTimeBound >= 3 OR $upperTimeBound <= 3), -3600, 0)
+ ),
+ 0
+ )
+
+ ) as 'length',
+
+ IF(end < endUpper AND end > endLower AND end < $to, end, 0) as endInBound,
+
+ machineuuid
+
+
+ # These nested selects are necessary because some things need to be calculated before others.
+ # (e.g. start is needed to calculate startLower)
+ from (
+ select
+ *,
+
+ # timeDiff is the clock change between innerStart and innerEnd. ( 0 = no clock change)
+ ((CAST(date_format(from_unixtime(innerStart), '%H') as SIGNED) -
+ CAST(date_format(convert_tz(from_unixtime(innerStart), @@session.time_zone, '+00:00'), '%H') as SIGNED) + 24) % 24
+ -
+ (CAST(date_format(from_unixtime(innerEnd), '%H') as SIGNED) -
+ CAST(date_format(convert_tz(from_unixtime(innerEnd), @@session.time_zone, '+00:00'), '%H') as SIGNED) + 24) % 24) as timeDiff
+ from (
+ select
+ *,
+
+ # innerStart and innerEnd are start and end but excluding the time before the first daily upper bound and after the last daily lower bound.
+ CAST(IF(start <= startUpper, startUpper, UNIX_TIMESTAMP(FROM_UNIXTIME(start, $upperFormat) + INTERVAL 1 DAY)) as UNSIGNED) as innerStart,
+ CAST(IF(end >= endLower, endLower, UNIX_TIMESTAMP(FROM_UNIXTIME(end, $lowerFormat) - INTERVAL 1 DAY)) as UNSIGNED) as innerEnd
+ from (
+ select
+ *,
+
+ # daysDiff = how many different days the start and end are apart (0 = start and end on the same day)
+ (TO_DAYS(FROM_UNIXTIME(end, '%y-%m-%d')) - TO_DAYS(FROM_UNIXTIME(start, '%y-%m-%d'))) as daysDiff,
+
+ # startLower = lower daily time bound on the starting day
+ CAST(UNIX_TIMESTAMP(FROM_UNIXTIME(start, $lowerFormat)) as UNSIGNED) as startLower,
+ # startUpper = upper daily time bound on the starting day
+ CAST(UNIX_TIMESTAMP(FROM_UNIXTIME(start, $upperFormat)) as UNSIGNED) as startUpper,
+ # endLower = lower daily time bound on the ending day
+ CAST(UNIX_TIMESTAMP(FROM_UNIXTIME(end, $lowerFormat)) as UNSIGNED) as endLower,
+ # endUpper = upper daily time bound on the ending day
+ CAST(UNIX_TIMESTAMP(FROM_UNIXTIME(end, $upperFormat)) as UNSIGNED) as endUpper
+ from (
+ # Statistic logs (combined with currently offline machines if offline times are requested) .
+ select CAST(IF(dateline < $from, $from, dateline) as UNSIGNED) as start,
+ CAST(IF(dateline+data > $to, $to, dateline+data) as UNSIGNED) as end,
+ typeid, machineuuid, 'statistic'
+ from statistic where dateline+data >= $from and dateline <= $to and typeid = '$typeid'
+ $union
+ ) t
+ ) t
+ ) t
+ ) t
+
+
+ # Filter out the session that are at least overlapping with the time bounds.
+ where (
+ (daysDiff = 0 and (start <= UNIX_TIMESTAMP(FROM_UNIXTIME(start, $upperFormat)) and end >= UNIX_TIMESTAMP(FROM_UNIXTIME(end, $lowerFormat))))
+ or
+ (daysDiff = 1 and (start <= UNIX_TIMESTAMP(FROM_UNIXTIME(start, $upperFormat)) or end >= UNIX_TIMESTAMP(FROM_UNIXTIME(end, $lowerFormat))))
+ or
+ daysDiff >= 2
+ )
+ ";
+ return "(".$queryString.")";
+ }
+}
+
diff --git a/modules-available/statistics_reporting/inc/remotereport.inc.php b/modules-available/statistics_reporting/inc/remotereport.inc.php
new file mode 100644
index 00000000..7aad8b3a
--- /dev/null
+++ b/modules-available/statistics_reporting/inc/remotereport.inc.php
@@ -0,0 +1,84 @@
+<?php
+
+class RemoteReport
+{
+
+ const ENABLED_ID = 'statistics-reporting-enabled';
+ const NEXT_SUBMIT_ID = 'statistics-reporting-next';
+
+ /**
+ * Enable or disable remote reporting of usage statistics.
+ *
+ * @param bool|string $isEnabled true or 'on' if reporting should be enabled
+ */
+ public static function setReportingEnabled($isEnabled)
+ {
+ $value = ($isEnabled === true || $isEnabled === 'on') ? 'on' : '';
+ Property::set(self::ENABLED_ID, $value, 60 * 24 * 14);
+ }
+
+ /**
+ * Returns whether remote reporting is enabled or not.
+ *
+ * @return bool true if reporting is on, false if off
+ */
+ public static function isReportingEnabled()
+ {
+ return Property::get(self::ENABLED_ID, false) === 'on';
+ }
+
+ /**
+ * Get the timestamp of the end of the next 7 day interval to
+ * report statistics for. Usually if this is < time() you want
+ * to generate the report.
+ *
+ * @return int timestamp of the end of the reporting time frame
+ */
+ public static function getReportingTimestamp()
+ {
+ $ts = Property::get(self::NEXT_SUBMIT_ID, 0);
+ if ($ts === 0) {
+ // No timestamp stored yet - might be a fresh install
+ // schedule for next time
+ self::updateNextReportingTimestamp();
+ $ts = Property::get(self::NEXT_SUBMIT_ID, 0);
+ } elseif ($ts < strtotime('last monday')) {
+ // Too long ago, move forward to last monday
+ $ts = strtotime('last monday');
+ }
+ return $ts;
+ }
+
+ /**
+ * Update the timestamp of the next scheduled statistics report.
+ * This sets the end of the next 7 day interval to the start of
+ * next monday (00:00).
+ */
+ public static function writeNextReportingTimestamp()
+ {
+ Property::set(self::NEXT_SUBMIT_ID, strtotime('next monday'), 60 * 24 * 14);
+ }
+
+ /**
+ * Generate the multi-dimensional array containing the anonymized
+ * (weekly) statistics to report.
+ *
+ * @param $from start timestamp
+ * @param $to end timestamp
+ * @return array wrapped up statistics, ready for reporting
+ */
+ public static function generateReport($from, $to) {
+ GetData::$from = $from;
+ GetData::$to = $to;
+ GetData::$salt = bin2hex(Util::randomBytes(20));
+ $data = GetData::total(GETDATA_ANONYMOUS);
+ $data['perLocation'] = GetData::perLocation(GETDATA_ANONYMOUS);
+ $data['perClient'] = GetData::perClient(GETDATA_ANONYMOUS);
+ $data['perUser'] = GetData::perUser(GETDATA_ANONYMOUS);
+ $data['perVM'] = GetData::perVM(GETDATA_ANONYMOUS);
+ $data['tsFrom'] = $from;
+ $data['tsTo'] = $to;
+ return $data;
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/lang/de/messages.json b/modules-available/statistics_reporting/lang/de/messages.json
new file mode 100644
index 00000000..0121e49a
--- /dev/null
+++ b/modules-available/statistics_reporting/lang/de/messages.json
@@ -0,0 +1,4 @@
+{
+ "invalid-table-type": "Ung\u00fcltiger Tabellentyp: {{0}}",
+ "remote-report-disabled": "Anonymer Statistikreport ist deaktiviert :-("
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/lang/de/module.json b/modules-available/statistics_reporting/lang/de/module.json
new file mode 100644
index 00000000..64003d47
--- /dev/null
+++ b/modules-available/statistics_reporting/lang/de/module.json
@@ -0,0 +1,10 @@
+{
+ "module_name": "Statistikauswertung",
+ "notAssigned": "Nicht zugewiesen",
+ "page_title": "Statistikauswertung",
+ "table_client": "Nach Clients",
+ "table_location": "Nach Orten",
+ "table_total": "Gesamt",
+ "table_user": "Nach Benutzern",
+ "table_vm": "Nach Veranstaltungen"
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/lang/de/template-tags.json b/modules-available/statistics_reporting/lang/de/template-tags.json
new file mode 100644
index 00000000..315837de
--- /dev/null
+++ b/modules-available/statistics_reporting/lang/de/template-tags.json
@@ -0,0 +1,22 @@
+{
+ "lang_apply": "Anwenden",
+ "lang_displayColumns": "Auswahl angezeigter Spalten",
+ "lang_displaySelection": "Anzeigemodus, Auswahl Zeitfenster",
+ "lang_downloadReport": "Report herunterladen",
+ "lang_export": "Exportieren",
+ "lang_hostname": "Hostname",
+ "lang_lastLogout": "Letzter Logout",
+ "lang_lastStart": "Letzter Boot",
+ "lang_location": "Ort",
+ "lang_longSessions": "Sitzungen \u2265 60s",
+ "lang_medianSessionLength": "Sitzungsdauer Median",
+ "lang_reportingDescription": "Helfen Sie uns bwLehrpool durch das w\u00f6chentliche Verschicken eines anonymisierten Statistikberichts zu verbessern. Wenn Sie den Inhalt eines solchen Reports genauer inspizieren wollen, k\u00f6nnen Sie \u00fcber den untenstehenden Button einen aktuellen Report ihres Servers herunterladen.",
+ "lang_reportingLabel": "Anonymisierte Nutzungsstatistiken herunterladen",
+ "lang_sessions": "Sitzungen",
+ "lang_shortSessions": "Sitzungen < 60s",
+ "lang_total": "Gesamt",
+ "lang_totalOffTime": "Gesamtzeit offline",
+ "lang_totalTime": "Gesamtzeit",
+ "lang_user": "Nutzer",
+ "lang_vm": "Veranstaltung"
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/lang/en/messages.json b/modules-available/statistics_reporting/lang/en/messages.json
new file mode 100644
index 00000000..cd423426
--- /dev/null
+++ b/modules-available/statistics_reporting/lang/en/messages.json
@@ -0,0 +1,4 @@
+{
+ "invalid-table-type": "Invalid table type: {{0}}",
+ "remote-report-disabled": "Anonymous statistics report is disabled :-("
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/lang/en/module.json b/modules-available/statistics_reporting/lang/en/module.json
new file mode 100644
index 00000000..f5ed37d3
--- /dev/null
+++ b/modules-available/statistics_reporting/lang/en/module.json
@@ -0,0 +1,10 @@
+{
+ "module_name": "Statistics Reporting",
+ "notAssigned": "Not assigned",
+ "page_title": "Statistics Reporting",
+ "table_client": "By client",
+ "table_location": "By location",
+ "table_total": "Total",
+ "table_user": "By user",
+ "table_vm": "By lecture"
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/lang/en/template-tags.json b/modules-available/statistics_reporting/lang/en/template-tags.json
new file mode 100644
index 00000000..a4865931
--- /dev/null
+++ b/modules-available/statistics_reporting/lang/en/template-tags.json
@@ -0,0 +1,22 @@
+{
+ "lang_apply": "Apply",
+ "lang_displayColumns": "Select columns to display",
+ "lang_displaySelection": "Select display mode and specify time span",
+ "lang_downloadReport": "Download report",
+ "lang_export": "Export",
+ "lang_hostname": "Hostname",
+ "lang_lastLogout": "Last logout",
+ "lang_lastStart": "Last boot",
+ "lang_location": "Location",
+ "lang_longSessions": "Sessions \u2265 60s",
+ "lang_medianSessionLength": "Median Session Length",
+ "lang_reportingDescription": "Help us improve bwLehrpool by automatically sending an anonymized statistics report once a week. If you want to check what data the report contains, you can download such a report for reference below.",
+ "lang_reportingLabel": "Send anonymized usage statistics",
+ "lang_sessions": "Sessions",
+ "lang_shortSessions": "Sessions < 60s",
+ "lang_total": "Total",
+ "lang_totalOffTime": "Total Time Offline",
+ "lang_totalTime": "Total Time",
+ "lang_user": "User",
+ "lang_vm": "Lecture"
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/lang/pt/template-tags.json b/modules-available/statistics_reporting/lang/pt/template-tags.json
new file mode 100644
index 00000000..e7981844
--- /dev/null
+++ b/modules-available/statistics_reporting/lang/pt/template-tags.json
@@ -0,0 +1,3 @@
+{
+ "lang_hello": "Olá"
+} \ No newline at end of file
diff --git a/modules-available/statistics_reporting/page.inc.php b/modules-available/statistics_reporting/page.inc.php
new file mode 100644
index 00000000..6bd908a5
--- /dev/null
+++ b/modules-available/statistics_reporting/page.inc.php
@@ -0,0 +1,279 @@
+<?php
+
+
+class Page_Statistics_Reporting extends Page
+{
+
+ private $action;
+ private $type;
+
+ // "Constants"
+ private $days;
+
+ /**
+ * @var array Names of columns that are being used by the various tables
+ */
+ private $COLUMNS = array('location', 'totalTime', 'medianSessionLength', 'sessions', 'longSessions', 'shortSessions',
+ 'totalOffTime', 'lastLogout', 'lastStart');
+
+ /**
+ * @var array Names of the tables we can display
+ */
+ private $TABLES = array('total', 'location', 'client', 'user', 'vm');
+
+ /**
+ * Called before any page rendering happens - early hook to check parameters etc.
+ */
+ protected function doPreprocess()
+ {
+ User::load();
+
+ if (!User::isLoggedIn()) {
+ Message::addError('main.no-permission');
+ Util::redirect('?do=Main'); // does not return
+ }
+
+ $this->action = Request::any('action', 'show', 'string');
+ $this->type = Request::get('type', 'total', 'string');
+ $this->days = Request::get('cutoff', 7, 'int');
+ $this->lower = Request::get('lower', 8, 'int');
+ $this->upper = Request::get('upper', 20, 'int');
+
+ if (!in_array($this->type, $this->TABLES)) {
+ Message::addError('invalid-table-type', $this->type);
+ $this->type = 'total';
+ }
+
+ // timespan you want to see. default = last 7 days
+ GetData::$from = strtotime("- " . ($this->days - 1) . " days 00:00:00");
+ GetData::$to = time();
+ GetData::$lowerTimeBound = $this->lower;
+ GetData::$upperTimeBound = $this->upper;
+
+ // Export - handle in doPreprocess so we don't render the menu etc.
+ if ($this->action === 'export') {
+ $this->doExport();
+ // Does not return
+ }
+ // Get report - fetch data exactly the way it would automatically be reported
+ // so the user can know what is going on
+ if ($this->action === 'getreport') {
+ $report = RemoteReport::generateReport(strtotime('-7 days'), time('now'));
+ Header('Content-Disposition: attachment; filename=remote-report.json');
+ Header('Content-Type: application/json; charset=utf-8');
+ die(json_encode($report));
+ }
+ }
+
+ /**
+ * Menu etc. has already been generated, now it's time to generate page content.
+ */
+ protected function doRender()
+ {
+ if ($this->action === 'show') {
+
+ /*
+ * Leave these here for the translate module
+ * Dictionary::translate('table_total');
+ * Dictionary::translate('table_location');
+ * Dictionary::translate('table_client');
+ * Dictionary::translate('table_user');
+ * Dictionary::translate('table_vm');
+ */
+
+ $data = array(
+ 'columns' => array(),
+ 'tables' => array(),
+ 'days' => array()
+ );
+
+ $forceOn = (Request::get('type') === false);
+ foreach ($this->COLUMNS as $column) {
+ $data['columns'][] = array(
+ 'id' => 'col_' . $column,
+ 'name' => Dictionary::translateFile('template-tags', 'lang_' . $column, true),
+ 'checked' => ($forceOn || Request::get('col_' . $column, 'off', 'string') !== 'off') ? 'checked' : '',
+ );
+ }
+
+ foreach ($this->TABLES as $table) {
+ $data['tables'][] = array(
+ 'name' => Dictionary::translate('table_' . $table, true),
+ 'value' => $table,
+ 'selected' => ($this->type === $table) ? 'selected' : '',
+ );
+ }
+
+ foreach (array(1,2,5,7,14,30,90) as $day) {
+ $data['days'][] = array(
+ 'days' => $day,
+ 'selected' => ($day === $this->days) ? 'selected' : '',
+ );
+ }
+
+ $data['lower'] = $this->lower;
+ $data['upper'] = $this->upper;
+
+ if (RemoteReport::isReportingEnabled()) {
+ $data['settingsButtonClass'] = 'default';
+ $data['reportChecked'] = 'checked';
+ } else {
+ $data['settingsButtonClass'] = 'danger';
+ }
+
+ Render::addTemplate('columnChooser', $data);
+
+ $data['data'] = $this->fetchData(GETDATA_PRINTABLE);
+ Render::addTemplate('table-' . $this->type, $data);
+ }
+ }
+
+ protected function doAjax()
+ {
+ $this->action = Request::any('action', false, 'string');
+ if ($this->action === 'setReporting') {
+ if (!User::isLoggedIn()) {
+ die("No.");
+ }
+ $state = Request::post('reporting', false, 'string');
+ if ($state === false) {
+ die('Missing setting value.');
+ }
+ RemoteReport::setReportingEnabled($state);
+ $data = array();
+ if (RemoteReport::isReportingEnabled()) {
+ $data['class'] = 'default';
+ $data['checked'] = true;
+ } else {
+ $data['class'] = 'danger';
+ }
+ Header('Content-Type: application/json; charset=utf-8');
+ die(json_encode($data));
+ } else {
+ echo 'Invalid action.';
+ }
+ }
+
+ private function doExport()
+ {
+ $format = Request::get('format', 'json', 'string');
+ $printable = (bool)Request::get('printable', 0, 'int');
+ $flags = 0;
+ if ($printable) {
+ $flags |= GETDATA_PRINTABLE;
+ }
+ $res = $this->fetchData($flags);
+ // Filter unwanted columns
+ if (isset($res[0])) {
+ foreach ($this->COLUMNS as $column) {
+ if (Request::get('col_' . $column, 'delete', 'string') === 'delete') {
+ foreach ($res as &$row) {
+ unset($row[$column], $row[$column . '_s']);
+ if ($column === 'location') {
+ unset($row['locationId']);
+ }
+ }
+ } elseif ($printable && isset($row[0][$column . '_s'])) {
+ foreach ($res as &$row) {
+ unset($row[$column]);
+ }
+ } elseif ($column === 'location' && (isset($res[0]['location']) || isset($res[0]['locationId']))) {
+ foreach ($res as &$row) {
+ if ($printable) {
+ unset($row['locationId']);
+ } else {
+ unset($row['location']);
+ }
+ }
+ }
+ }
+ unset($row);
+ }
+ Header('Content-Disposition: attachment; filename=' . 'statistics-' . date('Y.m.d-H.i.s') . '.' . $format);
+ switch ($format) {
+ case 'json':
+ Header('Content-Type: application/json; charset=utf-8');
+ $output = json_encode(array('data' => $res));
+ break;
+ case 'csv':
+ if (!is_array($res)) {
+ die('Error fetching data.');
+ }
+ Header('Content-Type: text/csv; charset=utf-8');
+ $fh = fopen('php://output', 'w');
+ // Output UTF-8 BOM - Excel needs this to automatically decode as UTF-8
+ // (and since Excel is the only sane reason to export as csv, just always do it)
+ fputs($fh, chr(239) . chr(187) . chr(191));
+ // Output
+ if (isset($res[0]) && is_array($res[0])) {
+ // List of rows
+ fputcsv($fh, array_keys($res[0]), ';');
+ foreach ($res as $row) {
+ fputcsv($fh, $row, ';');
+ }
+ } else {
+ // Single assoc array
+ fputcsv($fh, array_keys($res), ';');
+ fputcsv($fh, $res, ';');
+ }
+ fclose($fh);
+ exit();
+ break;
+ case 'xml':
+ $xml_data = new SimpleXMLElement('<?xml version="1.0" encoding="utf-8" ?><data></data>');
+ $this->array_to_xml($res, $xml_data, 'row');
+ $output = $xml_data->asXML();
+ Header('Content-Type: text/xml; charset=utf-8');
+ break;
+ default:
+ Header('Content-Type: text/plain');
+ $output = 'Invalid format: ' . $format;
+ }
+ die($output);
+ }
+
+ /**
+ * @param $data array Data to encode
+ * @param $xml_data \SimpleXMLElement XML Object to append to
+ */
+ private function array_to_xml($data, $xml_data, $parentName = 'row')
+ {
+ foreach ($data as $key => $value) {
+ if (is_numeric($key)) {
+ $key = $parentName;
+ }
+ if (is_array($value)) {
+ $subnode = $xml_data->addChild($key);
+ $this->array_to_xml($value, $subnode, $key);
+ } else {
+ $xml_data->addChild($key, htmlspecialchars($value));
+ }
+ }
+ }
+
+ private function fetchData($flags)
+ {
+ switch ($this->type) {
+ case 'total':
+ return GetData::total($flags);
+ case 'location':
+ $data = GetData::perLocation($flags);
+ $highlight = Request::get('location', false, 'int');
+ if ($highlight !== false) {
+ foreach ($data as &$row) {
+ if ($row['locationId'] == $highlight) {
+ $row['highlight'] = true;
+ }
+ }
+ }
+ return $data;
+ case 'client':
+ return GetData::perClient($flags);
+ case 'user':
+ return GetData::perUser($flags);
+ case 'vm':
+ return GetData::perVM($flags);
+ }
+ }
+
+}
diff --git a/modules-available/statistics_reporting/style.css b/modules-available/statistics_reporting/style.css
new file mode 100644
index 00000000..81dc74b0
--- /dev/null
+++ b/modules-available/statistics_reporting/style.css
@@ -0,0 +1,42 @@
+.top-row {
+ margin-bottom: 10px;
+}
+
+.top-row > * {
+ margin-right: 10px;
+ margin-bottom: 10px;
+}
+
+.top-row #button-settings {
+ margin-right: 0;
+}
+
+.buttonbar button {
+ margin-bottom: 4px;
+}
+
+.buttonbar {
+ margin-bottom: 20px;
+}
+
+#slider {
+ display: inline-block;
+ width: 160px;
+ margin-left: 20px;
+ margin-bottom: 3px;
+ margin-right: 20px;
+}
+
+#lower-handle, #upper-handle {
+ width: 3em;
+ height: 1.6em;
+ top: 50%;
+ margin-top: -.8em;
+ margin-left: -1.5em;
+ text-align: center;
+ line-height: 1.6em;
+}
+
+th[data-sort] {
+ cursor: pointer;
+}
diff --git a/modules-available/statistics_reporting/templates/columnChooser.html b/modules-available/statistics_reporting/templates/columnChooser.html
new file mode 100644
index 00000000..f08daf1c
--- /dev/null
+++ b/modules-available/statistics_reporting/templates/columnChooser.html
@@ -0,0 +1,179 @@
+<form method="get" id="controlsForm">
+ <input type="hidden" name="do" value="statistics_reporting">
+ <div class="row">
+ <div class="col-md-12">
+ <button id="button-settings" type="button" class="pull-right btn btn-{{settingsButtonClass}}" data-toggle="modal" data-target="#modal-settings"><span class="glyphicon glyphicon-cog"></span></button>
+ <strong class="text-capitalize">{{lang_displaySelection}}</strong>
+ </div>
+ </div>
+ <div class="row top-row">
+ <div class="col-md-4">
+ <select name="type" id="select-table" class="form-control">
+ {{#tables}}
+ <option value="{{value}}" {{selected}}>{{name}}</option>
+ {{/tables}}
+ </select>
+ </div>
+ <div class="col-md-4">
+ <select name="cutoff" id="select-cutoff" class="form-control">
+ {{#days}}
+ <option value="{{days}}" {{selected}}>{{days}} {{lang_days}}</option>
+ {{/days}}
+ </select>
+ </div>
+ <div class="col-md-3">
+ <div id="slider">
+ <div id="lower-handle" class="ui-slider-handle"></div>
+ <div id="upper-handle" class="ui-slider-handle"></div>
+ <input type="hidden" id="lower-field" name="lower" value="{{lower}}">
+ <input type="hidden" id="upper-field" name="upper" value="{{upper}}">
+ </div>
+ </div>
+ </div>
+ <div class="row top-row">
+ <div class="col-md-12 form-inline">
+ <div><strong class="text-capitalize">{{lang_displayColumns}}</strong></div>
+ {{#columns}}
+ <div class="checkbox">
+ <input id="id_{{id}}" name="{{id}}" value="on" type="checkbox" class="column-toggle form-control" {{checked}}>
+ <label for="id_{{id}}">{{name}}</label>
+ </div>
+ {{/columns}}
+ </div>
+ </div>
+ <div class="row top-row">
+ <div class="col-md-12 form-inline">
+ <div class="pull-right input-group">
+ <select class="form-control" name="format">
+ <option value="json">JSON</option>
+ <option value="csv">CSV (Excel)</option>
+ <option value="xml">XML</option>
+ </select>
+ <div class="input-group-btn">
+ <button type="submit" class="btn btn-default" name="action" value="export">{{lang_export}}</button>
+ </div>
+ </div>
+ <button type="submit" class="btn btn-primary">{{lang_apply}}</button>
+ </div>
+ </div>
+</form>
+
+<hr>
+
+<div id="modal-settings" class="modal fade" role="dialog">
+ <div class="modal-dialog">
+
+ <!-- Modal content-->
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal">&times;</button>
+ <h4 class="modal-title"><b>Settings</b></h4>
+ </div>
+ <div class="modal-body">
+ <div class="checkbox">
+ <input id="checkbox-reporting" type="checkbox" value="on" {{reportChecked}}>
+ <label for="checkbox-reporting" style="padding-left: 40px">{{lang_reportingLabel}}</label>
+ </div>
+ <div>
+ <p>{{lang_reportingDescription}}</p>
+ <a class="btn btn-success" href="?do=statistics_reporting&amp;action=getreport">{{lang_downloadReport}}</a>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-primary" data-dismiss="modal" onclick="saveSettings()">{{lang_save}}</button>
+ </div>
+ </div>
+
+ </div>
+</div>
+
+<script type="application/javascript">
+
+ document.addEventListener("DOMContentLoaded", function () {
+ var lowerHandle = $("#lower-handle");
+ var upperHandle = $("#upper-handle");
+ var lower = $('#lower-field').val();
+ var upper = $('#upper-field').val();
+ $( "#slider" ).slider({
+ range: true,
+ min: 0,
+ max: 24,
+ values: [ lower, upper ],
+ create: function() {
+ lowerHandle.text( lower+":00" );
+ upperHandle.text( upper+":00" );
+ },
+ slide: function(event, ui) {
+ lowerHandle.text(ui.values[0]+":00");
+ upperHandle.text(ui.values[1]+":00");
+ $('#lower-field').val(ui.values[0]);
+ $('#upper-field').val(ui.values[1]);
+ },
+ });
+
+ var table = $("table").stupidtable();
+ table.on("aftertablesort", function (event, data) {
+ var th = $(this).find("th");
+ th.find(".arrow").remove();
+ var dir = $.fn.stupidtable.dir;
+ var arrow = data.direction === dir.ASC ? "up" : "down";
+ th.eq(data.column).append(' <span class="arrow glyphicon glyphicon-chevron-'+arrow+'"></span>');
+ });
+
+ $(".locationLink").click(function(e) {
+ e.preventDefault();
+ var form = $('#controlsForm');
+ var inp = $('#location-id');
+ if (inp.length === 0) {
+ inp = $('<input />').attr('type', 'hidden')
+ .attr('name', "location")
+ .attr('id', 'location-id')
+ .appendTo(form);
+ }
+ inp.attr('value', $(this).data('lid'));
+ form.find('#select-table').val("location");
+ form.submit();
+ });
+
+ $('.column-toggle').change(function () {
+ updateColumn($(this));
+ });
+ $('.column-toggle').each(function () {
+ var box = $(this);
+ if ($('.' + box.attr('name')).length === 0) {
+ if (!box.is(':checked')) {
+ box.attr('value', 'off');
+ box.prop('checked', true);
+ }
+ box.parent().hide();
+ } else {
+ updateColumn(box);
+ }
+ });
+ });
+
+ function updateColumn(checkbox) {
+ var cols = $('.' + checkbox.attr('name'));
+ if (checkbox.is(':checked')) {
+ cols.show();
+ } else {
+ cols.hide();
+ }
+ }
+
+ function saveSettings() {
+ $.ajax({
+ url: '?do=statistics_reporting',
+ type: 'POST',
+ data: { action: "setReporting", reporting: $("#checkbox-reporting").is(":checked") ? "on" : "off", token: TOKEN },
+ success: function(value) {
+ if (typeof(value) === 'object') {
+ $("#checkbox-reporting").prop('checked', !!value['checked']);
+ $("#button-settings").removeClass('btn-default btn-danger').addClass('btn-' + value['class']);
+ } else {
+ alert('Invalid reply when setting value: ' + value + ' (' + typeof(value) + ')');
+ }
+ }
+ });
+ }
+</script> \ No newline at end of file
diff --git a/modules-available/statistics_reporting/templates/table-client.html b/modules-available/statistics_reporting/templates/table-client.html
new file mode 100644
index 00000000..be504cef
--- /dev/null
+++ b/modules-available/statistics_reporting/templates/table-client.html
@@ -0,0 +1,30 @@
+<table id="table-perclient" class="table table-condensed table-striped">
+ <thead>
+ <tr>
+ <th data-sort="string" class="text-left col-md-4">{{lang_hostname}}</th>
+ <th data-sort="string" class="text-left col_location">{{lang_location}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_totalTime">{{lang_totalTime}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_medianSessionLength">{{lang_medianSessionLength}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_longSessions">{{lang_longSessions}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_shortSessions">{{lang_shortSessions}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_totalOffTime">{{lang_totalOffTime}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_lastLogout">{{lang_lastLogout}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_lastStart">{{lang_lastStart}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#data}}
+ <tr>
+ <td class="text-left">{{hostname}}</td>
+ <td class="text-left col_location"><a class="locationLink" href="#" data-lid="{{locationId}}">{{location}}</a></td>
+ <td data-sort-value="{{totalTime}}" class="text-left col_totalTime">{{totalTime_s}}</td>
+ <td data-sort-value="{{medianSessionLength}}" class="text-left col_medianSessionLength">{{medianSessionLength_s}}</td>
+ <td class="text-left col_longSessions">{{longSessions}}</td>
+ <td class="text-left col_shortSessions">{{shortSessions}}</td>
+ <td data-sort-value="{{totalOffTime}}" class="text-left col_totalOffTime">{{totalOffTime_s}}</td>
+ <td data-sort-value="{{lastLogout}}" class="text-left col_lastLogout">{{lastLogout_s}}</td>
+ <td data-sort-value="{{lastStart}}" class="text-left col_lastStart">{{lastStart_s}}</td>
+ </tr>
+ {{/data}}
+ </tbody>
+</table>
diff --git a/modules-available/statistics_reporting/templates/table-location.html b/modules-available/statistics_reporting/templates/table-location.html
new file mode 100644
index 00000000..ccac623d
--- /dev/null
+++ b/modules-available/statistics_reporting/templates/table-location.html
@@ -0,0 +1,24 @@
+<table id="table-perlocation" class="table table-condensed table-striped">
+ <thead>
+ <tr>
+ <th data-sort="string" class="text-left col-md-2">{{lang_location}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_totalTime">{{lang_totalTime}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_medianSessionLength">{{lang_medianSessionLength}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_longSessions">{{lang_longSessions}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_shortSessions">{{lang_shortSessions}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_totalOffTime">{{lang_totalOffTime}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#data}}
+ <tr{{#highlight}} class="info"{{/highlight}}>
+ <td class="locationName text-left">{{location}}</td>
+ <td data-sort-value="{{totalTime}}" class="text-left col_totalTime">{{totalTime_s}}</td>
+ <td data-sort-value="{{medianSessionLength}}" class="text-left col_medianSessionLength">{{medianSessionLength_s}}</td>
+ <td class="text-left col_longSessions">{{longSessions}}</td>
+ <td class="text-left col_shortSessions">{{shortSessions}}</td>
+ <td data-sort-value="{{totalOffTime}}" class="text-left col_totalOffTime">{{totalOffTime_s}}</td>
+ </tr>
+ {{/data}}
+ </tbody>
+</table>
diff --git a/modules-available/statistics_reporting/templates/table-total.html b/modules-available/statistics_reporting/templates/table-total.html
new file mode 100644
index 00000000..4048a178
--- /dev/null
+++ b/modules-available/statistics_reporting/templates/table-total.html
@@ -0,0 +1,22 @@
+<table id="table-total" class="table table-condensed table-striped">
+ <thead>
+ <tr>
+ <th class="text-left col-md-2"></th>
+ <th class="text-left col_totalTime">{{lang_totalTime}}</th>
+ <th class="text-left col_medianSessionLength">{{lang_medianSessionLength}}</th>
+ <th class="text-left col_longSessions">{{lang_longSessions}}</th>
+ <th class="text-left col_shortSessions">{{lang_shortSessions}}</th>
+ <th class="text-left col_totalOffTime">{{lang_totalOffTime}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <th class="text-left">{{lang_total}}</th>
+ <td class="text-left col_totalTime">{{data.totalTime_s}}</td>
+ <td class="text-left col_medianSessionLength">{{data.medianSessionLength_s}}</td>
+ <td class="text-left col_longSessions">{{data.longSessions}}</td>
+ <td class="text-left col_shortSessions">{{data.shortSessions}}</td>
+ <td class="text-left col_totalOffTime">{{data.totalOffTime_s}}</td>
+ </tr>
+ </tbody>
+</table>
diff --git a/modules-available/statistics_reporting/templates/table-user.html b/modules-available/statistics_reporting/templates/table-user.html
new file mode 100644
index 00000000..5c2ba56f
--- /dev/null
+++ b/modules-available/statistics_reporting/templates/table-user.html
@@ -0,0 +1,16 @@
+<table id="table-peruser" class="table table-condensed table-striped">
+ <thead>
+ <tr>
+ <th data-sort="string" class="text-left col-md-4">{{lang_user}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_sessions">{{lang_sessions}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#data}}
+ <tr>
+ <td class="text-left">{{user}}</td>
+ <td class="text-left col_sessions">{{sessions}}</td>
+ </tr>
+ {{/data}}
+ </tbody>
+</table>
diff --git a/modules-available/statistics_reporting/templates/table-vm.html b/modules-available/statistics_reporting/templates/table-vm.html
new file mode 100644
index 00000000..9a775709
--- /dev/null
+++ b/modules-available/statistics_reporting/templates/table-vm.html
@@ -0,0 +1,19 @@
+<table id="table-pervm" class="table table-condensed table-striped">
+ <thead>
+ <tr>
+ <th data-sort="string" class="text-left col-md-4">{{lang_vm}}</th>
+ <th data-sort="int" data-sort-default="desc" class="text-left col_sessions">{{lang_sessions}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#data}}
+ <tr>
+ <td class="text-left">{{vm}}</td>
+ <td class="text-left col_sessions">{{sessions}}</td>
+ </tr>
+ {{/data}}
+ </tbody>
+</table>
+
+
+