summaryrefslogtreecommitdiffstats
path: root/modules-available/passthrough
diff options
context:
space:
mode:
Diffstat (limited to 'modules-available/passthrough')
-rw-r--r--modules-available/passthrough/config.json8
-rw-r--r--modules-available/passthrough/hooks/locations-column.inc.php58
-rw-r--r--modules-available/passthrough/inc/passthrough.inc.php53
-rw-r--r--modules-available/passthrough/install.inc.php23
-rw-r--r--modules-available/passthrough/lang/de/messages.json4
-rw-r--r--modules-available/passthrough/lang/de/module.json4
-rw-r--r--modules-available/passthrough/lang/de/permissions.json5
-rw-r--r--modules-available/passthrough/lang/de/template-tags.json15
-rw-r--r--modules-available/passthrough/lang/en/messages.json4
-rw-r--r--modules-available/passthrough/lang/en/module.json4
-rw-r--r--modules-available/passthrough/lang/en/permissions.json5
-rw-r--r--modules-available/passthrough/lang/en/template-tags.json15
-rw-r--r--modules-available/passthrough/page.inc.php195
-rw-r--r--modules-available/passthrough/permissions/permissions.json5
-rw-r--r--modules-available/passthrough/templates/hardware-list.html138
-rw-r--r--modules-available/passthrough/templates/location-assign.html37
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&amp;do=statistics&amp;filter[pcidev]=1&amp;op[pcidev]=%3D&amp;arg[pcidev]={{vendor}},{{class}}">
+ {{vendor_name}}
+ </a>
+ </div>
+ </td>
+ <td class="text-right">
+ <a href="?show=list&amp;do=statistics&amp;filter[pcidev]=1&amp;op[pcidev]=%3D&amp;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">&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 + ')'));
+ });
+ });
+ 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