diff options
Diffstat (limited to 'modules-available/passthrough')
16 files changed, 573 insertions, 0 deletions
diff --git a/modules-available/passthrough/config.json b/modules-available/passthrough/config.json new file mode 100644 index 00000000..34b16815 --- /dev/null +++ b/modules-available/passthrough/config.json @@ -0,0 +1,8 @@ +{ + "category": "main.settings-client", + "collapse": true, + "dependencies": [ + "statistics", + "locations" + ] +}
\ No newline at end of file diff --git a/modules-available/passthrough/hooks/locations-column.inc.php b/modules-available/passthrough/hooks/locations-column.inc.php new file mode 100644 index 00000000..2c09bd73 --- /dev/null +++ b/modules-available/passthrough/hooks/locations-column.inc.php @@ -0,0 +1,58 @@ +<?php + +if (!User::hasPermission('.passthrough.view')) { + return null; +} + +class PassthroughLocationColumn extends AbstractLocationColumn +{ + + private $lookup; + + public function __construct(array $allowedLocationIds) + { + $this->lookup = Database::queryKeyValueList("SELECT gxl.locationid, GROUP_CONCAT(gxl.groupid SEPARATOR ', ') AS grps + FROM passthrough_group_x_location gxl + WHERE locationid IN (:allowedLocationIds) GROUP BY locationid", compact('allowedLocationIds')); + } + + public function getColumnHtml(int $locationId): string + { + return htmlspecialchars($this->lookup[$locationId] ?? ''); + } + + public function getEditUrl(int $locationId): string + { + if (!User::hasPermission('.passthrough.edit.location', $locationId)) + return ''; + return '?do=passthrough&show=assignlocation&locationid=' . $locationId; + } + + public function header(): string + { + return Dictionary::translateFileModule('passthrough', 'module', 'location-column-header'); + } + + public function priority(): int + { + return 4000; + } + + public function propagateColumn(): bool + { + return true; + } + + public function propagationOverride(string $parent, string $data): string + { + if (empty($parent)) + return $data; + $merge = array_unique(array_merge( + explode(', ', $parent), explode(', ', $data))); + sort($merge); + return implode(', ', $merge); + } + +} + +return new PassthroughLocationColumn($allowedLocationIds);
\ 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..01d3edbb --- /dev/null +++ b/modules-available/passthrough/install.inc.php @@ -0,0 +1,23 @@ +<?php + +$result[] = tableCreate('passthrough_group', " + `groupid` varchar(32) CHARACTER SET ascii DEFAULT NULL, + `title` varchar(200) NOT NULL, + PRIMARY KEY (`groupid`) +"); + +$result[] = tableCreate('passthrough_group_x_location', " + `groupid` varchar(32) CHARACTER SET ascii DEFAULT NULL, + `locationid` INT(11) NOT NULL, + PRIMARY KEY (`groupid`, `locationid`) +"); + +$result[] = tableAddConstraint('passthrough_group_x_location', 'groupid', + 'passthrough_group', 'groupid', + 'ON DELETE CASCADE ON UPDATE CASCADE'); + +$result[] = tableAddConstraint('passthrough_group_x_location', 'locationid', + 'location', 'locationid', + 'ON DELETE CASCADE ON UPDATE CASCADE'); + +responseFromArray($result);
\ No newline at end of file diff --git a/modules-available/passthrough/lang/de/messages.json b/modules-available/passthrough/lang/de/messages.json new file mode 100644 index 00000000..aa33f6e5 --- /dev/null +++ b/modules-available/passthrough/lang/de/messages.json @@ -0,0 +1,4 @@ +{ + "list-updated": "Liste aktualisiert", + "location-updated": "Ort {{0}} gespeichert" +}
\ No newline at end of file diff --git a/modules-available/passthrough/lang/de/module.json b/modules-available/passthrough/lang/de/module.json new file mode 100644 index 00000000..53f186a0 --- /dev/null +++ b/modules-available/passthrough/lang/de/module.json @@ -0,0 +1,4 @@ +{ + "location-column-header": "Passthrough", + "module_name": "PCI-Passthrough" +}
\ No newline at end of file diff --git a/modules-available/passthrough/lang/de/permissions.json b/modules-available/passthrough/lang/de/permissions.json new file mode 100644 index 00000000..fbde0c66 --- /dev/null +++ b/modules-available/passthrough/lang/de/permissions.json @@ -0,0 +1,5 @@ +{ + "edit.group": "Kann Ger\u00e4te eine Passthrough-Gruppe zuweisen.", + "edit.location": "Kann Passthrough-Gruppe einem Raum\/Ort zuweisen.", + "view": "Kann diese Seite sehen." +}
\ No newline at end of file diff --git a/modules-available/passthrough/lang/de/template-tags.json b/modules-available/passthrough/lang/de/template-tags.json new file mode 100644 index 00000000..fa85edcd --- /dev/null +++ b/modules-available/passthrough/lang/de/template-tags.json @@ -0,0 +1,15 @@ +{ + "lang_add": "Hinzuf\u00fcgen", + "lang_addGroup": "Gruppe hinzuf\u00fcgen", + "lang_addPassthroughGroup": "Passthrough-Gruppe hinzuf\u00fcgen", + "lang_assignPassthrough": "Passthrough-Gruppe zuweisen", + "lang_deviceIdNumeric": "Ger\u00e4te-ID", + "lang_deviceName": "Ger\u00e4tename", + "lang_enabled": "Aktiv", + "lang_group": "Gruppe", + "lang_groupId": "Gruppen-ID", + "lang_groupTitle": "Titel", + "lang_noPassthroughGroup": "Keine Gruppe", + "lang_passthroughGroup": "Passthrough-Gruppe", + "lang_useCount": "#PCs" +}
\ No newline at end of file diff --git a/modules-available/passthrough/lang/en/messages.json b/modules-available/passthrough/lang/en/messages.json new file mode 100644 index 00000000..36e094d1 --- /dev/null +++ b/modules-available/passthrough/lang/en/messages.json @@ -0,0 +1,4 @@ +{ + "list-updated": "List was updated", + "location-updated": "Location {{0}} was updated" +}
\ No newline at end of file diff --git a/modules-available/passthrough/lang/en/module.json b/modules-available/passthrough/lang/en/module.json new file mode 100644 index 00000000..53f186a0 --- /dev/null +++ b/modules-available/passthrough/lang/en/module.json @@ -0,0 +1,4 @@ +{ + "location-column-header": "Passthrough", + "module_name": "PCI-Passthrough" +}
\ No newline at end of file diff --git a/modules-available/passthrough/lang/en/permissions.json b/modules-available/passthrough/lang/en/permissions.json new file mode 100644 index 00000000..0d1669d3 --- /dev/null +++ b/modules-available/passthrough/lang/en/permissions.json @@ -0,0 +1,5 @@ +{ + "edit.group": "Assign devices to passthrough groups.", + "edit.location": "Assign passthrough groups to locations.", + "view": "Can access this page." +}
\ No newline at end of file diff --git a/modules-available/passthrough/lang/en/template-tags.json b/modules-available/passthrough/lang/en/template-tags.json new file mode 100644 index 00000000..47344da9 --- /dev/null +++ b/modules-available/passthrough/lang/en/template-tags.json @@ -0,0 +1,15 @@ +{ + "lang_add": "Add", + "lang_addGroup": "Add group", + "lang_addPassthroughGroup": "Add passthrough group", + "lang_assignPassthrough": "Assign passthrough group", + "lang_deviceIdNumeric": "Device ID", + "lang_deviceName": "Device name", + "lang_enabled": "Enabled", + "lang_group": "Group", + "lang_groupId": "Group ID", + "lang_groupTitle": "Group title", + "lang_noPassthroughGroup": "No group", + "lang_passthroughGroup": "Passthrough group", + "lang_useCount": "#PCs" +}
\ 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..89ee7719 --- /dev/null +++ b/modules-available/passthrough/page.inc.php @@ -0,0 +1,195 @@ +<?php + +class Page_Passthrough extends Page +{ + + protected function doPreprocess() + { + User::load(); + User::assertPermission('view'); + $action = Request::post('action'); + if ($action === 'save-hwlist') { + $this->saveHwList(); + } elseif ($action === 'save-location') { + $this->saveLocation(); + } + if (Request::isPost()) { + Util::redirect('?do=passthrough'); + } + } + + private function saveHwList() + { + User::assertPermission('edit.group'); + $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'); + } + + private function saveLocation() + { + $locationId = Request::post('locationid', Request::REQUIRED, 'int'); + User::assertPermission('edit.location', $locationId); + $list = []; + $groups = []; + foreach (Request::post('enabled', [], 'array') as $groupId) { + $groupId = (string)$groupId; + $list[] = [$groupId, $locationId]; + $groups[] = $groupId; + } + if (!empty($list)) { + Database::exec("INSERT IGNORE INTO passthrough_group_x_location (groupid, locationid) + VALUES :list", ['list' => $list]); + Database::exec("DELETE FROM passthrough_group_x_location + WHERE locationid = :lid AND groupid NOT IN (:groups)", ['lid' => $locationId, 'groups' => $groups]); + } else { + Database::exec("DELETE FROM passthrough_group_x_location + WHERE locationid = :lid", ['lid' => $locationId]); + } + Message::addSuccess('location-updated', Location::getName($locationId)); + Util::redirect('?do=passthrough&show=assignlocation&locationid=' . $locationId); + } + + /* + * + */ + + protected function doRender() + { + $show = Request::get('show'); + if ($show === 'hwlist') { + $this->showHardwareList(); + } elseif ($show === 'assignlocation') { + $this->showLocationMapping(); + } else { + Util::redirect('?do=passthrough&show=hwlist'); + } + } + + /** + * Show all the hardware that is known. Start with video adapters. + * @return void + */ + private function showHardwareList() + { + $q = new HardwareQuery(HardwareInfo::PCI_DEVICE, null, false); + $q->addGlobalColumn('vendor'); + $q->addGlobalColumn('device'); + $q->addGlobalColumn('rev'); + $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[$lastClass] = [ + 'collapse' => $row['class'] !== '0300', + 'class' => $row['class'], + 'class_name' => PciId::getPciId(PciId::DEVCLASS, $row['class'], true) ?: 'Unknown', + 'devlist' => [], + ]; + } + $row['vendor_name'] = PciId::getPciId(PciId::VENDOR, $row['vendor'] ?? ''); + $row['device_name'] = PciId::getPciId(PciId::DEVICE, $row['vendor'] . ':' . $row['device']); + $finalRows[$lastClass]['devlist'][] = $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', ['classlist' => array_values($finalRows)]); + if (!empty($missing)) { + Render::addTemplate('js-pciquery', + ['missing_ids' => json_encode(array_keys($missing))], 'statistics'); + } + } + + /** + * Show mapping between specific location and passthrough groups. + * @return void + */ + private function showLocationMapping() + { + $locationId = Request::get('locationid', Request::REQUIRED, 'int'); + $locationIds = Location::getLocationRootChain($locationId); + $res = Database::queryAll("SELECT g.groupid, g.title, GROUP_CONCAT(gxl.locationid) AS lids FROM passthrough_group g + LEFT JOIN passthrough_group_x_location gxl ON (g.groupid = gxl.groupid AND gxl.locationid IN (:lids)) + GROUP BY groupid, title + ORDER BY lids ASC", + ['lids' => $locationIds]); + foreach ($res as &$item) { + if ($item['lids'] === null) + continue; + $item['checked'] = 'checked'; + $list = explode(',', $item['lids']); + if (!in_array($locationId, $list)) { + $item['disabled'] = true; + $item['parent_location'] = Location::getName($list[0]); + } + } + Render::addTemplate('location-assign', [ + 'list' => array_reverse($res), + 'locationid' => $locationId, + 'locationname' => Location::getName($locationId), + ]); + } + + /* + * + */ + + 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..bf3095c4 --- /dev/null +++ b/modules-available/passthrough/permissions/permissions.json @@ -0,0 +1,5 @@ +{ + "view": false, + "edit.group": false, + "edit.location": true +}
\ 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..4fdfb14f --- /dev/null +++ b/modules-available/passthrough/templates/hardware-list.html @@ -0,0 +1,138 @@ +<form method="post" action="?do=passthrough"> + <input type="hidden" name="token" value="{{token}}"> + {{#classlist}} + <div class="panel panel-default"> + <div class="panel-heading"> + {{#collapse}} + <span class="slx-pointer" data-toggle="collapse" data-target="#div-class-{{class}}"> + <b class="caret"></b> + {{/collapse}} + <span>{{class}}</span> – <strong>{{class_name}}</strong> + {{#collapse}} + </span> + {{/collapse}} + </div> + <div id="div-class-{{class}}" {{#collapse}}class="collapse"{{/collapse}}> + <table class="table"> + <thead> + <tr> + <th class="text-nowrap slx-smallcol">{{lang_deviceIdNumeric}}</th> + <th>{{lang_deviceName}}</th> + <th class="text-nowrap slx-smallcol">{{lang_useCount}}</th> + <th class="text-nowrap">{{lang_passthroughGroup}}</th> + </tr> + </thead> + <tbody> + {{#devlist}} + <tr class="c-{{vendor}}-{{device}} tr"> + <td class="text-nowrap">{{vendor}}:{{device}} [{{rev}}]</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}}"> + <a href="?show=list&do=statistics&filter[pcidev]=1&op[pcidev]=%3D&arg[pcidev]={{vendor}},{{class}}"> + {{vendor_name}} + </a> + </div> + </td> + <td class="text-right"> + <a href="?show=list&do=statistics&filter[pcidev]=1&op[pcidev]=%3D&arg[pcidev]={{vendor}}:{{device}}"> + {{connected_count}} + </a> + </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> + </tr> + {{/devlist}} + </tbody> + </table> + </div> + </div> + {{/classlist}} + <div id="new-groups"></div> + <div style="position:fixed;bottom:0;right:0;padding:8px;background:#fff;width:100%;border-top:1px solid #ddd"> + <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> + </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">×</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 + ')')); + }); + }); + hashChanged(); + }); + window.addEventListener('hashchange', function () { + hashChanged(); + }); + + function hashChanged() { + var c = window.location.hash; + $('tr.tr').removeClass('bg-success'); + if (c && c.length > 1) { + var d = $('.c-' + c.substr(1)).addClass('bg-success'); + d.closest('.collapse').collapse('show'); + if (d.length > 0) d[0].scrollIntoView(); + } + } +</script>
\ No newline at end of file diff --git a/modules-available/passthrough/templates/location-assign.html b/modules-available/passthrough/templates/location-assign.html new file mode 100644 index 00000000..037ab79d --- /dev/null +++ b/modules-available/passthrough/templates/location-assign.html @@ -0,0 +1,37 @@ +<h1>{{lang_assignPassthrough}}</h1> +<h2>{{locationname}}</h2> + +<form method="post" action="?do=passthrough"> + <input type="hidden" name="token" value="{{token}}"> + <input type="hidden" name="locationid" value="{{locationid}}"> + <div class="row"> + <div class="col-sm-9">{{lang_group}}</div> + <div class="col-sm-3">{{lang_enabled}}</div> + </div> + {{#list}} + <div class="row"> + <div class="col-sm-9"> + {{groupid}} + <span class="small text-muted">{{title}}</span> + </div> + <div class="col-sm-3"> + <div class="checkbox"> + {{#disabled}} + <input id="check-{{groupid}}" type="checkbox" disabled checked> + {{/disabled}} + {{^disabled}} + <input id="check-{{groupid}}" type="checkbox" name="enabled[]" value="{{groupid}}" {{checked}}> + {{/disabled}} + <label for="check-{{groupid}}"></label> + <span class="text-muted">{{parent_location}}</span> + </div> + </div> + </div> + {{/list}} + <div class="buttonbar text-right"> + <button class="btn btn-success" type="submit" name="action" value="save-location"> + <span class="glyphicon glyphicon-floppy-disk"></span> + {{lang_save}} + </button> + </div> +</form>
\ No newline at end of file |