Merge branch 'devel' into chriskoz-terminalHistorySupport

This commit is contained in:
Gina Häußge 2014-02-24 19:11:33 +01:00
commit 586a7a85e3
14 changed files with 479 additions and 19 deletions

View file

@ -12,4 +12,4 @@ API Documentation
connection.rst
printer.rst
job.rst
logs.rst

177
docs/api/logs.rst Normal file
View file

@ -0,0 +1,177 @@
.. _sec-api-logs:
*******************
Log file management
*******************
.. note::
All log file management operations require admin rights.
.. contents::
.. _sec-api-logs-list:
Retrieve a list of available log files
======================================
.. http:post:: /api/logs
Retrieve information regarding all log files currently available and regarding the disk space still available
in the system on the location the log files are being stored.
Returns a :ref:`Logfile Retrieve response <sec-api-logs-datamodel-retrieveresponse>`.
**Example Request**
.. sourcecode:: http
GET /api/logs HTTP/1.1
Host: example.com
X-Api-Key: abcdef...
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"files" : [
{
"date" : 1393158814,
"name" : "octoprint.log",
"size" : 43712,
"refs": {
"resource": "http://example.com/api/logs/octoprint.log",
"download": "http://example.com/downloads/logs/octoprint.log"
}
},
{
"date" : 1392628936,
"name" : "octoprint.log.2014-02-17",
"size" : 13205,
"refs": {
"resource": "http://example.com/api/logs/octoprint.log.2014-02-17",
"download": "http://example.com/downloads/logs/octoprint.log.2014-02-17"
}
},
{
"date" : 1393158814,
"name" : "serial.log",
"size" : 1798419,
"refs": {
"resource": "http://example.com/api/logs/serial.log",
"download": "http://example.com/downloads/logs/serial.log"
}
}
],
"free": 12237201408
}
:statuscode 200: No error
:statuscode 403: If the given API token did not have admin rights associated with it
.. _sec-api-logs-delete:
Delete a specific logfile
=========================
.. http:delete:: /api/logs/(path:filename)
Delete the selected log file with name `filename`.
Returns a :http:statuscode:`204` after successful deletion.
**Example Request**
.. sourcecode:: http
DELETE /api/logs/octoprint.log.2014-02-17 HTTP/1.1
Host: example.com
X-Api-Key: abcdef...
:param filename: The filename of the log file to delete
:statuscode 204: No error
:statuscode 403: If the given API token did not have admin rights associated with it
:statuscode 404: If the file was not found
.. _sec-api-logs-datamodel:
Datamodel
=========
.. _sec-api-logs-datamodel-retrieveresponse:
Logfile Retrieve Response
-------------------------
.. list-table::
:widths: 15 5 10 30
:header-rows: 1
* - Name
- Multiplicity
- Type
- Description
* - ``files``
- 0..*
- Array of :ref:`File information items <sec-api-logs-datamodel-fileinfo>`
- The list of requested files. Might be an empty list if no files are available
* - ``free``
- 1
- String
- The amount of disk space in bytes available in the local disk space (refers to OctoPrint's ``logs`` folder).
.. _sec-api-logs-datamodel-fileinfo:
File information
----------------
.. list-table::
:widths: 15 5 10 30
:header-rows: 1
* - Name
- Multiplicity
- Type
- Description
* - ``name``
- 1
- String
- The name of the file
* - ``size``
- 1
- Number
- The size of the file in bytes.
* - ``date``
- 1
- Unix timestamp
- The timestamp when this file was last modified.
* - ``refs``
- 1
- :ref:`sec-api-logs-datamodel-ref`
- References relevant to this file
.. _sec-api-logs-datamodel-ref:
References
----------
.. list-table::
:widths: 15 5 10 30
:header-rows: 1
* - Name
- Multiplicity
- Type
- Description
* - ``resource``
- 1
- URL
- The resource that represents the file (e.g. for deleting)
* - ``download``
- 1
- URL
- The download URL for the file

View file

@ -2,6 +2,8 @@
__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
import flask
import tornado.wsgi
from sockjs.tornado import SockJSRouter
from flask import Flask, render_template, send_from_directory, make_response
from flask.ext.login import LoginManager
@ -27,8 +29,8 @@ principals = Principal(app)
admin_permission = Permission(RoleNeed("admin"))
user_permission = Permission(RoleNeed("user"))
from octoprint.server.util import LargeResponseHandler, ReverseProxied, restricted_access, PrinterStateConnection
# only import the octoprint stuff down here, as it might depend on things defined above to be initialized already
from octoprint.server.util import LargeResponseHandler, ReverseProxied, restricted_access, PrinterStateConnection, admin_validator
from octoprint.printer import Printer, getConnectionOptions
from octoprint.settings import settings
import octoprint.gcodefiles as gcodefiles
@ -174,9 +176,23 @@ class Server():
self._router = SockJSRouter(self._createSocketConnection, "/sockjs")
def admin_access_validation(request):
"""
Creates a custom wsgi and Flask request context in order to be able to process user information
stored in the current session.
:param request: The Tornado request for which to create the environment and context
"""
wsgi_environ = tornado.wsgi.WSGIContainer.environ(request)
with app.request_context(wsgi_environ):
app.session_interface.open_session(app, flask.request)
loginManager.reload_user()
admin_validator(flask.request)
self._tornado_app = Application(self._router.urls + [
(r"/downloads/timelapse/([^/]*\.mpg)", LargeResponseHandler, {"path": settings().getBaseFolder("timelapse"), "as_attachment": True}),
(r"/downloads/files/local/([^/]*\.(gco|gcode))", LargeResponseHandler, {"path": settings().getBaseFolder("uploads"), "as_attachment": True}),
(r"/downloads/logs/([^/]*)", LargeResponseHandler, {"path": settings().getBaseFolder("logs"), "as_attachment": True, "access_validation": admin_access_validation}),
(r".*", FallbackHandler, {"fallback": WSGIContainer(app.wsgi_app)})
])
self._server = HTTPServer(self._tornado_app)

View file

@ -27,6 +27,7 @@ from . import files as api_files
from . import settings as api_settings
from . import timelapse as api_timelapse
from . import users as api_users
from . import log as api_logs
#~~ first run setup

View file

@ -11,7 +11,6 @@ import octoprint.util as util
from octoprint.filemanager.destinations import FileDestinations
from octoprint.settings import settings, valid_boolean_trues
from octoprint.server import printer, gcodeManager, eventManager, restricted_access, NO_CONTENT
from octoprint.server.util import urlForDownload
from octoprint.server.api import api
@ -66,7 +65,7 @@ def _getFileList(origin):
file.update({
"refs": {
"resource": url_for(".readGcodeFile", target=FileDestinations.LOCAL, filename=file["name"], _external=True),
"download": urlForDownload(FileDestinations.LOCAL, file["name"])
"download": url_for("index", _external=True) + "downloads/files/" + FileDestinations.LOCAL + "/" + file["name"]
}
})
return files
@ -169,7 +168,7 @@ def uploadGcodeFile(target):
"origin": FileDestinations.LOCAL,
"refs": {
"resource": url_for(".readGcodeFile", target=FileDestinations.LOCAL, filename=filename, _external=True),
"download": urlForDownload(FileDestinations.LOCAL, filename)
"download": url_for("index", _external=True) + "downloads/files/" + FileDestinations.LOCAL + "/" + filename
}
}
})
@ -269,6 +268,5 @@ def deleteGcodeFile(filename, target):
else:
gcodeManager.removeFile(filename)
# return an updated list of files
return readGcodeFiles()
return NO_CONTENT

View file

@ -0,0 +1,62 @@
# coding=utf-8
__author__ = "Marc Hannappel Salandora"
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
import os
from flask import request, jsonify, url_for, make_response
from werkzeug.utils import secure_filename
from octoprint.settings import settings
from octoprint.server import restricted_access, NO_CONTENT, admin_permission
from octoprint.server.util import redirectToTornado
from octoprint.server.api import api
from octoprint.util import getFreeBytes
@api.route("/logs", methods=["GET"])
@restricted_access
@admin_permission.require(403)
def getLogFiles():
files = _getLogFiles()
return jsonify(files=files, free=getFreeBytes(settings().getBaseFolder("logs")))
@api.route("/logs/<path:filename>", methods=["GET"])
@restricted_access
@admin_permission.require(403)
def downloadLog(filename):
return redirectToTornado(request, url_for("index") + "downloads/logs/" + filename)
@api.route("/logs/<path:filename>", methods=["DELETE"])
@restricted_access
@admin_permission.require(403)
def deleteLog(filename):
secure = os.path.join(settings().getBaseFolder("logs"), secure_filename(filename))
if not os.path.exists(secure):
return make_response("File not found: %s" % filename, 404)
os.remove(secure)
return NO_CONTENT
def _getLogFiles():
files = []
basedir = settings().getBaseFolder("logs")
for osFile in os.listdir(basedir):
statResult = os.stat(os.path.join(basedir, osFile))
files.append({
"name": osFile,
"date": int(statResult.st_mtime),
"size": statResult.st_size,
"refs": {
"resource": url_for(".downloadLog", filename=osFile, _external=True),
"download": url_for("index", _external=True) + "downloads/logs/" + osFile
}
})
return files

View file

@ -1,7 +1,11 @@
# coding=utf-8
__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
from flask.ext.principal import identity_changed, Identity
from tornado.web import StaticFileHandler, HTTPError
from flask import url_for, make_response, request, current_app
from flask.ext.login import login_required, login_user
from flask.ext.login import login_required, login_user, current_user
from werkzeug.utils import redirect
from sockjs.tornado import SockJSConnection
@ -21,6 +25,7 @@ import octoprint.server
from octoprint.users import ApiUser
from octoprint.events import Events
def restricted_access(func, apiEnabled=True):
"""
If you decorate a view with this, it will ensure that first setup has been
@ -80,11 +85,32 @@ def api_access(func):
return decorated_view
def _getUserForApiKey(apikey):
if settings().get(["api", "enabled"]) and apikey is not None:
if apikey == settings().get(["api", "key"]):
# master key was used
return ApiUser()
else:
# user key might have been used
return octoprint.server.userManager.findUser(apikey=apikey)
else:
return None
def _getApiKey(request):
if "apikey" in request.values:
# Check Flask GET/POST arguments
if hasattr(request, "values") and "apikey" in request.values:
return request.values["apikey"]
# Check Tornado GET/POST arguments
if hasattr(request, "arguments") and "apikey" in request.arguments \
and len(request.arguments["apikey"]) > 0 and len(request.arguments["apikey"].strip()) > 0:
return request.arguments["apikey"]
# Check Tornado and Flask headers
if "X-Api-Key" in request.headers.keys():
return request.headers.get("X-Api-Key")
return None
@ -204,11 +230,15 @@ class LargeResponseHandler(StaticFileHandler):
CHUNK_SIZE = 16 * 1024
def initialize(self, path, default_filename=None, as_attachment=False):
def initialize(self, path, default_filename=None, as_attachment=False, access_validation=None):
StaticFileHandler.initialize(self, path, default_filename)
self._as_attachment = as_attachment
self._access_validation = access_validation
def get(self, path, include_body=True):
if self._access_validation is not None:
self._access_validation(self.request)
path = self.parse_url_path(path)
abspath = os.path.abspath(os.path.join(self.root, path))
# os.path.abspath strips a trailing /
@ -273,6 +303,29 @@ class LargeResponseHandler(StaticFileHandler):
self.set_header("Content-Disposition", "attachment")
#~~ admin access validator for use with tornado
def admin_validator(request):
"""
Validates that the given request is made by an admin user, identified either by API key or existing Flask
session.
Must be executed in an existing Flask request context!
:param request: The Flask request object
"""
apikey = _getApiKey(request)
if settings().get(["api", "enabled"]) and apikey is not None:
user = _getUserForApiKey(apikey)
else:
user = current_user
if user is None or not user.is_authenticated() or not user.is_admin():
raise HTTPError(403)
#~~ reverse proxy compatible wsgi middleware
@ -333,7 +386,3 @@ def redirectToTornado(request, target):
redirectUrl += fragment
return redirect(redirectUrl)
def urlForDownload(origin, filename):
return url_for("index", _external=True) + "downloads/files/" + origin + "/" + filename

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewModel, temperatureViewModel, controlViewModel, terminalViewModel, gcodeFilesViewModel, timelapseViewModel, gcodeViewModel) {
function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewModel, temperatureViewModel, controlViewModel, terminalViewModel, gcodeFilesViewModel, timelapseViewModel, gcodeViewModel, logViewModel) {
var self = this;
self.loginStateViewModel = loginStateViewModel;
@ -10,6 +10,7 @@ function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewM
self.gcodeFilesViewModel = gcodeFilesViewModel;
self.timelapseViewModel = timelapseViewModel;
self.gcodeViewModel = gcodeViewModel;
self.logViewModel = logViewModel;
self._socket = undefined;
self._autoReconnecting = false;
@ -38,7 +39,8 @@ function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewM
self._autoReconnectTrial = 0;
if ($("#offline_overlay").is(":visible")) {
$("#offline_overlay").hide();
$("#offline_overlay").hide();
self.logViewModel.requestData();
self.timelapseViewModel.requestData();
self.loginStateViewModel.requestData();
self.gcodeFilesViewModel.requestData();

View file

@ -13,6 +13,7 @@ $(function() {
var gcodeFilesViewModel = new GcodeFilesViewModel(printerStateViewModel, loginStateViewModel);
var gcodeViewModel = new GcodeViewModel(loginStateViewModel, settingsViewModel);
var navigationViewModel = new NavigationViewModel(loginStateViewModel, appearanceViewModel, settingsViewModel, usersViewModel);
var logViewModel = new LogViewModel(loginStateViewModel);
var dataUpdater = new DataUpdater(
loginStateViewModel,
@ -23,7 +24,8 @@ $(function() {
terminalViewModel,
gcodeFilesViewModel,
timelapseViewModel,
gcodeViewModel
gcodeViewModel,
logViewModel
);
// work around a stupid iOS6 bug where ajax requests get cached and only work once, as described at
@ -286,6 +288,12 @@ $(function() {
}
}
ko.bindingHandlers.allowBindings = {
init: function (elem, valueAccessor) {
return { controlsDescendantBindings: !valueAccessor() };
}
};
ko.applyBindings(connectionViewModel, document.getElementById("connection_accordion"));
ko.applyBindings(printerStateViewModel, document.getElementById("state_accordion"));
ko.applyBindings(gcodeFilesViewModel, document.getElementById("files_accordion"));
@ -301,6 +309,7 @@ $(function() {
ko.applyBindings(navigationViewModel, document.getElementById("navbar"));
ko.applyBindings(appearanceViewModel, document.getElementsByTagName("head")[0]);
ko.applyBindings(printerStateViewModel, document.getElementById("drop_overlay"));
ko.applyBindings(logViewModel, document.getElementById("logs"));
var timelapseElement = document.getElementById("timelapse");
if (timelapseElement) {
@ -315,6 +324,7 @@ $(function() {
gcodeFilesViewModel.requestData();
timelapseViewModel.requestData();
settingsViewModel.requestData();
logViewModel.requestData();
loginStateViewModel.subscribe(function(change, data) {
if ("login" == change) {

View file

@ -0,0 +1,62 @@
function LogViewModel(loginStateViewModel) {
var self = this;
self.loginState = loginStateViewModel;
// initialize list helper
self.listHelper = new ItemListHelper(
"logFiles",
{
"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;
},
"modification": function(a, b) {
// sorts descending
if (a["date"] > b["date"]) return -1;
if (a["date"] < b["date"]) return 1;
return 0;
},
"size": function(a, b) {
// sorts descending
if (a["bytes"] > b["bytes"]) return -1;
if (a["bytes"] < b["bytes"]) return 1;
return 0;
}
},
{
},
"name",
[],
[],
CONFIG_LOGFILESPERPAGE
);
self.requestData = function() {
$.ajax({
url: API_BASEURL + "logs",
type: "GET",
dataType: "json",
success: self.fromResponse
});
};
self.fromResponse = function(response) {
var files = response.files;
if (files === undefined)
return;
self.listHelper.updateItems(files);
}
self.removeFile = function(filename) {
$.ajax({
url: API_BASEURL + "logs/" + filename,
type: "DELETE",
dataType: "json",
success: self.requestData
});
}
}

View file

@ -279,6 +279,38 @@ table {
}
}
}
// log files
&.settings_logs_name {
text-overflow: ellipsis;
text-align: left;
}
&.settings_logs_size {
text-align: right;
width: 70px;
}
&.settings_logs_date {
text-align: left;
width: 130px;
}
&.settings_logs_action {
text-align: center;
width: 70px;
a {
text-decoration: none;
color: #000;
&.disabled {
color: #ccc;
cursor: default;
}
}
}
}
}

View file

@ -27,6 +27,7 @@
var CONFIG_GCODEFILESPERPAGE = 5;
var CONFIG_TIMELAPSEFILESPERPAGE = 10;
var CONFIG_LOGFILESPERPAGE = 10;
var CONFIG_USERSPERPAGE = 10;
var CONFIG_WEBCAM_STREAM = "{{ webcamStream }}";
var CONFIG_ACCESS_CONTROL = {% if enableAccessControl -%} true; {% else %} false; {%- endif %}
@ -644,6 +645,7 @@
<script type="text/javascript" src="{{ url_for('static', filename='js/app/viewmodels/terminal.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/app/viewmodels/timelapse.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/app/viewmodels/users.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/app/viewmodels/log.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/app/dataupdater.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/app/helpers.js') }}"></script>

View file

@ -20,6 +20,7 @@
<li class="nav-header">OctoPrint</li>
<li><a href="#settings_folder" data-toggle="tab">Folders</a></li>
<li><a href="#settings_appearance" data-toggle="tab">Appearance</a></li>
<li><a href="#settings_logs" data-toggle="tab">Logs</a></li>
</ul>
<div class="tab-content span8">
@ -535,6 +536,54 @@
</div>
{% endif %}
<div class="tab-pane" id="settings_logs" data-bind="allowBindings: false">
<div id="logs">
<h1>Logs</h1>
<div class="pull-right">
<small>
Sort by: <a href="#" data-bind="click: function() { listHelper.changeSorting('name'); }">Name (ascending)</a> | <a href="#" data-bind="click: function() { listHelper.changeSorting('modification'); }">Modification date (descending)</a> | <a href="#" data-bind="click: function() { listHelper.changeSorting('size'); }">Size (descending)</a>
</small>
</div>
<table class="table table-striped table-hover table-condensed table-hover" id="log_files">
<thead>
<tr>
<th class="settings_logs_name">Name</th>
<th class="settings_logs_size">Size</th>
<th class="settings_logs_date">Date</th>
<th class="settings_logs_action">Action</th>
</tr>
</thead>
<tbody data-bind="foreach: listHelper.paginatedItems">
<tr data-bind="attr: {title: name}">
<td class="settings_logs_name" data-bind="text: name"></td>
<td class="settings_logs_size" data-bind="text: formatSize(size)"></td>
<td class="settings_logs_date" data-bind="text: formatDate(date)"></td>
<td class="settings_logs_action">
<a href="#" class="icon-trash" data-bind="click: function() { if ($root.loginState.isUser()) { $parent.removeFile($data.name); } else { return; } }, css: {disabled: !$root.loginState.isUser()}"></a>&nbsp;|&nbsp;<a href="#" class="icon-download" data-bind="attr: {href: refs.download}"></a>
</td>
</tr>
</tbody>
</table>
<div class="pagination pagination-mini pagination-centered">
<ul>
<li data-bind="css: {disabled: listHelper.currentPage() === 0}">
<a href="#" data-bind="click: listHelper.prevPage">«</a>
</li>
</ul>
<ul data-bind="foreach: listHelper.pages">
<li data-bind="css: { active: $data.number === $root.listHelper.currentPage(), disabled: $data.number === -1 }">
<a href="#" data-bind="text: $data.text, click: function() { $root.listHelper.changePage($data.number); }"></a>
</li>
</ul>
<ul>
<li data-bind="css: {disabled: listHelper.currentPage() === listHelper.lastPage()}">
<a href="#" data-bind="click: listHelper.nextPage">»</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>