summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimon Rettberg2021-09-21 16:52:06 +0200
committerSimon Rettberg2022-03-09 15:06:54 +0100
commit67e6b6c9981207d7d658f2ad2bf1c39b75c099c7 (patch)
treed2f54c424dd983a1500acf0d3d0bc3bbfee6e9cd
parent[statistics] Support new json-format of hardware info from client (diff)
downloadslx-admin-67e6b6c9981207d7d658f2ad2bf1c39b75c099c7.tar.gz
slx-admin-67e6b6c9981207d7d658f2ad2bf1c39b75c099c7.tar.xz
slx-admin-67e6b6c9981207d7d658f2ad2bf1c39b75c099c7.zip
[passthrough] New module for managing hardware passthrough for QEMU
-rw-r--r--modules-available/minilinux/inc/linuxbootentryhook.inc.php20
-rw-r--r--modules-available/passthrough/config.json7
-rw-r--r--modules-available/passthrough/inc/passthrough.inc.php53
-rw-r--r--modules-available/passthrough/install.inc.php9
-rw-r--r--modules-available/passthrough/page.inc.php128
-rw-r--r--modules-available/passthrough/permissions/permissions.json3
-rw-r--r--modules-available/passthrough/templates/hardware-list.html125
-rw-r--r--modules-available/statistics/api.inc.php11
-rw-r--r--modules-available/statistics/inc/hardwareinfo.inc.php69
-rw-r--r--modules-available/statistics/inc/hardwareparser.inc.php83
-rw-r--r--modules-available/statistics/inc/hardwarequery.inc.php73
-rw-r--r--modules-available/statistics/inc/pciid.inc.php76
-rw-r--r--modules-available/statistics/install.inc.php11
-rw-r--r--modules-available/statistics/page.inc.php78
-rw-r--r--modules-available/statistics/pages/hints.inc.php77
-rw-r--r--modules-available/statistics/templates/hints-ram-underclocked.html44
-rw-r--r--modules-available/statistics/templates/hints-ram-upgrade.html31
17 files changed, 793 insertions, 105 deletions
diff --git a/modules-available/minilinux/inc/linuxbootentryhook.inc.php b/modules-available/minilinux/inc/linuxbootentryhook.inc.php
index e57336f0..9e10bd6e 100644
--- a/modules-available/minilinux/inc/linuxbootentryhook.inc.php
+++ b/modules-available/minilinux/inc/linuxbootentryhook.inc.php
@@ -15,7 +15,7 @@ class LinuxBootEntryHook extends BootEntryHook
return Dictionary::translateFileModule('minilinux', 'module', 'module_name', true);
}
- public function extraFields()
+ public function extraFields(): array
{
/* For translate module:
* Dictionary::translate('ipxe-kcl-extra');
@@ -32,7 +32,7 @@ class LinuxBootEntryHook extends BootEntryHook
/**
* @return HookEntryGroup[]
*/
- protected function groupsInternal()
+ protected function groupsInternal(): array
{
/*
* Dictionary::translate('default_boot_entry');
@@ -76,7 +76,7 @@ class LinuxBootEntryHook extends BootEntryHook
* @param $localData
* @return BootEntry the actual boot entry instance for given entry, false if invalid id
*/
- public function getBootEntryInternal($localData)
+ public function getBootEntryInternal($localData): BootEntry
{
$id = $localData['id'];
if ($id === 'default') { // Special case
@@ -125,7 +125,7 @@ class LinuxBootEntryHook extends BootEntryHook
return BootEntry::newStandardBootEntry($bios, $efi, $arch);
}
- private function generateExecData($effectiveId, $remoteData, $localData)
+ private function generateExecData($effectiveId, $remoteData, $localData): ExecData
{
$exec = new ExecData();
// Defaults
@@ -146,9 +146,7 @@ class LinuxBootEntryHook extends BootEntryHook
if (!empty($localData['debug'])) {
// Debug boot enabled
$exec->commandLine = IPxe::modifyCommandLine($exec->commandLine,
- isset($remoteData['debugCommandLineModifier'])
- ? $remoteData['debugCommandLineModifier']
- : '-vga -quiet -splash -loglevel loglevel=7'
+ $remoteData['debugCommandLineModifier'] ?? '-vga -quiet -splash -loglevel loglevel=7'
);
}
// disable all CPU sidechannel attack mitigations etc.
@@ -156,6 +154,14 @@ class LinuxBootEntryHook extends BootEntryHook
$exec->commandLine = IPxe::modifyCommandLine($exec->commandLine,
'noibrs noibpb nopti nospectre_v2 nospectre_v1 l1tf=off nospec_store_bypass_disable no_stf_barrier mds=off mitigations=off');
}
+ // GVT, PCI Pass-thru etc.
+ if (Module::isAvailable('statistics')) {
+ $hwextra = HardwareInfo::getKclModifications();
+ if (!empty($hwextra)) {
+ $exec->commandLine = IPxe::modifyCommandLine($exec->commandLine, $hwextra);
+ }
+ }
+ // User-supplied modifications
if (!empty($localData['kcl-extra'])) {
$exec->commandLine = IPxe::modifyCommandLine($exec->commandLine, $localData['kcl-extra']);
}
diff --git a/modules-available/passthrough/config.json b/modules-available/passthrough/config.json
new file mode 100644
index 00000000..3f65995f
--- /dev/null
+++ b/modules-available/passthrough/config.json
@@ -0,0 +1,7 @@
+{
+ "category": "main.settings-client",
+ "dependencies": [
+ "statistics",
+ "locations"
+ ]
+} \ No newline at end of file
diff --git a/modules-available/passthrough/inc/passthrough.inc.php b/modules-available/passthrough/inc/passthrough.inc.php
new file mode 100644
index 00000000..524aea5e
--- /dev/null
+++ b/modules-available/passthrough/inc/passthrough.inc.php
@@ -0,0 +1,53 @@
+<?php
+
+class Passthrough
+{
+
+ public static function getGroupDropdown(array &$row): array
+ {
+ $out = [];
+ if ($row['class'] === '0300') {
+ foreach (['GPU', 'GVT'] as $id) {
+ $out[] = [
+ 'ptid' => $id,
+ 'ptname' => $id,
+ 'selected' => ($row['@PASSTHROUGH'] === $id ? 'selected' : ''),
+ ];
+ }
+ return $out;
+ }
+ static $list = false;
+ if ($list === false) {
+ $list = Database::queryKeyValueList("SELECT groupid, title FROM passthrough_group ORDER BY groupid");
+ self::ensurePrepopulated($list);
+ }
+ $row['custom_groups'] = true;
+ foreach ($list as $id => $title) {
+ if ($id === 'GPU' || $id === 'GVT')
+ continue;
+ $item = ['ptid' => $id, 'ptname' => $id . ' (' . $title . ')'];
+ if ($row['@PASSTHROUGH'] === $id) {
+ $item['selected'] = 'selected';
+ }
+ $out[] = $item;
+ }
+ return $out;
+ }
+
+ private static function ensurePrepopulated(&$list)
+ {
+ $want = [
+ 'GPU' => '[Special] GPU passthrough default group',
+ 'GVT' => '[Special] Intel GVT-g default group',
+ ];
+ foreach ($want as $id => $title) {
+ if (!isset($list[$id])) {
+ Database::exec("INSERT INTO passthrough_group (groupid, title) VALUES (:id, :title)
+ ON DUPLICATE KEY UPDATE title = VALUES(title)",
+ ['id' => $id, 'title' => $title]);
+ $list[$id] = $title;
+ }
+ }
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/passthrough/install.inc.php b/modules-available/passthrough/install.inc.php
new file mode 100644
index 00000000..e08be38b
--- /dev/null
+++ b/modules-available/passthrough/install.inc.php
@@ -0,0 +1,9 @@
+<?php
+
+$result[] = tableCreate('passthrough_group', "
+ `groupid` varchar(32) CHARACTER SET ascii DEFAULT NULL,
+ `title` varchar(200) NOT NULL,
+ PRIMARY KEY (`groupid`)
+");
+
+responseFromArray($result); \ No newline at end of file
diff --git a/modules-available/passthrough/page.inc.php b/modules-available/passthrough/page.inc.php
new file mode 100644
index 00000000..3ab0696e
--- /dev/null
+++ b/modules-available/passthrough/page.inc.php
@@ -0,0 +1,128 @@
+<?php
+
+class Page_Passthrough extends Page
+{
+
+ protected function doPreprocess()
+ {
+ User::load();
+ User::assertPermission('view');
+ $action = Request::post('action');
+ if ($action === 'save-hwlist') {
+ $this->saveHwList();
+ }
+ if (Request::isPost()) {
+ Util::redirect('?do=passthrough');
+ }
+ }
+
+ private function saveHwList()
+ {
+ $newgroups = Request::post('newgroup', [], 'array');
+ foreach ($newgroups as $id => $title) {
+ $id = strtoupper(preg_replace('/[^a-z0-9_\-]/i', '', $id));
+ if (empty($id))
+ continue;
+ Database::exec("INSERT IGNORE INTO passthrough_group (groupid, title)
+ VALUES (:group, :title)",
+ ['group' => $id, 'title' => $title]);
+ }
+ $groups = Request::post('ptgroup', Request::REQUIRED, 'array');
+ $insert = [];
+ $delete = [];
+ foreach ($groups as $hwid => $group) {
+ if (empty($group)) {
+ $delete[] = $hwid;
+ } else {
+ $insert[] = [$hwid, '@PASSTHROUGH', $group];
+ }
+ }
+ if (!empty($insert)) {
+ Database::exec("INSERT INTO statistic_hw_prop (hwid, prop, `value`) VALUES :list
+ ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)",
+ ['list' => $insert]);
+ }
+ if (!empty($delete)) {
+ Database::exec("DELETE FROM statistic_hw_prop WHERE hwid IN (:list) AND prop = '@PASSTHROUGH'", ['list' => $delete]);
+ }
+ Message::addSuccess('list-updated');
+ Util::redirect('?do=passthrough&show=hwlist');
+ }
+
+ /*
+ *
+ */
+
+ protected function doRender()
+ {
+ $show = Request::get('show');
+ if ($show === 'hwlist') {
+ $this->showHardwareList();
+ } else {
+ Util::redirect('?do=passthrough&show=hwlist');
+ }
+ }
+
+ private function showHardwareList()
+ {
+ $q = new HardwareQuery(HardwareInfo::PCI_DEVICE, null, false);
+ $q->addGlobalColumn('vendor');
+ $q->addGlobalColumn('device');
+ $q->addGlobalColumn('class');
+ $q->addGlobalColumn('@PASSTHROUGH');
+ $rows = [];
+ foreach ($q->query('shw.hwid') as $row) {
+ $row['ptlist'] = Passthrough::getGroupDropdown($row);
+ $rows[] = $row;
+ }
+ // Sort Graphics Cards first, rest by class, vendor, device
+ usort($rows, function ($row1, $row2) {
+ $a = $row1['class'];
+ $b = $row2['class'];
+ if ($a === $b)
+ return hexdec($row1['vendor'].$row1['device']) - hexdec($row2['vendor'] . $row2['device']);
+ if ($a === '0300')
+ return -1;
+ if ($b === '0300')
+ return 1;
+ return hexdec($a) - hexdec($b);
+ });
+ $finalRows = [];
+ $missing = [];
+ $lastClass = '';
+ foreach ($rows as $row) {
+ if ($row['class'] !== $lastClass) {
+ // Add class row header
+ $lastClass = $row['class'];
+ $finalRows[] = [
+ 'class' => $row['class'],
+ 'class_name' => PciId::getPciId(PciId::DEVCLASS, $row['class'], true) ?: 'Unknown',
+ ];
+ }
+ $row['vendor_name'] = PciId::getPciId(PciId::VENDOR, $row['vendor'] ?? '');
+ $row['device_name'] = PciId::getPciId(PciId::DEVICE, $row['vendor'] . ':' . $row['device']);
+ $finalRows[] = $row;
+ // Build up query
+ if ($row['vendor_name'] === false) {
+ $missing[$row['vendor']] = true;
+ }
+ if ($row['device_name'] === false) {
+ $missing[$row['vendor'] . ':' . $row['device']] = true;
+ }
+ }
+ Render::addTemplate('hardware-list', [
+ 'list' => $finalRows,
+ 'missing_ids' => json_encode(array_keys($missing)),
+ ]);
+ }
+
+ /*
+ *
+ */
+
+ protected function doAjax()
+ {
+ //
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/passthrough/permissions/permissions.json b/modules-available/passthrough/permissions/permissions.json
new file mode 100644
index 00000000..d0932f1e
--- /dev/null
+++ b/modules-available/passthrough/permissions/permissions.json
@@ -0,0 +1,3 @@
+{
+ "view": false
+} \ No newline at end of file
diff --git a/modules-available/passthrough/templates/hardware-list.html b/modules-available/passthrough/templates/hardware-list.html
new file mode 100644
index 00000000..d331acb5
--- /dev/null
+++ b/modules-available/passthrough/templates/hardware-list.html
@@ -0,0 +1,125 @@
+<form method="post" action="?do=passthrough&amp;show=hwlist">
+ <input type="hidden" name="token" value="{{token}}">
+ <table class="table">
+ <thead>
+ <tr>
+ <th class="slx-smallcol">{{lang_deviceIdNumeric}}</th>
+ <th>{{lang_deviceName}}</th>
+ <th class="slx-smallcol">{{lang_useCount}}</th>
+ <th>{{lang_passthroughGroup}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#list}}
+ <tr>
+ {{#class_name}}
+ <td colspan="4">
+ <span>{{class}}</span> – <strong>{{class_name}}</strong>
+ </td>
+ {{/class_name}}
+ {{^class_name}}
+ <td>{{vendor}}:{{device}}</td>
+ <td>
+ <table class="slx-ellipsis">
+ <tr>
+ <td {{^device_name}}class="query-{{vendor}}-{{device}}"{{/device_name}}>
+ {{device_name}}
+ </td>
+ </tr>
+ </table>
+ <div class="small {{^vendor_name}}query-{{vendor}}{{/vendor_name}}">
+ {{vendor_name}}
+ </div>
+ </td>
+ <td class="text-right">{{connected_count}}</td>
+ <td>
+ <select name="ptgroup[{{hwid}}]" class="form-control {{#custom_groups}}ptgroup-select{{/custom_groups}}">
+ <option value="">{{lang_noPassthroughGroup}}</option>
+ {{#ptlist}}
+ <option value="{{ptid}}" {{selected}}>{{ptname}}</option>
+ {{/ptlist}}
+ </select>
+ </td>
+ {{/class_name}}
+ </tr>
+ {{/list}}
+ </tbody>
+ </table>
+ <div id="new-groups"></div>
+ <div class="buttonbar text-right">
+ <button type="button" data-target="#add-group-form" data-toggle="modal" class="btn btn-default">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_add}}
+ </button>
+ <button type="submit" name="action" value="save-hwlist" class="btn btn-success">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+</form>
+
+<div class="modal fade" id="add-group-form" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal">&times;</button>
+ <b>{{lang_addPassthroughGroup}}</b>
+ </div>
+ <div class="modal-body">
+ <div class="form-group">
+ <label for="group-id">{{lang_groupId}}</label>
+ <input type="text" name="group-id" id="group-id" class="form-control">
+ </div>
+ <div class="form-group">
+ <label for="group-title">{{lang_groupTitle}}</label>
+ <input type="text" name="group-title" id="group-title" class="form-control">
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-default"
+ data-dismiss="modal">{{lang_cancel}}</button>
+ <button id="add-group-button" type="button" class="btn btn-success" data-dismiss="modal">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_addGroup}}
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function() {
+ $('#add-group-button').click(function () {
+ var gid = $('#group-id').val().replace(/[^a-zA-Z0-9_\-]/g, '').toUpperCase();
+ var title = $('#group-title').val().trim();
+ if (gid.length === 0)
+ return;
+ $('#new-groups').append($('<input type="hidden">')
+ .attr('name', 'newgroup[' + gid + ']')
+ .attr('value', title));
+ $('.ptgroup-select').each(function() {
+ $(this).append($('<option>').attr('value', gid).text(gid + ' (' + title + ')'));
+ });
+ });
+ var missing = {{{missing_ids}}};
+ var doQuery = function() {
+ if (missing && missing.length > 0) {
+ $.ajax({
+ url: '?do=statistics', dataType: "json", method: "POST", data: {
+ token: TOKEN,
+ action: 'json-lookup',
+ list: missing.splice(0, 10) // Query 10 at a time max
+ }
+ }).done(function (data) {
+ if (!data)
+ return;
+ for (var k in data) {
+ $('.query-' + k.replace(':', '-')).text(data[k]);
+ }
+ doQuery();
+ });
+ }
+ }
+ doQuery();
+ });
+</script> \ No newline at end of file
diff --git a/modules-available/statistics/api.inc.php b/modules-available/statistics/api.inc.php
index 0a194f2e..52dbe284 100644
--- a/modules-available/statistics/api.inc.php
+++ b/modules-available/statistics/api.inc.php
@@ -1,15 +1,19 @@
<?php
if (Request::any('action') === 'test' && isLocalExecution()) {
+ /*
+ error_log(HardwareInfo::getKclModifications());
+ exit;
$x = new HardwareQuery(HardwareInfo::PCI_DEVICE);
//$x->addCompare(false, 'Memory Slot Occupied', '>=', true, 'Memory Slot Count');
$x->addWhere(true, 'vendor', '=', '8086');
- $x->addColumn(true, 'device');
+ $x->addGlobalColumn('device');
$res = $x->query();
foreach ($res as $row) {
error_log(json_encode($row));
}
exit;
+ */
HardwareParser::parseMachine('0A5D9E23-80F4-9C43-912C-96D80AE7E80B',
file_get_contents('/tmp/bla.json'));
echo 'Kweries: ' . Database::getQueryCount();
@@ -79,7 +83,7 @@ if ($type[0] === '~') {
$hostname = '';
}
$data = Util::cleanUtf8(Request::post('json', '', 'string'));
- $hasJson = !empty($data);
+ $hasJson = !empty($data) && $data[0] === '{';
if (!$hasJson) {
$data = Util::cleanUtf8(Request::post('data', '', 'string'));
}
@@ -340,7 +344,6 @@ if ($type[0] === '~') {
'hwid' => $hwid,
'machineuuid' => $uuid,
'devpath' => $port,
- 'serial' => '',
), array('disconnecttime' => 0));
$validProps = array();
if (count($screen) > 1) {
@@ -383,7 +386,7 @@ if ($type[0] === '~') {
// Some screens connected, make sure old entries get removed
Database::exec("UPDATE machine_x_hw x, statistic_hw h
SET x.disconnecttime = UNIX_TIMESTAMP()
- WHERE (x.hwid, x.devpath) NOT IN (:pairs) AND x.disconnecttime = 0 AND h.hwtype = :type
+ WHERE (x.hwid, x.devpath) NOT IN (:pairs) AND x.hwid = h.hwid AND x.disconnecttime = 0 AND h.hwtype = :type
AND x.machineuuid = :uuid", array(
'pairs' => $keepPair,
'uuid' => $uuid,
diff --git a/modules-available/statistics/inc/hardwareinfo.inc.php b/modules-available/statistics/inc/hardwareinfo.inc.php
index 5ef94365..6a6c74cd 100644
--- a/modules-available/statistics/inc/hardwareinfo.inc.php
+++ b/modules-available/statistics/inc/hardwareinfo.inc.php
@@ -13,4 +13,73 @@ class HardwareInfo
const HDD = 'HDD';
const CPU = 'CPU';
+ /**
+ * Get a KCL modification string for the given machine, enabling GVT, PCI passthrough etc.
+ * You can provide a UUID and/or MAC, or nothing. If nothing is provided,
+ * the "uuid" and "mac" GET parameters will be used. If both are provided,
+ * the resulting machine that has the greatest "lastseen" value will be used.
+ * @param ?string $uuid UUID of machine
+ * @param ?string $mac MAC of machine
+ * @return string
+ */
+ public static function getKclModifications($uuid = null, $mac = null): string
+ {
+ if ($uuid === null && $mac === null) {
+ $uuid = Request::get('uuid', '', 'string');
+ $mac = Request::get('mac', '', 'string');
+ $mac = str_replace(':', '-', $mac);
+ }
+ $res = Database::simpleQuery("SELECT machineuuid, lastseen FROM machine
+ WHERE machineuuid = :uuid OR macaddr = :mac", ['uuid' => $uuid, 'mac' => $mac]);
+ $best = null;
+ foreach ($res as $row) {
+ if ($best === null || $best['lastseen'] < $row['lastseen']) {
+ $best = $row;
+ }
+ }
+ if ($best === null)
+ return '';
+ $hw = new HardwareQuery(self::PCI_DEVICE, $best['machineuuid'], true);
+ // TODO: Get list of enabled pass through groups for this client's location
+ $hw->addWhere(true, '@PASSTHROUGH', 'IN', ['GPU', 'GVT']);
+ $hw->addGlobalColumn('vendor');
+ $hw->addGlobalColumn('device');
+ $hw->addLocalColumn('slot');
+ $res = $hw->query();
+ $passthrough = [];
+ $slots = [];
+ $gvt = false;
+ foreach ($res as $row) {
+ if ($row['@PASSTHROUGH'] === 'GVT') {
+ $gvt = true;
+ } else {
+ $passthrough[$row['vendor'] . ':' . $row['device']] = 1;
+ error_log('Passthouorgh: ' . $row['vendor'] . ':' . $row['device']);
+ $slots[preg_replace('/\.[0-9]+$/', '', $row['slot'])] = 1;
+ }
+ }
+ $kcl = '';
+ if ($gvt || !empty($passthrough)) {
+ $kcl = '-iommu -intel_iommu iommu=pt intel_iommu=on'; // TODO AMD
+ }
+ if (!empty($passthrough)) {
+ foreach (array_keys($slots) as $slot) {
+ error_log('Querying slot ' . $slot);
+ $hw = new HardwareQuery(self::PCI_DEVICE, $best['machineuuid'], true);
+ $hw->addWhere(false, 'slot', 'LIKE', $slot . '.%');
+ $hw->addGlobalColumn('vendor');
+ $hw->addGlobalColumn('device');
+ foreach ($hw->query() as $row) {
+ $passthrough[$row['vendor'] . ':' . $row['device']] = 1;
+ error_log('Extra PT: ' . $row['vendor'] . ':' . $row['device']);
+ }
+ }
+ $kcl .= ' vfio-pci.ids=' . implode(',', array_keys($passthrough));
+ }
+ if ($gvt) {
+ $kcl .= ' i915.enable_gvt=1';
+ }
+ return $kcl;
+ }
+
}
diff --git a/modules-available/statistics/inc/hardwareparser.inc.php b/modules-available/statistics/inc/hardwareparser.inc.php
index d356e226..15534749 100644
--- a/modules-available/statistics/inc/hardwareparser.inc.php
+++ b/modules-available/statistics/inc/hardwareparser.inc.php
@@ -308,29 +308,29 @@ class HardwareParser
foreach ($out as $entry) {
if (!isset($pci[$entry['class']])) {
$class = 'c.' . $entry['class'];
- $res = Page_Statistics::getPciId('CLASS', $class);
- if ($res === false || $res['dateline'] < $NOW) {
+ $res = PciId::getPciId('CLASS', $class);
+ if ($res === false) {
$pci[$entry['class']]['lookupClass'] = 'do-lookup';
$pci[$entry['class']]['class'] = $class;
} else {
- $pci[$entry['class']]['class'] = $res['value'];
+ $pci[$entry['class']]['class'] = $res;
}
}
$new = array(
'ven' => $entry['ven'],
'dev' => $entry['ven'] . ':' . $entry['dev'],
);
- $res = Page_Statistics::getPciId('VENDOR', $new['ven']);
- if ($res === false || $res['dateline'] < $NOW) {
+ $res = PciId::getPciId('VENDOR', $new['ven']);
+ if ($res === false) {
$new['lookupVen'] = 'do-lookup';
} else {
- $new['ven'] = $res['value'];
+ $new['ven'] = $res;
}
- $res = Page_Statistics::getPciId('DEVICE', $new['dev']);
- if ($res === false || $res['dateline'] < $NOW) {
+ $res = PciId::getPciId('DEVICE', $new['dev']);
+ if ($res === false) {
$new['lookupDev'] = 'do-lookup';
} else {
- $new['dev'] = $res['value'] . ' (' . $new['dev'] . ')';
+ $new['dev'] = $res . ' (' . $new['dev'] . ')';
}
$pci[$entry['class']]['entries'][] = $new;
}
@@ -444,8 +444,8 @@ class HardwareParser
return (int)($out[1] * self::SI_LOOKUP[strtoupper($out[2])]);
}
// Count, size (unitless)
- if (is_numeric($val) && preg_match('/^[0-9]+$/', $val)
- && preg_match('/used|occupied|count|number|size/', $key)) {
+ if (is_numeric($val) && preg_match('/^-?[0-9]+$/', $val)
+ && preg_match('/used|occupied|count|number|size|temperature/', $key)) {
return (int)$val;
}
// Date
@@ -538,7 +538,7 @@ class HardwareParser
{
$ret = [];
foreach ($data as $key => $vals) {
- $val = trim($vals['values'][0]);
+ $val = trim($vals['values'][0] ?? 'NULL');
if ($val === '[Empty]' || $val === 'NULL')
continue;
$val = preg_replace('/[^a-z0-9]/', '', strtolower($val));
@@ -567,7 +567,7 @@ class HardwareParser
*/
private static function markDisconnected(string $uuid, string $dbType, array $excludedHwIds)
{
- error_log("Marking disconnected for $dbType from " . implode(', ', $excludedHwIds));
+ error_log("Marking disconnected for $dbType except " . implode(', ', $excludedHwIds));
if (empty($excludedHwIds)) {
Database::exec("UPDATE machine_x_hw mxh, statistic_hw h
SET mxh.disconnecttime = UNIX_TIMESTAMP()
@@ -630,10 +630,12 @@ class HardwareParser
$globalMainboardExtra['Memory Slot Count'] += $mem['Number Of Devices'];
}
if (isset($mem['Maximum Capacity'])) {
- $globalMainboardExtra['Memory Maximum Capacity'] += self::convertSize($mem['Maximum Capacity'], 'M', false);
+ $globalMainboardExtra['Memory Maximum Capacity']
+ += self::convertSize($mem['Maximum Capacity'], 'M', false);
}
}
- $globalMainboardExtra['Memory Maximum Capacity'] = self::convertSize($globalMainboardExtra['Memory Maximum Capacity'] . ' MB');
+ $globalMainboardExtra['Memory Maximum Capacity']
+ = self::convertSize($globalMainboardExtra['Memory Maximum Capacity'] . ' MB', 'G');
// BIOS section - need to cross-match this with mainboard or system model, as it doesn't have a meaningful
// identifier on its own
$bios = self::prepareDmiProperties(self::getDmiHandles($data, 0)[0]);
@@ -651,8 +653,14 @@ class HardwareParser
}
}
// Using the general helper function
- $ramModCount = self::updateHwTypeFromDmi($uuid, $data, 17, HardwareInfo::RAM_MODULE, function (array $flat): bool {
- return self::convertSize(($flat['Size'] ?? 0), '', false) > 65 * 1024 * 1024;
+ $capa = 0;
+ $ramModCount = self::updateHwTypeFromDmi($uuid, $data, 17, HardwareInfo::RAM_MODULE, function (array $flat) use (&$capa): bool {
+ $size = self::convertSize(($flat['Size'] ?? 0), '', false);
+ if ($size > 65 * 1024 * 1024) {
+ $capa += $size;
+ return true;
+ }
+ return false;
},
['Locator'],
['Data Width',
@@ -669,6 +677,7 @@ class HardwareParser
);
// Fake RAM slots used/total into this
$localMainboardExtra['Memory Slot Occupied'] = $ramModCount;
+ $localMainboardExtra['Memory Installed Capacity'] = self::convertSize($capa, 'G', true);
self::updateHwTypeFromDmi($uuid, $data, 2, HardwareInfo::MAINBOARD, ['Manufacturer', 'Product Name'],
[],
['Manufacturer', 'Product Name', 'Type', 'Version'],
@@ -714,15 +723,18 @@ class HardwareParser
// ---- lspci ------------------------------------
$pciHwIds = [];
foreach (($data['lspci'] ?? []) as $dev) {
- $hwid = self::writeGlobalHardwareData(HardwareInfo::PCI_DEVICE,
- self::propsFromArray($dev, 'vendor', 'device', 'rev', 'class'));
+ $props = self::propsFromArray($dev, 'vendor', 'device', 'rev', 'class');
+ if (!isset($props['vendor']) || !isset($props['device']))
+ continue;
+ $hwid = self::writeGlobalHardwareData(HardwareInfo::PCI_DEVICE, $props);
$mappingId = self::writeLocalHardwareData($uuid, $hwid, $dev['slot'] ?? 'unknown',
- self::propsFromArray($dev, 'slot', 'subsystem', 'subsystem_vendor'));
+ self::propsFromArray($dev, 'slot', 'subsystem', 'subsystem_vendor', 'iommu_group'));
$pciHwIds[] = $mappingId;
}
self::markDisconnected($uuid, HardwareInfo::PCI_DEVICE, $pciHwIds);
// ---- Disks ------------------------------------0Y3R3K
$hddHwIds = [];
+ $id44 = $id45 = 0;
foreach (($data['drives'] ?? []) as $dev) {
if (empty($dev['readlink']))
continue;
@@ -739,11 +751,12 @@ class HardwareParser
if (!isset($smart['rotation_rate']) && isset($lsblk['rota']) && !$lsblk['rota']) {
$smart['rotation_rate'] = 0;
}
+ $size = $lsblk['size'] ?? $smart['user_capacity']['bytes'] ?? 'unknown';
$hwid = self::writeGlobalHardwareData(HardwareInfo::HDD, [
// Try to use the model name as the unique identifier
'model' => $smart['model_name'] ?? $lsblk['model'] ?? 'unknown',
// Append device size as some kind of fallback, in case model is unknown
- 'size' => $lsblk['size'] ?? $smart['user_capacity']['bytes'] ?? 'unknown',
+ 'size' => $size,
'physical_block_size' => $smart['physical_block_size'] ?? $lsblk['phy-sec'] ?? 0,
'logical_block_size' => $smart['logical_block_size'] ?? $lsblk['log-sec'] ?? 0,
] + self::propsFromArray($smart,
@@ -781,6 +794,7 @@ class HardwareParser
}, ARRAY_FILTER_USE_BOTH);
}
// Partitions - find special ones
+ $used = 0;
if (isset($dev['sfdisk']['partitiontable'])) {
$table['partition_table'] = $dev['sfdisk']['partitiontable']['label'] ?? 'none';
switch ($dev['sfdisk']['partitiontable']['unit'] ?? 'sectors') {
@@ -795,6 +809,13 @@ class HardwareParser
}
$i = 0;
foreach (($dev['sfdisk']['partitiontable']['partitions'] ?? []) as $part) {
+ if (!isset($part['size']))
+ continue;
+ $type = hexdec($part['type'] ?? '0');
+ if ($type === 0x0 || $type === 0x5 || $type === 0xf || $type === 0x15 || $type === 0x1f
+ || $type === 0x85 || $type === 0xc5 || $type == 0xcf)
+ continue; // Extended partition, ignore
+ $used += $part['size'] * $fac;
$id = 'part_' . $i . '_';
foreach (['start', 'size', 'type', 'uuid', 'name'] as $item) {
if (!isset($part[$item]))
@@ -811,19 +832,23 @@ class HardwareParser
if ($type == '44' || strtolower($type) === '87f86132-ff94-4987-b250-444444444444'
|| $name === 'OpenSLX-ID44') {
$table[$id . 'slxtype'] = '44';
+ $id44 += $part['size'] * $fac;
} elseif ($type == '45' || strtolower($type) === '87f86132-ff94-4987-b250-454545454545'
|| $name === 'OpenSLX-ID45') {
$table[$id . 'slxtype'] = '45';
+ $id45 += $part['size'] * $fac;
}
//
++$i;
}
}
+ $table['unused'] = $size - $used;
+ $table += self::propsFromArray($smart + ($lsblk ?? []),
+ 'serial_number', 'firmware_version',
+ 'interface_speed//current//string',
+ 'smart_status//passed', 'temperature//current', 'temperature//min', 'temperature//max');
$mappingId = self::writeLocalHardwareData($uuid, $hwid, $dev['readlink'],
- self::propsFromArray($smart + ($lsblk ?? []),
- 'serial_number', 'firmware_version',
- 'interface_speed//current//string',
- 'smart_status//passed', 'temperature//current', 'temperature//min', 'temperature//max') + $table);
+ $table);
// Delete old partition and smart attribute entries
Database::exec("DELETE FROM machine_x_hw_prop WHERE machinehwid = :id AND prop NOT IN (:keep)
AND prop NOT LIKE '@%'", [
@@ -836,8 +861,12 @@ class HardwareParser
self::markDisconnected($uuid, HardwareInfo::HDD, $hddHwIds);
//
// Mark parse date
- Database::exec("UPDATE machine SET dataparsetime = UNIX_TIMESTAMP() WHERE machineuuid = :uuid",
- ['uuid' => $uuid]);
+ Database::exec("UPDATE machine SET dataparsetime = UNIX_TIMESTAMP(), id44mb = :id44, id45mb = :id45
+ WHERE machineuuid = :uuid", [
+ 'uuid' => $uuid,
+ 'id44' => round($id44 / (1024 * 1024)),
+ 'id45' => round($id45 / (1024 * 1024)),
+ ]);
}
/**
diff --git a/modules-available/statistics/inc/hardwarequery.inc.php b/modules-available/statistics/inc/hardwarequery.inc.php
index 7ccde2f6..6b5662d4 100644
--- a/modules-available/statistics/inc/hardwarequery.inc.php
+++ b/modules-available/statistics/inc/hardwarequery.inc.php
@@ -13,16 +13,18 @@ class HardwareQuery
* @param string $type type form HardwareInfo
* @param ?string $uuid
*/
- public function __construct($type, $uuid = null, $connectedOnly = true)
+ public function __construct(string $type, $uuid = null, $connectedOnly = true)
{
if ($connectedOnly) {
- $this->where[] = 'mxhw.disconnecttime = 0';
+ $this->joins['mxhw_join'] = "INNER JOIN machine_x_hw mxhw ON (mxhw.hwid = shw.hwid AND mxhw.disconnecttime = 0)";
+ } else {
+ $this->joins['mxhw_join'] = "INNER JOIN machine_x_hw mxhw ON (mxhw.hwid = shw.hwid)";
}
if ($uuid !== null) {
$this->where[] = 'mxhw.machineuuid = :uuid';
$this->args['uuid'] = $uuid;
}
- $this->joins[] = "INNER JOIN statistic_hw shw ON (mxhw.hwid = shw.hwid AND shw.hwtype = :hwtype)";
+ $this->where[] = 'shw.hwtype = :hwtype';
$this->args['hwtype'] = $type;
}
@@ -44,7 +46,13 @@ class HardwareQuery
}
}
- public function addWhere(bool $global, string $prop, string $op, string $value)
+ /**
+ * @param bool $global
+ * @param string $prop
+ * @param string $op
+ * @param string|string[] $value
+ */
+ public function addWhere(bool $global, string $prop, string $op, $value)
{
if (isset($this->columns[$prop]))
return;
@@ -54,12 +62,24 @@ class HardwareQuery
$vid = $this->id();
$valueCol = ($op === '<' || $op === '>' || $op === '<=' || $op === '>=') ? 'numeric' : 'value';
$this->joins[$prop] = "INNER JOIN $table $tid ON ($srcTable.$column = $tid.$column AND
- $tid.prop = :$pid AND $tid.`$valueCol` $op :$vid)";
+ $tid.prop = :$pid AND $tid.`$valueCol` $op (:$vid))";
$this->args[$pid] = $prop;
$this->args[$vid] = $value;
$this->columns[$prop] = "$tid.`value` AS `$prop`";
}
+ public function addMachineWhere(string $column, string $op, $value)
+ {
+ if (isset($this->columns[$column]))
+ return;
+ $valueCol = ($op === '<' || $op === '>' || $op === '<=' || $op === '>=') ? 'numeric' : 'value';
+ $vid = $this->id();
+ $this->joins['machine'] = 'INNER JOIN machine m USING (machineuuid)';
+ $this->where[] = "m.$column $op :$vid";
+ $this->args[$vid] = $value;
+ $this->columns[$column] = "m.$column";
+ }
+
public function addCompare(bool $global1, string $prop1, string $op, string $global2, string $prop2)
{
$this->fillTableVars($global1, $srcTable1, $table1, $column1);
@@ -76,6 +96,17 @@ class HardwareQuery
$this->args[$pid1] = $prop1;
$this->args[$pid2] = $prop2;
$this->columns[$prop1] = "$tid1.`value` AS `$prop1`";
+ $this->columns[$prop2] = "$tid2.`value` AS `$prop2`";
+ }
+
+ public function addGlobalColumn(string $prop)
+ {
+ $this->addColumn(true, $prop);
+ }
+
+ public function addLocalColumn(string $prop)
+ {
+ $this->addColumn(false, $prop);
}
public function addColumn(bool $global, string $prop)
@@ -85,21 +116,41 @@ class HardwareQuery
$this->fillTableVars($global, $srcTable, $table, $column);
$tid = $this->id();
$pid = $this->id();
- $this->joins[$prop] = "INNER JOIN $table $tid ON ($srcTable.$column = $tid.$column AND $tid.prop = :$pid)";
+ $this->joins[$prop] = "LEFT JOIN $table $tid ON ($srcTable.$column = $tid.$column AND $tid.prop = :$pid)";
$this->args[$pid] = $prop;
$this->columns[$prop] = "$tid.`value` AS `$prop`";
}
+ public function addMachineColumn(string $column)
+ {
+ if (isset($this->columns[$column]))
+ return;
+ $this->joins['machine'] = 'INNER JOIN machine m USING (machineuuid)';
+ $this->columns[$column] = "m.$column";
+ }
+
/**
* @return false|PDOStatement
*/
- public function query()
+ public function query(string $groupBy = '')
{
- $this->columns[] = 'mxhw.machineuuid';
- $query = 'SELECT ' . implode(', ', $this->columns)
- . ' FROM machine_x_hw mxhw '
+ $columns = $this->columns;
+ $columns[] = 'mxhw.machineuuid';
+ $columns[] = 'shw.hwid';
+ if (empty($groupBy) || $groupBy === 'mxhw.machinehwid') {
+ $columns[] = 'mxhw.disconnecttime';
+ } else {
+ $columns[] = 'Sum(If(mxhw.disconnecttime = 0, 1, 0)) AS connected_count';
+ }
+ if (!empty($groupBy)) {
+ $columns[] = 'Count(*) AS group_count';
+ $groupBy = " GROUP BY $groupBy";
+ }
+ $query = 'SELECT ' . implode(', ', $columns)
+ . ' FROM statistic_hw shw '
. implode(' ', $this->joins)
- . ' WHERE ' . implode(' AND ', $this->where);
+ . ' WHERE ' . implode(' AND ', $this->where)
+ . $groupBy;
return Database::simpleQuery($query, $this->args);
}
diff --git a/modules-available/statistics/inc/pciid.inc.php b/modules-available/statistics/inc/pciid.inc.php
new file mode 100644
index 00000000..65edf570
--- /dev/null
+++ b/modules-available/statistics/inc/pciid.inc.php
@@ -0,0 +1,76 @@
+<?php
+
+class PciId
+{
+
+ const DEVICE = 'DEVICE';
+ const VENDOR = 'VENDOR';
+ const DEVCLASS = 'CLASS';
+ const AUTO = 'AUTO';
+
+
+ /**
+ * @param string $cat type of query - self::DEVICE, self::VENDOR, self::DEVCLASS or self::AUTO for auto detection
+ * @param string $id the id to query - depends on $cat
+ * @return string|false Name of Class/Vendor/Device, false if not found
+ */
+ public static function getPciId(string $cat, string $id, bool $dnsQuery = false)
+ {
+ static $cache = [];
+ if ($cat === self::DEVCLASS && $id[1] !== '.') {
+ $id = 'c.' . $id;
+ }
+ if ($cat === self::AUTO) {
+ if (preg_match('/^([a-f0-9]{4}):([a-f0-9]{4})$/', $id, $out)) {
+ $cat = 'DEVICE';
+ } elseif (preg_match('/^([a-f0-9]{4})$/', $id, $out)) {
+ $cat = 'VENDOR';
+ } elseif (preg_match('/^c\.([a-f0-9]{2})([a-f0-9]{2})$/', $id, $out)) {
+ $cat = 'CLASS';
+ } else {
+ error_log('Invalid PCIID lookup format: ' . $id);
+ return false;
+ }
+ }
+ $key = $cat . '-' . $id;
+ if (isset($cache[$key]))
+ return $cache[$key];
+ $row = Database::queryFirst('SELECT value, dateline FROM pciid WHERE category = :cat AND id = :id LIMIT 1',
+ array('cat' => $cat, 'id' => $id));
+ if ($row !== false && $row['dateline'] >= time()) {
+ return $cache[$key] = $row['value'];
+ }
+ if (!$dnsQuery)
+ return false;
+ // Unknown, query
+ if ($cat === self::DEVICE && preg_match('/^([a-f0-9]{4}):([a-f0-9]{4})$/', $id, $out)) {
+ $host = $out[2] . '.' . $out[1];
+ } elseif ($cat === self::VENDOR && preg_match('/^([a-f0-9]{4})$/', $id, $out)) {
+ $host = $out[1];
+ } elseif ($cat === self::DEVCLASS && preg_match('/^c\.([a-f0-9]{2})([a-f0-9]{2})$/', $id, $out)) {
+ $host = $out[2] . '.' . $out[1] . '.c';
+ } else {
+ error_log("getPciId called with unknown format: ($cat) ($id)");
+ return false;
+ }
+ $res = dns_get_record($host . '.pci.id.ucw.cz', DNS_TXT);
+ if (!is_array($res))
+ return false;
+ foreach ($res as $entry) {
+ if (isset($entry['txt']) && substr($entry['txt'], 0, 2) === 'i=') {
+ $string = substr($entry['txt'], 2);
+ Database::exec('INSERT INTO pciid (category, id, value, dateline) VALUES (:cat, :id, :value, :timeout)'
+ . ' ON DUPLICATE KEY UPDATE value = VALUES(value), dateline = VALUES(dateline)',
+ array(
+ 'cat' => $cat,
+ 'id' => $id,
+ 'value' => $string,
+ 'timeout' => time() + mt_rand(10, 30) * 86400,
+ ), true);
+ return $cache[$key] = $string;
+ }
+ }
+ return $cache[$key] = ($row['value'] ?? false);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/statistics/install.inc.php b/modules-available/statistics/install.inc.php
index 5856c81e..1b664d04 100644
--- a/modules-available/statistics/install.inc.php
+++ b/modules-available/statistics/install.inc.php
@@ -40,6 +40,7 @@ $res[] = tableCreate('machine', "
`cpumodel` varchar(120) NOT NULL,
`systemmodel` varchar(120) NOT NULL DEFAULT '',
`id44mb` int(10) unsigned NOT NULL,
+ `id45mb` int(10) unsigned NOT NULL,
`badsectors` int(10) unsigned NOT NULL,
`data` mediumblob NOT NULL,
`dataparsetime` int(10) unsigned NOT NULL DEFAULT 0,
@@ -339,7 +340,15 @@ if (!tableHasColumn('machine', 'dataparsetime')) {
$ret = Database::exec("ALTER TABLE `machine`
ADD COLUMN `dataparsetime` int(10) unsigned NOT NULL DEFAULT '0' AFTER `data`");
if ($ret === false) {
- finalResponse(UPDATE_FAILED, 'Adding mem-stat columns to machine table failed: ' . Database::lastError());
+ finalResponse(UPDATE_FAILED, 'Adding dateparsetime column to machine table failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+if (!tableHasColumn('machine', 'id45mb')) {
+ $ret = Database::exec("ALTER TABLE `machine`
+ ADD COLUMN `id45mb` int(10) unsigned NOT NULL AFTER `id44mb`");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Adding id45mb column to machine table failed: ' . Database::lastError());
}
$res[] = UPDATE_DONE;
}
diff --git a/modules-available/statistics/page.inc.php b/modules-available/statistics/page.inc.php
index 3e4aa9ce..04d9a515 100644
--- a/modules-available/statistics/page.inc.php
+++ b/modules-available/statistics/page.inc.php
@@ -24,10 +24,12 @@ class Page_Statistics extends Page
/*
Dictionary::translate('submenu_projectors');
Dictionary::translate('submenu_replace');
+ Dictionary::translate('submenu_hints');
*/
- foreach (['projectors', 'replace'] as $section) {
- Dashboard::addSubmenu('?do=statistics&show=' . $section, Dictionary::translate('submenu_' . $section, true));
+ foreach (['projectors', 'replace', 'hints'] as $section) {
+ Dashboard::addSubmenu('?do=statistics&show=' . $section,
+ Dictionary::translate('submenu_' . $section, true));
}
$this->show = Request::any('show', false, 'string');
@@ -251,11 +253,24 @@ class Page_Statistics extends Page
{
if (!User::load())
return;
- if (Request::any('action') === 'bios') {
+ $action = Request::any('action');
+ if ($action === 'bios') {
require_once 'modules/statistics/pages/machine.inc.php';
SubPage::ajaxCheckBios();
return;
}
+ if ($action === 'json-lookup') {
+ $reply = [];
+ foreach (Request::post('list', [], 'array') as $item) {
+ $name = PciId::getPciId(PciId::AUTO, $item, true);
+ if ($name === false) {
+ $name = '?????';
+ }
+ $reply[$item] = $name;
+ }
+ header('Content-Type: application/json');
+ die(json_encode($reply));
+ }
$param = Request::any('lookup', false, 'string');
if ($param === false) {
@@ -263,61 +278,14 @@ class Page_Statistics extends Page
}
$add = '';
if (preg_match('/^([a-f0-9]{4}):([a-f0-9]{4})$/', $param, $out)) {
- $cat = 'DEVICE';
- $host = $out[2] . '.' . $out[1];
$add = ' (' . $param . ')';
- } elseif (preg_match('/^([a-f0-9]{4})$/', $param, $out)) {
- $cat = 'VENDOR';
- $host = $out[1];
- } elseif (preg_match('/^c\.([a-f0-9]{2})([a-f0-9]{2})$/', $param, $out)) {
- $cat = 'CLASS';
- $host = $out[2] . '.' . $out[1] . '.c';
- } else {
- die('Invalid format requested');
- }
- $cached = Page_Statistics::getPciId($cat, $param);
- if ($cached !== false && $cached['dateline'] > time()) {
- echo $cached['value'], $add;
- exit;
- }
- $res = dns_get_record($host . '.pci.id.ucw.cz', DNS_TXT);
- if (is_array($res)) {
- foreach ($res as $entry) {
- if (isset($entry['txt']) && substr($entry['txt'], 0, 2) === 'i=') {
- $string = substr($entry['txt'], 2);
- Page_Statistics::setPciId($cat, $param, $string);
- echo $string, $add;
- exit;
- }
- }
}
- if ($cached !== false) {
- echo $cached['value'], $add;
- exit;
+ $cached = PciId::getPciId(PciId::AUTO, $param, true);
+ if ($cached === false) {
+ $cached = 'Unknown';
}
- die('Not found');
- }
-
- public static function getPciId($cat, $id)
- {
- static $cache = [];
- $key = $cat . '-' . $id;
- if (isset($cache[$key]))
- return $cache[$key];
- return $cache[$key] = Database::queryFirst('SELECT value, dateline FROM pciid WHERE category = :cat AND id = :id LIMIT 1',
- array('cat' => $cat, 'id' => $id));
- }
-
- private static function setPciId($cat, $id, $value)
- {
- Database::exec('INSERT INTO pciid (category, id, value, dateline) VALUES (:cat, :id, :value, :timeout)'
- . ' ON DUPLICATE KEY UPDATE value = VALUES(value), dateline = VALUES(dateline)',
- array(
- 'cat' => $cat,
- 'id' => $id,
- 'value' => $value,
- 'timeout' => time() + mt_rand(10, 30) * 86400,
- ), true);
+ echo $cached, $add;
+ exit;
}
}
diff --git a/modules-available/statistics/pages/hints.inc.php b/modules-available/statistics/pages/hints.inc.php
new file mode 100644
index 00000000..5c6acfbb
--- /dev/null
+++ b/modules-available/statistics/pages/hints.inc.php
@@ -0,0 +1,77 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+
+ }
+
+ public static function doRender()
+ {
+ self::showMemoryUpgrade();
+ self::showMemorySlow();
+ self::showUnusedSpace();
+ }
+
+ private static function showMemoryUpgrade()
+ {
+ $q = new HardwareQuery(HardwareInfo::MAINBOARD);
+ $q->addLocalColumn('Memory Slot Occupied');
+ $q->addGlobalColumn('Memory Slot Count');
+ $q->addGlobalColumn('Memory Maximum Capacity');
+ $q->addMachineColumn('clientip');
+ $q->addMachineColumn('hostname');
+ $q->addWhere(false, 'Memory Installed Capacity', '<', 8 * 1024 * 1024 * 1024);
+ $list = [];
+ foreach ($q->query() as $row) {
+ if (HardwareParser::convertSize($row['Memory Installed Capacity'], 'M', false)
+ >= HardwareParser::convertSize($row['Memory Maximum Capacity'], 'M', false)) {
+ $row['size_class'] = 'danger';
+ $list[] = $row;
+ } elseif ($row['Memory Slot Occupied'] < $row['Memory Slot Count']) {
+ $row['count_class'] = 'success';
+ array_unshift($list, $row);
+ } else {
+ $list[] = $row;
+ }
+ }
+ Render::addTemplate('hints-ram-upgrade', ['list' => $list]);
+ }
+
+ private static function showMemorySlow()
+ {
+ $q = new HardwareQuery(HardwareInfo::RAM_MODULE);
+ $q->addLocalColumn('Locator');
+ $q->addLocalColumn('Bank Locator');
+ $q->addGlobalColumn('Form Factor');
+ $q->addGlobalColumn('Type');
+ $q->addGlobalColumn('Size');
+ $q->addGlobalColumn('Manufacturer');
+ $q->addLocalColumn('Serial Number');
+ $q->addMachineColumn('clientip');
+ $q->addMachineColumn('hostname');
+ $q->addCompare(true, 'Speed', '>', false, 'Configured Memory Speed');
+ $list = $q->query()->fetchAll();
+ Render::addTemplate('hints-ram-underclocked', ['list' => $list]);
+ }
+
+ private static function showUnusedSpace()
+ {
+ $q = new HardwareQuery(HardwareInfo::HDD);
+ $q->addWhere(false, 'unused', '>', 2000000000); // 2 GB
+ $q->addMachineWhere('id44mb', '<', 20000); // 20 GB
+ $id44 = $q->query()->fetchAll();
+ $q = new HardwareQuery(HardwareInfo::HDD);
+ $q->addWhere(false, 'unused', '>', 25000000000); // 25 GB
+ $q->addMachineWhere('id44mb', '>', 20000); // 20 GB
+ $q->addMachineWhere('id45mb', '<', 20000); // 20 GB
+ $id45 = $q->query()->fetchAll();
+ Render::addTemplate('hints-hdd-grow', [
+ 'id44' => $id44,
+ 'id45' => $id45,
+ ]);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/statistics/templates/hints-ram-underclocked.html b/modules-available/statistics/templates/hints-ram-underclocked.html
new file mode 100644
index 00000000..e1f19c4f
--- /dev/null
+++ b/modules-available/statistics/templates/hints-ram-underclocked.html
@@ -0,0 +1,44 @@
+<h2>{{lang_ramUnderclocked}}</h2>
+
+<p>{{lang_ramUnderclockedText}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_machine}}</th>
+ <th>{{lang_type}}</th>
+ <th>{lang_speedCurrent}}</th>
+ <th>{{lang_speedDesign}}</th>
+ <th>{{lang_manufacturer}}</th>
+ <th>{{lang_serialNumber}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#list}}
+ <tr>
+ <td>
+ <a class="slx-bold" href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ <div class="small">{{machineuuid}}</div>
+ </td>
+ <td>
+ {{Type}} {{Form Factor}}
+ <div>{{Size}}</div>
+ </td>
+ <td>
+ {{Configured Memory Speed}}
+ </td>
+ <td>
+ {{Speed}}
+ </td>
+ <td>
+ {{Manufacturer}}
+ </td>
+ <td>
+ {{Serial Number}}
+ </td>
+ </tr>
+ {{/list}}
+ </tbody>
+</table> \ No newline at end of file
diff --git a/modules-available/statistics/templates/hints-ram-upgrade.html b/modules-available/statistics/templates/hints-ram-upgrade.html
new file mode 100644
index 00000000..be0d2532
--- /dev/null
+++ b/modules-available/statistics/templates/hints-ram-upgrade.html
@@ -0,0 +1,31 @@
+<h2>{{lang_ramUpgrade}}</h2>
+
+<p>{{lang_ramUpgradeText}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_machine}}</th>
+ <th>{{lang_installedCountMax}}</th>
+ <th>{{lang_ramSizeCurrentMax}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#list}}
+ <tr>
+ <td>
+ <a class="slx-bold" href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ <div class="small">{{machineuuid}}</div>
+ </td>
+ <td class="{{count_class}}">
+ {{Memory Slot Occupied}} / {{Memory Slot Count}}
+ </td>
+ <td class="{{size_class}}">
+ {{Memory Installed Capacity}} / {{Memory Maximum Capacity}}
+ </td>
+ </tr>
+ {{/list}}
+ </tbody>
+</table> \ No newline at end of file