The Plugin Manager is now bundled with OctoPrint

This commit is contained in:
Gina Häußge 2015-05-29 16:31:43 +02:00
parent a42868d7eb
commit caef322b65
10 changed files with 1340 additions and 7 deletions

View file

@ -17,7 +17,9 @@
* You can now define a folder (default: `~/.octoprint/watched`) to be watched for newly added GCODE (or -- if slicing
support is enabled -- STL) files to automatically add.
* OctoPrint now has a [plugin system](http://docs.octoprint.org/en/devel/plugins/index.html) which allows extending its
core functionality.
core functionality. Plugins may be installed through the new Plugin Manager available in OctoPrint's settings. This
Plugin Manager also allows browsing and easy installation of plugins registered on the official
[OctoPrint Plugin Repository](http://plugins.octoprint.org).
* New type of API key: [App Session Keys](http://docs.octoprint.org/en/devel/api/apps.html) for trusted applications
* Printer Profiles: Printer properties like print volume, extruder offsets etc are now managed via Printer Profiles. A
connection to a printer will always have a printer profile associated.

View file

@ -26,11 +26,17 @@ and on the `OctoPrint organization Github page <https://github.com/OctoPrint>`_.
Installing Plugins
==================
Plugins can be installed either by unpacking them into one of the configured plugin folders (regularly those are
``<octoprint source root>/plugins`` and ``<octoprint config folder>/plugins`` [#f1]_ or by installing them as regular python
modules via ``pip`` [#f2]_.
Plugins can be installed through the bundled Plugin Manager, which allows installing plugins available in the
`OctoPrint Plugin Repository <http://plugins.octoprint.org>`_, from a web address or from an uploaded file archive.
Please refer to the documentation of the plugin for installations instructions.
Please refer to the documentation of the plugin for additional installations instructions.
Manual Installation
-------------------
If you don't want or can't use the Plugin Manager, plugins may also be installed manually either by copying and
unpacking them into one of the configured plugin folders (regularly those are ``<octoprint source root>/plugins`` and
``<octoprint config folder>/plugins`` [#f1]_ or by installing them as regular python modules via ``pip`` [#f2]_.
For a plugin available on the Python Package Index (PyPi), the process is as simple as issuing a

View file

@ -28,7 +28,8 @@ INSTALL_REQUIRES = [
"netifaces",
"pylru",
"rsa",
"pkginfo"
"pkginfo",
"requests"
]
# Additional requirements for optional install options

View file

@ -0,0 +1,524 @@
# coding=utf-8
from __future__ import absolute_import
__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License"
import octoprint.plugin
import octoprint.plugin.core
from octoprint.settings import valid_boolean_trues
from octoprint.server.util.flask import restricted_access
from octoprint.server import admin_permission
from flask import jsonify, make_response
import logging
import sarge
import sys
import requests
import re
class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
octoprint.plugin.TemplatePlugin,
octoprint.plugin.AssetPlugin,
octoprint.plugin.SettingsPlugin,
octoprint.plugin.StartupPlugin,
octoprint.plugin.BlueprintPlugin):
def __init__(self):
self._pending_enable = set()
self._pending_disable = set()
self._pending_install = set()
self._pending_uninstall = set()
self._repository_available = False
self._repository_plugins = []
def initialize(self):
self._console_logger = logging.getLogger("octoprint.plugins.pluginmanager.console")
##~~ StartupPlugin
def on_startup(self, host, port):
console_logging_handler = logging.handlers.RotatingFileHandler(self._settings.get_plugin_logfile_path(postfix="console"), maxBytes=2*1024*1024)
console_logging_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
console_logging_handler.setLevel(logging.DEBUG)
self._console_logger.addHandler(console_logging_handler)
self._console_logger.setLevel(logging.DEBUG)
self._console_logger.propagate = False
self._repository_available = self._refresh_repository()
##~~ SettingsPlugin
def get_settings_defaults(self):
return dict(
repository="http://plugins.octoprint.org/plugins.json",
pip=None
)
##~~ AssetPlugin
def get_assets(self):
return dict(
js=["js/pluginmanager.js"],
css=["css/pluginmanager.css"],
less=["less/pluginmanager.less"]
)
##~~ TemplatePlugin
def get_template_configs(self):
return [
dict(type="settings", template="pluginmanager_settings.jinja2", custom_bindings=True)
]
##~~ BlueprintPlugin
@octoprint.plugin.BlueprintPlugin.route("/upload_archive", methods=["POST"])
@restricted_access
@admin_permission.require(403)
def upload_archive(self):
import flask
input_name = "file"
input_upload_path = input_name + "." + self._settings.global_get(["server", "uploads", "pathSuffix"])
input_upload_name = input_name + "." + self._settings.global_get(["server", "uploads", "nameSuffix"])
if input_upload_path not in flask.request.values or input_upload_name not in flask.request.values:
return flask.make_response("No file included", 400)
upload_path = flask.request.values[input_upload_path]
upload_name = flask.request.values[input_upload_name]
import tempfile
import shutil
import os
archive = tempfile.NamedTemporaryFile(delete=False, suffix="-{upload_name}".format(**locals()))
try:
archive.close()
shutil.copy(upload_path, archive.name)
return self.command_install(path=archive.name, force="force" in flask.request.values and flask.request.values["force"] in valid_boolean_trues)
finally:
try:
os.remove(archive.name)
except Exception as e:
self._logger.warn("Could not remove temporary file {path} again: {message}".format(path=archive.name, message=str(e)))
##~~ SimpleApiPlugin
def get_api_commands(self):
return {
"install": ["url"],
"uninstall": ["plugin"],
"enable": ["plugin"],
"disable": ["plugin"],
"refresh_repository": []
}
def on_api_get(self, request):
if not admin_permission.can():
return make_response("Insufficient rights", 403)
plugins = self._plugin_manager.plugins
result = []
for name, plugin in plugins.items():
result.append(self._to_external_representation(plugin))
if "refresh_repository" in request.values and request.values["refresh_repository"] in valid_boolean_trues:
self._refresh_repository()
return jsonify(plugins=result, repository=dict(available=self._repository_available, plugins=self._repository_plugins), os=self._get_os(), octoprint=self._get_octoprint_version())
def on_api_command(self, command, data):
if not admin_permission.can():
return make_response("Insufficient rights", 403)
if command == "install":
url = data["url"]
plugin_name = data["plugin"] if "plugin" in data else None
return self.command_install(url=url, force="force" in data and data["force"] in valid_boolean_trues, reinstall=plugin_name)
elif command == "uninstall":
plugin_name = data["plugin"]
if not plugin_name in self._plugin_manager.plugins:
return make_response("Unknown plugin: %s" % plugin_name, 404)
plugin = self._plugin_manager.plugins[plugin_name]
return self.command_uninstall(plugin)
elif command == "enable" or command == "disable":
plugin_name = data["plugin"]
if not plugin_name in self._plugin_manager.plugins:
return make_response("Unknown plugin: %s" % plugin_name, 404)
plugin = self._plugin_manager.plugins[plugin_name]
return self.command_toggle(plugin, command)
elif command == "refresh_repository":
self._repository_available = self._refresh_repository()
return jsonify(repository=dict(available=self._repository_available, plugins=self._repository_plugins))
def command_install(self, url=None, path=None, force=False, reinstall=None):
if url is not None:
pip_args = ["install", sarge.shell_quote(url)]
elif path is not None:
pip_args = ["install", path]
else:
raise ValueError("Either url or path must be provided")
all_plugins_before = self._plugin_manager.find_plugins()
try:
returncode, stdout, stderr = self._call_pip(pip_args)
except:
self._logger.exception("Could not install plugin from %s" % url)
return make_response("Could not install plugin from url, see the log for more details", 500)
else:
if force:
pip_args += ["--ignore-installed", "--force-reinstall", "--no-deps"]
try:
returncode, stdout, stderr = self._call_pip(pip_args)
except:
self._logger.exception("Could not install plugin from %s" % url)
return make_response("Could not install plugin from url, see the log for more details", 500)
try:
last_line = filter(lambda x: x.strip() != "", stdout)[-1]
except IndexError:
result = dict(result=False, reason="Could not parse output from pip")
self._send_result_notification("install", result)
return jsonify(result)
# The last line of a pip install command looks something like this:
#
# Successfully installed OctoPrint-Plugin-1.0 Dependency-One-0.1 Dependency-Two-9.3
#
# So we'll first strip the first part, then split by whitespace and remove the version to get the name of
# the installed package. By then matching up the package names stored for all our entry point plugins against
# the list of installed packages from our pip install run, we'll find the plugin that was installed.
last_line = last_line.strip()
if not last_line.startswith("Successfully installed "):
result = dict(result=False, reason="Pip did not report successful installation")
self._send_result_notification("install", result)
return jsonify(result)
installed = map(lambda x: x.strip().rsplit("-", 1)[0], last_line[len("Successfully installed "):].split(" "))
all_plugins_after = self._plugin_manager.find_plugins(existing=dict(), ignore_uninstalled=False)
for key, plugin in all_plugins_after.items():
if plugin.origin is None or plugin.origin[0] != "entry_point":
continue
if plugin.origin[3] in installed:
new_plugin_key = key
new_plugin = plugin
break
else:
return make_response("Could not find plugin that was installed", 500)
self._plugin_manager.mark_plugin(new_plugin_key, uninstalled=False)
self._plugin_manager.reload_plugins()
needs_restart = self._plugin_manager.is_restart_needing_plugin(new_plugin) or new_plugin_key in all_plugins_before or reinstall is not None
needs_refresh = new_plugin.implementation and isinstance(new_plugin.implementation, octoprint.plugin.ReloadNeedingPlugin)
self._plugin_manager.log_all_plugins()
result = dict(result=True, url=url, needs_restart=needs_restart, needs_refresh=needs_refresh, was_reinstalled=new_plugin_key in all_plugins_before or reinstall is not None, plugin=self._to_external_representation(new_plugin))
self._send_result_notification("install", result)
return jsonify(result)
def command_uninstall(self, plugin):
if plugin.key == "pluginmanager":
return make_response("Can't uninstall Plugin Manager", 400)
if plugin.bundled:
return make_response("Bundled plugins cannot be uninstalled", 400)
if plugin.origin[0] == "entry_point":
# plugin is installed through entry point, need to use pip to uninstall it
origin = plugin.origin[3]
if origin is None:
origin = plugin.origin[2]
pip_args = ["uninstall", "--yes", origin]
try:
self._call_pip(pip_args)
except:
self._logger.exception(u"Could not uninstall plugin via pip")
return make_response("Could not uninstall plugin via pip, see the log for more details", 500)
elif plugin.origin[0] == "folder":
import os
import shutil
full_path = os.path.realpath(plugin.location)
if os.path.isdir(full_path):
# plugin is installed via a plugin folder, need to use rmtree to get rid of it
self._log_stdout(u"Deleting plugin from {folder}".format(folder=plugin.location))
shutil.rmtree(full_path)
elif os.path.isfile(full_path):
self._log_stdout(u"Deleting plugin from {file}".format(file=plugin.location))
os.remove(full_path)
if full_path.endswith(".py"):
pyc_file = "{full_path}c".format(**locals())
if os.path.isfile(pyc_file):
os.remove(pyc_file)
needs_restart = self._plugin_manager.is_restart_needing_plugin(plugin)
needs_refresh = plugin.implementation and isinstance(plugin.implementation, octoprint.plugin.ReloadNeedingPlugin)
self._plugin_manager.mark_plugin(plugin.key, uninstalled=True)
if not needs_restart:
try:
self._plugin_manager.disable_plugin(plugin.key, plugin=plugin)
except octoprint.plugin.core.PluginLifecycleException as e:
self._logger.exception(u"Problem disabling plugin {name}".format(name=plugin.key))
result = dict(result=False, uninstalled=True, disabled=False, unloaded=False, reason=e.reason)
self._send_result_notification("uninstall", result)
return jsonify(result)
try:
self._plugin_manager.unload_plugin(plugin.key)
except octoprint.plugin.core.PluginLifecycleException as e:
self._logger.exception(u"Problem unloading plugin {name}".format(name=plugin.key))
result = dict(result=False, uninstalled=True, disabled=True, unloaded=False, reason=e.reason)
self._send_result_notification("uninstall", result)
return jsonify(result)
self._plugin_manager.reload_plugins()
result = dict(result=True, needs_restart=needs_restart, needs_refresh=needs_refresh, plugin=self._to_external_representation(plugin))
self._send_result_notification("uninstall", result)
return jsonify(result)
def command_toggle(self, plugin, command):
if plugin.key == "pluginmanager":
return make_response("Can't enable/disable Plugin Manager", 400)
needs_restart = self._plugin_manager.is_restart_needing_plugin(plugin)
needs_refresh = plugin.implementation and isinstance(plugin.implementation, octoprint.plugin.ReloadNeedingPlugin)
pending = ((command == "disable" and plugin.key in self._pending_enable) or (command == "enable" and plugin.key in self._pending_disable))
needs_restart_api = needs_restart and not pending
needs_refresh_api = needs_refresh and not pending
try:
if command == "disable":
self._mark_plugin_disabled(plugin, needs_restart=needs_restart)
elif command == "enable":
self._mark_plugin_enabled(plugin, needs_restart=needs_restart)
except octoprint.plugin.core.PluginLifecycleException as e:
self._logger.exception(u"Problem toggling enabled state of {name}: {reason}".format(name=plugin.key, reason=e.reason))
result = dict(result=False, reason=e.reason)
except octoprint.plugin.core.PluginNeedsRestart:
result = dict(result=True, needs_restart=True, needs_refresh=True, plugin=self._to_external_representation(plugin))
else:
result = dict(result=True, needs_restart=needs_restart_api, needs_refresh=needs_refresh_api, plugin=self._to_external_representation(plugin))
self._send_result_notification(command, result)
return jsonify(result)
def _send_result_notification(self, action, result):
notification = dict(type="result", action=action)
notification.update(result)
self._plugin_manager.send_plugin_message(self._identifier, notification)
def _call_pip(self, args):
pip_command = self._settings.get(["pip"])
if pip_command is None:
import os
python_command = sys.executable
binary_dir = os.path.dirname(python_command)
pip_command = os.path.join(binary_dir, "pip")
if sys.platform == "win32":
# Windows is a bit special... first of all the file will be called pip.exe, not just pip, and secondly
# for a non-virtualenv install (e.g. global install) the pip binary will not be located in the
# same folder as python.exe, but in a subfolder Scripts, e.g.
#
# C:\Python2.7\
# |- python.exe
# `- Scripts
# `- pip.exe
# virtual env?
pip_command = os.path.join(binary_dir, "pip.exe")
if not os.path.isfile(pip_command):
# nope, let's try the Scripts folder then
scripts_dir = os.path.join(binary_dir, "Scripts")
if os.path.isdir(scripts_dir):
pip_command = os.path.join(scripts_dir, "pip.exe")
if not os.path.isfile(pip_command) or not os.access(pip_command, os.X_OK):
raise RuntimeError(u"No pip path configured and {pip_command} does not exist or is not executable, can't install".format(**locals()))
command = [pip_command] + args
self._logger.debug(u"Calling: {}".format(" ".join(command)))
p = sarge.run(" ".join(command), shell=True, async=True, stdout=sarge.Capture(), stderr=sarge.Capture())
p.wait_events()
all_stdout = []
all_stderr = []
try:
while p.returncode is None:
line = p.stderr.readline(timeout=0.5)
if line:
self._log_stderr(line)
all_stderr.append(line)
line = p.stdout.readline(timeout=0.5)
if line:
self._log_stdout(line)
all_stdout.append(line)
p.commands[0].poll()
finally:
p.close()
stderr = p.stderr.text
if stderr:
split_lines = stderr.split("\n")
self._log_stderr(*split_lines)
all_stderr += split_lines
stdout = p.stdout.text
if stdout:
split_lines = stdout.split("\n")
self._log_stdout(*split_lines)
all_stdout += split_lines
return p.returncode, all_stdout, all_stderr
def _log_stdout(self, *lines):
self._log(lines, prefix=">", stream="stdout")
def _log_stderr(self, *lines):
self._log(lines, prefix="!", stream="stderr")
def _log(self, lines, prefix=None, stream=None, strip=True):
if strip:
lines = map(lambda x: x.strip(), lines)
self._plugin_manager.send_plugin_message(self._identifier, dict(type="loglines", loglines=[dict(line=line, stream=stream) for line in lines]))
for line in lines:
self._console_logger.debug(u"{prefix} {line}".format(**locals()))
def _mark_plugin_enabled(self, plugin, needs_restart=False):
disabled_list = list(self._settings.global_get(["plugins", "_disabled"]))
if plugin.key in disabled_list:
disabled_list.remove(plugin.key)
self._settings.global_set(["plugins", "_disabled"], disabled_list)
self._settings.save(force=True)
if not needs_restart:
self._plugin_manager.enable_plugin(plugin.key)
else:
if plugin.key in self._pending_disable:
self._pending_disable.remove(plugin.key)
elif not plugin.enabled and plugin.key not in self._pending_enable:
self._pending_enable.add(plugin.key)
def _mark_plugin_disabled(self, plugin, needs_restart=False):
disabled_list = list(self._settings.global_get(["plugins", "_disabled"]))
if not plugin.key in disabled_list:
disabled_list.append(plugin.key)
self._settings.global_set(["plugins", "_disabled"], disabled_list)
self._settings.save(force=True)
if not needs_restart:
self._plugin_manager.disable_plugin(plugin.key)
else:
if plugin.key in self._pending_enable:
self._pending_enable.remove(plugin.key)
elif plugin.enabled and plugin.key not in self._pending_disable:
self._pending_disable.add(plugin.key)
def _refresh_repository(self):
import requests
repository_url = self._settings.get(["repository"])
try:
r = requests.get(repository_url)
except Exception as e:
self._logger.warn("Could not fetch plugins from repository at {repository_url}: {message}".format(repository_url=repository_url, message=str(e)))
return False
current_os = self._get_os()
octoprint_version = self._get_octoprint_version()
if "-" in octoprint_version:
octoprint_version = octoprint_version[:octoprint_version.find("-")]
def map_repository_entry(entry):
result = dict(entry)
result["is_compatible"] = dict(
octoprint=True,
os=True
)
if "compatibility" in entry:
if "octoprint" in entry["compatibility"]:
import semantic_version
for octo_compat in entry["compatibility"]["octoprint"]:
s = semantic_version.Spec("=={}".format(octo_compat))
if semantic_version.Version(octoprint_version) in s:
break
else:
result["is_compatible"]["octoprint"] = False
if "os" in entry["compatibility"]:
result["is_compatible"]["os"] = current_os in entry["compatibility"]["os"]
return result
self._repository_plugins = map(map_repository_entry, r.json())
return True
def _get_os(self):
if sys.platform == "win32":
return "windows"
elif sys.platform == "linux2":
return "linux"
elif sys.platform == "darwin":
return "macos"
else:
return "unknown"
def _get_octoprint_version(self):
from octoprint._version import get_versions
return get_versions()["version"]
def _to_external_representation(self, plugin):
return dict(
key=plugin.key,
name=plugin.name,
description=plugin.description,
author=plugin.author,
version=plugin.version,
url=plugin.url,
license=plugin.license,
bundled=plugin.bundled,
enabled=plugin.enabled,
pending_enable=(not plugin.enabled and plugin.key in self._pending_enable),
pending_disable=(plugin.enabled and plugin.key in self._pending_disable),
pending_install=(plugin.key in self._pending_install),
pending_uninstall=(plugin.key in self._pending_uninstall)
)
__plugin_name__ = "Plugin Manager"
__plugin_author__ = "Gina Häußge"
__plugin_url__ = "https://github.com/foosel/OctoPrint/wiki/Plugin:-Plugin-Manager"
__plugin_description__ = "Allows installing and managing OctoPrint plugins"
__plugin_license__ = "AGPLv3"
__plugin_implementation__ = PluginManagerPlugin()

View file

@ -0,0 +1 @@
table th.settings_plugin_plugin_manager_plugins_name,table td.settings_plugin_plugin_manager_plugins_name{text-overflow:ellipsis;text-align:left}table th.settings_plugin_plugin_manager_plugins_actions,table td.settings_plugin_plugin_manager_plugins_actions{text-align:center;width:80px}table th.settings_plugin_plugin_manager_plugins_actions a,table td.settings_plugin_plugin_manager_plugins_actions a{text-decoration:none;color:#000}table th.settings_plugin_plugin_manager_plugins_actions a.disabled,table td.settings_plugin_plugin_manager_plugins_actions a.disabled{color:#ccc;cursor:default}#settings_plugin_pluginmanager_repositorydialog .slimScrollDiv{margin-bottom:20px}#settings_plugin_pluginmanager_repositorydialog h4{position:relative}#settings_plugin_pluginmanager_repositorydialog h4 a.dropdown-toggle{color:inherit;text-decoration:none}#settings_plugin_pluginmanager_repositorydialog .form-search{text-align:center;margin-bottom:5px!important}#settings_plugin_pluginmanager_repositorydialog .form-inline{padding:5px;padding-right:10px;margin-bottom:0}#settings_plugin_pluginmanager_repositorydialog .form-inline .help-block{margin-bottom:0;font-size:85%}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_unavailable{overflow:hidden;width:100%;height:300px;background-image:url("../img/repo_unavailable.png");text-align:center;display:table}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_unavailable div{display:table-cell;vertical-align:middle}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_list{overflow:hidden;width:auto;height:300px}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_list .entry{border-bottom:1px solid #ddd;padding:5px;padding-right:10px}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,573 @@
$(function() {
function PluginManagerViewModel(parameters) {
var self = this;
self.loginState = parameters[0];
self.settingsViewModel = parameters[1];
self.plugins = new ItemListHelper(
"plugin.pluginmanager.installedplugins",
{
"name": function (a, b) {
// sorts ascending
if (a["name"].toLocaleLowerCase() < b["name"].toLocaleLowerCase()) return -1;
if (a["name"].toLocaleLowerCase() > b["name"].toLocaleLowerCase()) return 1;
return 0;
}
},
{
},
"name",
[],
[],
5
);
self.repositoryplugins = new ItemListHelper(
"plugin.pluginmanager.repositoryplugins",
{
"title": function (a, b) {
// sorts ascending
if (a["title"].toLocaleLowerCase() < b["title"].toLocaleLowerCase()) return -1;
if (a["title"].toLocaleLowerCase() > b["title"].toLocaleLowerCase()) return 1;
return 0;
},
"published": function (a, b) {
// sorts descending
if (a["published"].toLocaleLowerCase() > b["published"].toLocaleLowerCase()) return -1;
if (a["published"].toLocaleLowerCase() < b["published"].toLocaleLowerCase()) return 1;
return 0;
}
},
{
"filter_installed": function(plugin) {
return !self.installed(plugin);
},
"filter_incompatible": function(plugin) {
return plugin.is_compatible.octoprint && plugin.is_compatible.os;
}
},
"title",
["filter_installed", "filter_incompatible"],
[],
0
);
self.uploadElement = $("#settings_plugin_pluginmanager_repositorydialog_upload");
self.uploadButton = $("#settings_plugin_pluginmanager_repositorydialog_upload_start");
self.repositoryAvailable = ko.observable(false);
self.repositorySearchQuery = ko.observable();
self.repositorySearchQuery.subscribe(function() {
self.performRepositorySearch();
});
self.installUrl = ko.observable();
self.uploadFilename = ko.observable();
self.loglines = ko.observableArray([]);
self.installedPlugins = ko.observableArray([]);
self.working = ko.observable(false);
self.workingTitle = ko.observable();
self.workingDialog = undefined;
self.workingOutput = undefined;
self.invalidUrl = ko.computed(function() {
var url = self.installUrl();
return url !== undefined && url.trim() != "" && !(_.startsWith(url.toLocaleLowerCase(), "http://") || _.startsWith(url.toLocaleLowerCase(), "https://"));
});
self.enableUrlInstall = ko.computed(function() {
var url = self.installUrl();
return url !== undefined && url.trim() != "" && !self.invalidUrl();
});
self.invalidArchive = ko.computed(function() {
var name = self.uploadFilename();
return name !== undefined && !(_.endsWith(name.toLocaleLowerCase(), ".zip") || _.endsWith(name.toLocaleLowerCase(), ".tar.gz") || _.endsWith(name.toLocaleLowerCase(), ".tgz") || _.endsWith(name.toLocaleLowerCase(), ".tar"));
});
self.enableArchiveInstall = ko.computed(function() {
var name = self.uploadFilename();
return name !== undefined && name.trim() != "" && !self.invalidArchive();
});
self.uploadElement.fileupload({
dataType: "json",
maxNumberOfFiles: 1,
autoUpload: false,
add: function(e, data) {
if (data.files.length == 0) {
return false;
}
self.uploadFilename(data.files[0].name);
self.uploadButton.unbind("click");
self.uploadButton.bind("click", function() {
self._markWorking(gettext("Installing plugin..."), gettext("Installing plugin from uploaded archive..."));
data.submit();
return false;
});
},
done: function(e, data) {
self._markDone();
self.uploadButton.unbind("click");
self.uploadFilename("");
},
fail: function(e, data) {
new PNotify({
title: gettext("Something went wrong"),
text: gettext("Please consult octoprint.log for details"),
type: "error",
hide: false
});
self._markDone();
self.uploadButton.unbind("click");
self.uploadFilename("");
}
});
self.performRepositorySearch = function() {
var query = self.repositorySearchQuery();
if (query !== undefined && query.trim() != "") {
self.repositoryplugins.changeSearchFunction(function(entry) {
return entry && (entry["title"].toLocaleLowerCase().indexOf(query) > -1 || entry["description"].toLocaleLowerCase().indexOf(query) > -1);
});
} else {
self.repositoryplugins.resetSearch();
}
};
self.fromResponse = function(data) {
self._fromPluginsResponse(data.plugins);
self._fromRepositoryResponse(data.repository)
};
self._fromPluginsResponse = function(data) {
var installedPlugins = [];
_.each(data, function(plugin) {
installedPlugins.push(plugin.key);
});
self.installedPlugins(installedPlugins);
self.plugins.updateItems(data);
};
self._fromRepositoryResponse = function(data) {
self.repositoryAvailable(data.available);
if (data.available) {
self.repositoryplugins.updateItems(data.plugins);
} else {
self.repositoryplugins.updateItems([]);
}
};
self.requestData = function(includeRepo) {
if (!self.loginState.isAdmin()) {
return;
}
$.ajax({
url: API_BASEURL + "plugin/pluginmanager" + ((includeRepo) ? "?refresh_repository=true" : ""),
type: "GET",
dataType: "json",
success: self.fromResponse
});
};
self.togglePlugin = function(data) {
if (!self.loginState.isAdmin()) {
return;
}
if (data.key == "pluginmanager") return;
var command = self._getToggleCommand(data);
var payload = {plugin: data.key};
self._postCommand(command, payload, function(response) {
self.requestData();
}, function() {
new PNotify({
title: gettext("Something went wrong"),
text: gettext("Please consult octoprint.log for details"),
type: "error",
hide: false
})
});
};
self.showRepository = function() {
self.repositoryDialog.modal("show");
};
self.pluginDetails = function(data) {
window.open(data.page);
};
self.installFromRepository = function(data) {
if (!self.loginState.isAdmin()) {
return;
}
if (self.installed(data)) {
self.installPlugin(data.archive, data.title, data.id);
} else {
self.installPlugin(data.archive, data.title);
}
};
self.installPlugin = function(url, name, reinstall) {
if (!self.loginState.isAdmin()) {
return;
}
if (url === undefined) {
url = self.installUrl();
}
if (!url) return;
var workTitle, workText;
if (!reinstall) {
workTitle = gettext("Installing plugin...");
if (name) {
workText = _.sprintf(gettext("Installing plugin \"%(name)s\" from %(url)s..."), {url: url, name: name});
} else {
workText = _.sprintf(gettext("Installing plugin from %(url)s..."), {url: url});
}
} else {
workTitle = gettext("Reinstalling plugin...");
workText = _.sprintf(gettext("Reinstalling plugin \"%(name)s\" from %(url)s..."), {url: url, name: name});
}
self._markWorking(workTitle, workText);
var command = "install";
var payload = {url: url};
if (reinstall) {
payload["plugin"] = reinstall;
payload["force"] = true;
}
self._postCommand(command, payload, function(response) {
self.requestData();
self._markDone();
self.installUrl("");
}, function() {
new PNotify({
title: gettext("Something went wrong"),
text: gettext("Please consult octoprint.log for details"),
type: "error",
hide: false
});
self._markDone();
});
};
self.uninstallPlugin = function(data) {
if (!self.loginState.isAdmin()) {
return;
}
if (data.bundled) return;
if (data.key == "pluginmanager") return;
self._markWorking(gettext("Uninstalling plugin..."), _.sprintf(gettext("Uninstalling plugin \"%(name)s\""), {name: data.name}));
var command = "uninstall";
var payload = {plugin: data.key};
self._postCommand(command, payload, function(response) {
self.requestData();
self._markDone();
}, function() {
new PNotify({
title: gettext("Something went wrong"),
text: gettext("Please consult octoprint.log for details"),
type: "error",
hide: false
});
self._markDone();
});
};
self.refreshRepository = function() {
if (!self.loginState.isAdmin()) {
return;
}
self._postCommand("refresh_repository", {}, function(data) {
self._fromRepositoryResponse(data.repository);
})
};
self.installed = function(data) {
return _.includes(self.installedPlugins(), data.id);
};
self.isCompatible = function(data) {
return data.is_compatible.octoprint && data.is_compatible.os;
};
self.installButtonText = function(data) {
return self.isCompatible(data) ? (self.installed(data) ? gettext("Reinstall") : gettext("Install")) : gettext("Incompatible");
};
self._displayNotification = function(response, titleSuccess, textSuccess, textRestart, textReload, titleError, textError) {
if (response.result) {
if (response.needs_restart) {
new PNotify({
title: titleSuccess,
text: textRestart,
hide: false
});
} else if (response.needs_refresh) {
new PNotify({
title: titleSuccess,
text: textReload,
confirm: {
confirm: true,
buttons: [{
text: gettext("Reload now"),
click: function () {
location.reload(true);
}
}]
},
buttons: {
closer: false,
sticker: false
},
hide: false
})
} else {
new PNotify({
title: titleSuccess,
text: textSuccess,
type: "success",
hide: false
})
}
} else {
new PNotify({
title: titleError,
text: textError,
type: "error",
hide: false
});
}
};
self._postCommand = function (command, data, successCallback, failureCallback, alwaysCallback, timeout) {
var payload = _.extend(data, {command: command});
var params = {
url: API_BASEURL + "plugin/pluginmanager",
type: "POST",
dataType: "json",
data: JSON.stringify(payload),
contentType: "application/json; charset=UTF-8",
success: function(response) {
if (successCallback) successCallback(response);
},
error: function() {
if (failureCallback) failureCallback();
},
complete: function() {
if (alwaysCallback) alwaysCallback();
}
};
if (timeout != undefined) {
params.timeout = timeout;
}
$.ajax(params);
};
self._markWorking = function(title, line) {
self.working(true);
self.workingTitle(title);
self.loglines.removeAll();
self.loglines.push({line: line, stream: "message"});
self.workingDialog.modal("show");
};
self._markDone = function() {
self.working(false);
self.loglines.push({line: gettext("Done!"), stream: "message"});
self._scrollWorkingOutputToEnd();
};
self._scrollWorkingOutputToEnd = function() {
self.workingOutput.scrollTop(self.workingOutput[0].scrollHeight - self.workingOutput.height());
};
self._getToggleCommand = function(data) {
return ((!data.enabled || data.pending_disable) && !data.pending_enable) ? "enable" : "disable";
};
self.toggleButtonCss = function(data) {
var icon = self._getToggleCommand(data) == "enable" ? "icon-circle-blank" : "icon-circle";
var disabled = (data.key == "pluginmanager") ? " disabled" : "";
return icon + disabled;
};
self.toggleButtonTitle = function(data) {
return self._getToggleCommand(data) == "enable" ? gettext("Enable Plugin") : gettext("Disable Plugin");
};
self.onBeforeBinding = function() {
self.settings = self.settingsViewModel.settings;
};
self.onUserLoggedIn = function(user) {
if (user.admin) {
self.requestData();
}
};
self.onStartup = function() {
self.workingDialog = $("#settings_plugin_pluginmanager_workingdialog");
self.workingOutput = $("#settings_plugin_pluginmanager_workingdialog_output");
self.repositoryDialog = $("#settings_plugin_pluginmanager_repositorydialog");
$("#settings_plugin_pluginmanager_repositorydialog_list").slimScroll({
height: "306px",
size: "5px",
distance: "0",
railVisible: true,
alwaysVisible: true,
scrollBy: "102px"
});
};
self.onDataUpdaterPluginMessage = function(plugin, data) {
if (plugin != "pluginmanager") {
return;
}
if (!self.loginState.isAdmin()) {
return;
}
if (!data.hasOwnProperty("type")) {
return;
}
var messageType = data.type;
if (messageType == "loglines" && self.working()) {
_.each(data.loglines, function(line) {
self.loglines.push(line);
});
self._scrollWorkingOutputToEnd();
} else if (messageType == "result") {
var titleSuccess, textSuccess, textRestart, textReload, titleError, textError;
var action = data.action;
var name = "Unknown";
if (action == "install") {
if (data.hasOwnProperty("plugin")) {
name = data.plugin.name;
}
if (data.was_reinstalled) {
titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" reinstalled"), {name: name});
textSuccess = gettext("The plugin was reinstalled successfully");
textRestart = gettext("The plugin was reinstalled successfully, however a restart of OctoPrint is needed for that to take effect.");
textReload = gettext("The plugin was reinstalled successfully, however a reload of the page is needed for that to take effect.");
} else {
titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" installed"), {name: name});
textSuccess = gettext("The plugin was installed successfully");
textRestart = gettext("The plugin was installed successfully, however a restart of OctoPrint is needed for that to take effect.");
textReload = gettext("The plugin was installed successfully, however a reload of the page is needed for that to take effect.");
}
titleError = gettext("Something went wrong");
var url = "unknown";
if (data.hasOwnProperty("url")) {
url = data.url;
}
if (data.hasOwnProperty("reason")) {
if (data.was_reinstalled) {
textError = _.sprintf(gettext("Reinstalling the plugin from URL \"%(url)s\" failed: %(reason)s"), {reason: data.reason, url: url});
} else {
textError = _.sprintf(gettext("Installing the plugin from URL \"%(url)s\" failed: %(reason)s"), {reason: data.reason, url: url});
}
} else {
if (data.was_reinstalled) {
textError = _.sprintf(gettext("Reinstalling the plugin from URL \"%(url)s\" failed, please see the log for details."), {url: url});
} else {
textError = _.sprintf(gettext("Installing the plugin from URL \"%(url)s\" failed, please see the log for details."), {url: url});
}
}
} else if (action == "uninstall") {
if (data.hasOwnProperty("plugin")) {
name = data.plugin.name;
}
titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" uninstalled"), {name: name});
textSuccess = gettext("The plugin was uninstalled successfully");
textRestart = gettext("The plugin was uninstalled successfully, however a restart of OctoPrint is needed for that to take effect.");
textReload = gettext("The plugin was uninstalled successfully, however a reload of the page is needed for that to take effect.");
titleError = gettext("Something went wrong");
if (data.hasOwnProperty("reason")) {
textError = _.sprintf(gettext("Uninstalling the plugin failed: %(reason)s"), {reason: data.reason});
} else {
textError = gettext("Uninstalling the plugin failed, please see the log for details.");
}
} else if (action == "enable") {
if (data.hasOwnProperty("plugin")) {
name = data.plugin.name;
}
titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" enabled"), {name: name});
textSuccess = gettext("The plugin was enabled successfully.");
textRestart = gettext("The plugin was enabled successfully, however a restart of OctoPrint is needed for that to take effect.");
textReload = gettext("The plugin was enabled successfully, however a reload of the page is needed for that to take effect.");
titleError = gettext("Something went wrong");
if (data.hasOwnProperty("reason")) {
textError = _.sprintf(gettext("Toggling the plugin failed: %(reason)s"), {reason: data.reason});
} else {
textError = gettext("Toggling the plugin failed, please see the log for details.");
}
} else if (action == "disable") {
if (data.hasOwnProperty("plugin")) {
name = data.plugin.name;
}
titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" disabled"), {name: name});
textSuccess = gettext("The plugin was disabled successfully.");
textRestart = gettext("The plugin was disabled successfully, however a restart of OctoPrint is needed for that to take effect.");
textReload = gettext("The plugin was disabled successfully, however a reload of the page is needed for that to take effect.");
titleError = gettext("Something went wrong");
if (data.hasOwnProperty("reason")) {
textError = _.sprintf(gettext("Toggling the plugin failed: %(reason)s"), {reason: data.reason});
} else {
textError = gettext("Toggling the plugin failed, please see the log for details.");
}
} else {
return;
}
self._displayNotification(data, titleSuccess, textSuccess, textRestart, textReload, titleError, textError);
self.requestData();
}
};
}
// view model class, parameters for constructor, container to bind to
ADDITIONAL_VIEWMODELS.push([PluginManagerViewModel, ["loginStateViewModel", "settingsViewModel"], "#settings_plugin_pluginmanager"]);
});

View file

@ -0,0 +1,81 @@
table {
th, td {
&.settings_plugin_plugin_manager_plugins_name {
text-overflow: ellipsis;
text-align: left;
}
&.settings_plugin_plugin_manager_plugins_actions {
text-align: center;
width: 80px;
a {
text-decoration: none;
color: #000;
&.disabled {
color: #ccc;
cursor: default;
}
}
}
}
}
#settings_plugin_pluginmanager_repositorydialog {
.slimScrollDiv {
margin-bottom: 20px;
}
h4 {
position: relative;
a.dropdown-toggle {
color: inherit;
text-decoration: none;
}
}
.form-search {
text-align: center;
margin-bottom: 5px !important;
}
.form-inline {
padding: 5px;
padding-right: 10px;
margin-bottom: 0;
.help-block {
margin-bottom: 0;
font-size: 85%;
}
}
#settings_plugin_pluginmanager_repositorydialog_unavailable {
overflow: hidden;
width: 100%;
height: 300px;
background-image: url("../img/repo_unavailable.png");
text-align: center;
display: table;
div {
display: table-cell;
vertical-align: middle;
}
}
#settings_plugin_pluginmanager_repositorydialog_list {
overflow: hidden;
width: auto;
height: 300px;
.entry {
border-bottom: 1px solid #ddd;
padding: 5px;
padding-right: 10px;
}
}
}

View file

@ -0,0 +1,145 @@
<h3>{{ _('Installed Plugins') }}</h3>
<table class="table table-striped table-hover table-condensed table-hover">
<thead>
<tr>
<th class="settings_plugin_plugin_manager_plugins_name">{{ _('Name') }}</th>
<th class="settings_plugin_plugin_manager_plugins_actions">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody data-bind="foreach: plugins.paginatedItems">
<tr>
<td class="settings_plugin_plugin_manager_plugins_name">
<div data-bind="css: {muted: !enabled}"><span data-bind="text: name"></span> <span data-bind="visible: version">(<span data-bind="text: version"></span>)</span> <i title="{{ _('Bundled') }}" class="icon-th-large" data-bind="visible: bundled"></i> <i title="Restart needed" class="icon-refresh" data-bind="visible: pending_enable || pending_disable || pending_uninstall"></i> <i title="Uninstalled" class="icon-remove" data-bind="visible: pending_uninstall"></i></div>
<div><small class="muted" data-bind="text: description">&nbsp;</small></div>
<div data-bind="css: {muted: !enabled}">
<small data-bind="visible: url"><i class="icon-home"></i> <a data-bind="attr: {href: url}">{{ _('Homepage') }}</a></small>
<small data-bind="visible: license"><i class="icon-legal"></i> <span data-bind="text: license"></span></small>
<small data-bind="visible: author"><i class="icon-user"></i> <span data-bind="text: author"></span></small>
<small>&nbsp;</small>
</div>
</td>
<td class="settings_plugin_plugin_manager_plugins_actions">
<a href="#" data-bind="css: $root.toggleButtonCss($data), attr: {title: $root.toggleButtonTitle($data)}, enable: key != 'pluginmanager', click: function() { $root.togglePlugin($data) }"></a>&nbsp;|&nbsp;<a href="#" class="icon-trash" title="{{ _('Uninstall Plugin') }}" data-bind="css: {disabled: bundled || key == 'pluginmanager' || pending_uninstall}, enable: !bundled && key != 'pluginmanager' && !pending_uninstall, click: function() { $root.uninstallPlugin($data) }"></a>
</td>
</tr>
</tbody>
</table>
<div class="pagination pagination-mini pagination-centered">
<ul>
<li data-bind="css: {disabled: plugins.currentPage() === 0}"><a href="#" data-bind="click: plugins.prevPage">«</a></li>
</ul>
<ul data-bind="foreach: plugins.pages">
<li data-bind="css: { active: $data.number === $root.plugins.currentPage(), disabled: $data.number === -1 }"><a href="#" data-bind="text: $data.text, click: function() { $root.plugins.changePage($data.number); }"></a></li>
</ul>
<ul>
<li data-bind="css: {disabled: plugins.currentPage() === plugins.lastPage()}"><a href="#" data-bind="click: plugins.nextPage">»</a></li>
</ul>
</div>
<button class="btn btn-block" data-bind="click: $root.showRepository">{{ _('Get More...') }}</button>
<div id="settings_plugin_pluginmanager_workingdialog" class="modal hide fade">
<div class="modal-header">
<a href="#" class="close" data-dismiss="modal" aria-hidden="true">&times;</a>
<h3 data-bind="text: workingTitle"></h3>
</div>
<div class="modal-body">
<pre id="settings_plugin_pluginmanager_workingdialog_output" class="terminal pre-scrollable" style="height: 170px" data-bind="foreach: loglines"><span data-bind="text: line, css: {stdout: stream == 'stdout', stderr: stream == 'stderr'}"></span><br></pre>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal" data-bind="enable: !$root.working()" aria-hidden="true">{{ _('Close') }}</button>
</div>
</div>
<div id="settings_plugin_pluginmanager_repositorydialog" class="modal hide fade">
<div class="modal-header">
<a href="#" class="close" data-dismiss="modal" aria-hidden="true">&times;</a>
<h3>{{ _('Install new Plugins...') }}</h3>
</div>
<div class="modal-body">
<h4 style="position: relative">
{{ _('... from the <a href="%(url)s" target="_blank">Plugin Repository</a>', url='http://plugins.octoprint.org') }}
<a class="dropdown-toggle pull-right" data-toggle="dropdown" href="#">
<span class="icon-wrench"></span>
<ul class="dropdown-menu pull-right">
<li><a href="#" data-bind="click: function() { repositoryplugins.changeSorting('title'); }"><i class="icon-ok" data-bind="style: {visibility: repositoryplugins.currentSorting() == 'title' ? 'visible' : 'hidden'}"></i> {{ _('Sort by title') }} ({{ _('ascending') }})</a></li>
<li><a href="#" data-bind="click: function() { repositoryplugins.changeSorting('published'); }"><i class="icon-ok" data-bind="style: {visibility: repositoryplugins.currentSorting() == 'published' ? 'visible' : 'hidden'}"></i> {{ _('Sort by publication date') }} ({{ _('descending') }})</a></li>
<li class="divider"></li>
<li><a href="#" data-bind="click: function() { repositoryplugins.toggleFilter('filter_installed'); }"><i class="icon-ok" data-bind="style: {visibility: _.contains(repositoryplugins.currentFilters(), 'filter_installed') ? 'visible' : 'hidden'}"></i> {{ _('Only show uninstalled plugins') }}</a></li>
<li><a href="#" data-bind="click: function() { repositoryplugins.toggleFilter('filter_incompatible'); }"><i class="icon-ok" data-bind="style: {visibility: _.contains(repositoryplugins.currentFilters(), 'filter_incompatible') ? 'visible' : 'hidden'}"></i> {{ _('Only show compatible plugins') }}</a></li>
<li class="divider"></li>
<li><a href="#" data-bind="click: function() { refreshRepository(); }"><i class="icon-refresh"></i> {{ _('Refresh list from repository') }}</a></li>
</ul>
</a>
</h4>
<form class="form-search">
<input type="text" class="input-block search-query" data-bind="value: repositorySearchQuery, valueUpdate: 'input'" placeholder="{{ _('Search...') }}">
</form>
<div data-bind="visible: repositoryAvailable()">
<div id="settings_plugin_pluginmanager_repositorydialog_list" data-bind="slimScrolledForeach: repositoryplugins.paginatedItems">
<div class="entry">
<div class="row-fluid">
<div class="span9">
<div><span data-bind="text: title"></span></div>
<div><small class="muted" data-bind="text: description">&nbsp;</small></div>
<div>
<small data-bind="visible: page"><i class="icon-info"></i> <a data-bind="attr: {href: page}" target="_blank">{{ _('Details') }}</a></small>
<small data-bind="visible: homepage"><i class="icon-home"></i> <a data-bind="attr: {href: homepage}" target="_blank">{{ _('Homepage') }}</a></small>
<small data-bind="visible: license"><i class="icon-legal"></i> <span data-bind="text: license"></span></small>
<small data-bind="visible: author"><i class="icon-user"></i> <span data-bind="text: author"></span></small>
<small>&nbsp;</small>
</div>
</div>
<div class="span3">
<button class="btn btn-primary btn-block" data-bind="enable: $root.isCompatible($data), css: {disabled: !$root.isCompatible($data)}, click: function() { if ($root.isCompatible($data)) { $root.installFromRepository($data); } else { return false; } }"><i class="icon-add"></i> <span data-bind="text: $root.installButtonText($data)"></span></button>
</div>
</div>
</div>
</div>
</div>
<div id="settings_plugin_pluginmanager_repositorydialog_unavailable" data-bind="visible: !repositoryAvailable()">
<div>
<p>
<strong>{{ _('Sadly the repository is currently not available') }}</strong>
</p>
<p>
<small>{{ _('Is your OctoPrint installation connected to the internet?') }}</small>
</p>
</div>
</div>
<h4>{{ _('... from URL') }}</h4>
<form class="form-inline">
<div class="control-group row-fluid" data-bind="css: {error: invalidUrl}">
<div class="span9">
<input type="text" class="input-block-level" data-bind="value: installUrl, valueUpdate: 'input'" placeholder="{{ _('Enter URL...') }}" >
</div>
<button class="btn btn-primary span3" data-bind="enable: enableUrlInstall, css: {disabled: !enableUrlInstall()}, click: function() { if (enableUrlInstall()) { $root.installPlugin(); } }">{{ _('Install') }}</button>
</div>
<span class="help-block" data-bind="visible: invalidUrl">{{ _('This does not look like a valid "http://" or "https://" URL.') }}</span>
</form>
<h4>{{ _('... from an uploaded archive') }}</h4>
<form class="form-inline">
<div class="control-group row-fluid" data-bind="css: {error: invalidArchive}">
<div class="input-prepend span9">
<span class="btn fileinput-button">
<span>{{ _('Browse...') }}</span>
<input id="settings_plugin_pluginmanager_repositorydialog_upload" type="file" name="file" data-url="{{ url_for("plugin.pluginmanager.upload_archive") }}">
</span>
<span class="add-on add-on-limited text-left" data-bind="text: uploadFilename, attr: {title: uploadFilename}"></span>
</div>
<button id="settings_plugin_pluginmanager_repositorydialog_upload_start" class="btn btn-primary span3" data-bind="enable: enableArchiveInstall, css: {disabled: !enableArchiveInstall()}, click: function(){}">{{ _('Install') }}</button>
</div>
<span class="help-block" data-bind="visible: invalidArchive">{{ _('This does not look like a valid plugin archive. Valid plugin archives should be either zip files or tarballs and have the extension ".zip", ".tar.gz", ".tgz" or ".tar"') }}</span>
</form>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal" aria-hidden="true">{{ _('Close') }}</button>
</div>
</div>

View file

@ -183,7 +183,7 @@ default_settings = {
"settings": [
"section_printer", "serial", "printerprofiles", "temperatures", "terminalfilters", "gcodescripts",
"section_features", "features", "webcam", "accesscontrol", "api",
"section_octoprint", "folders", "appearance", "logs"
"section_octoprint", "folders", "appearance", "logs", "plugin_pluginmanager"
],
"usersettings": ["access", "interface"],
"generic": []