Merge branch 'devel' into Salandora-settings

This commit is contained in:
Gina Häußge 2014-03-02 22:13:03 +01:00
commit 17f3c5777e
19 changed files with 573 additions and 46 deletions

View file

@ -20,7 +20,7 @@ Get connection settings
.. sourcecode:: http
GET /api/control/connection HTTP/1.1
GET /api/connection HTTP/1.1
Host: example.com
Content-Type: application/json
X-Api-Key: abcdef...
@ -77,7 +77,7 @@ Issue a connection command
.. sourcecode:: http
POST /api/control/connection HTTP/1.1
POST /api/connection HTTP/1.1
Host: example.com
Content-Type: application/json
X-Api-Key: abcdef...
@ -94,7 +94,7 @@ Issue a connection command
.. sourcecode:: http
POST /api/control/connection HTTP/1.1
POST /api/connection HTTP/1.1
Host: example.com
Content-Type: application/json
X-Api-Key: abcdef...
@ -114,6 +114,6 @@ Issue a connection command
Defaults to ``false`` if not set.
:json boolean autoconnect: ``connect`` command: Whether to attempt to automatically connect to the printer on server
startup. If not set no changes will be made to the current setting.
:statuscode 200: No error
:statuscode 204: No error
:statuscode 400: If the selected `port` or `baudrate` for a ``connect`` command are not part of the available
options.

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

@ -560,8 +560,6 @@ class MetadataAnalyzer:
def _work(self):
aborted = None
while True:
self._active.wait()
if aborted is not None:
filename = aborted
aborted = None
@ -570,6 +568,8 @@ class MetadataAnalyzer:
(priority, filename) = self._queue.get()
self._logger.debug("Processing file %s from queue (priority %d)" % (filename, priority))
self._active.wait()
try:
self._analyzeGcode(filename)
self._queue.task_done()

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)
@ -190,6 +206,8 @@ class Server():
printer.connect(port, baudrate)
try:
IOLoop.instance().start()
except KeyboardInterrupt:
logger.info("Goodbye!")
except:
logger.fatal("Now that is embarrassing... Something really really went wrong here. Please report this including the stacktrace below in OctoPrint's bugtracker. Thanks!")
logger.exception("Stacktrace follows:")

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

@ -1,5 +1,4 @@
$(function() {
//~~ Initialize view models
var loginStateViewModel = new LoginStateViewModel();
var usersViewModel = new UsersViewModel(loginStateViewModel);
@ -14,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,
@ -24,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
@ -287,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"));
@ -302,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) {
@ -316,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

@ -21,6 +21,9 @@ function TerminalViewModel(loginStateViewModel, settingsViewModel) {
self.filters = self.settings.terminalFilters;
self.filterRegex = undefined;
self.cmdHistory = [];
self.cmdHistoryIdx = -1;
self.activeFilters = ko.observableArray([]);
self.activeFilters.subscribe(function(e) {
self.updateFilterRegex();
@ -30,25 +33,25 @@ function TerminalViewModel(loginStateViewModel, settingsViewModel) {
self.fromCurrentData = function(data) {
self._processStateData(data.state);
self._processCurrentLogData(data.logs);
}
};
self.fromHistoryData = function(data) {
self._processStateData(data.state);
self._processHistoryLogData(data.logHistory);
}
};
self._processCurrentLogData = function(data) {
if (!self.log)
self.log = []
self.log = self.log.concat(data)
self.log = self.log.slice(-300)
self.log = [];
self.log = self.log.concat(data);
self.log = self.log.slice(-300);
self.updateOutput();
}
};
self._processHistoryLogData = function(data) {
self.log = data;
self.updateOutput();
}
};
self._processStateData = function(data) {
self.isErrorOrClosed(data.flags.closedOrError);
@ -58,7 +61,7 @@ function TerminalViewModel(loginStateViewModel, settingsViewModel) {
self.isError(data.flags.error);
self.isReady(data.flags.ready);
self.isLoading(data.flags.loading);
}
};
self.updateFilterRegex = function() {
var filterRegexStr = self.activeFilters().join("|").trim();
@ -68,7 +71,7 @@ function TerminalViewModel(loginStateViewModel, settingsViewModel) {
self.filterRegex = new RegExp(filterRegexStr);
}
console.log("Terminal filter regex: " + filterRegexStr);
}
};
self.updateOutput = function() {
if (!self.log)
@ -86,10 +89,13 @@ function TerminalViewModel(loginStateViewModel, settingsViewModel) {
if (self.autoscrollEnabled()) {
container.scrollTop(container[0].scrollHeight - container.height())
}
}
};
self.sendCommand = function() {
var command = self.command();
if (!command) {
return;
}
var re = /^([gmt][0-9]+)(\s.*)?/;
var commandMatch = command.match(re);
@ -105,14 +111,45 @@ function TerminalViewModel(loginStateViewModel, settingsViewModel) {
contentType: "application/json; charset=UTF-8",
data: JSON.stringify({"command": command})
});
self.cmdHistory.push(command);
self.cmdHistory.slice(-300); // just to set a sane limit to how many manually entered commands will be saved...
self.cmdHistoryIdx = self.cmdHistory.length;
self.command("");
}
}
};
self.handleEnter = function(event) {
self.handleKeyDown = function(event) {
var keyCode = event.keyCode;
if (keyCode == 38 || keyCode == 40) {
if (keyCode == 38 && self.cmdHistory.length > 0 && self.cmdHistoryIdx > 0) {
self.cmdHistoryIdx--;
} else if (keyCode == 40 && self.cmdHistoryIdx < self.cmdHistory.length - 1) {
self.cmdHistoryIdx++;
}
if (self.cmdHistoryIdx >= 0 && self.cmdHistoryIdx < self.cmdHistory.length) {
self.command(self.cmdHistory[self.cmdHistoryIdx]);
}
// prevent the cursor from being moved to the beginning of the input field (this is actually the reason
// why we do the arrow key handling in the keydown event handler, keyup would be too late already to
// prevent this from happening, causing a jumpy cursor)
return false;
}
// do not prevent default action
return true;
};
self.handleKeyUp = function(event) {
if (event.keyCode == 13) {
self.sendCommand();
}
}
// do not prevent default action
return true;
};
}

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 %}
@ -516,7 +517,7 @@
</div>
<div class="input-append" style="display: none;" data-bind="visible: loginState.isUser">
<input type="text" id="terminal-command" data-bind="value: command, event: { keyup: function(d,e) { handleEnter(e); } }, enable: isOperational() && loginState.isUser()">
<input type="text" id="terminal-command" data-bind="value: command, event: { keyup: function(d,e) { return handleKeyUp(e); }, keydown: function(d,e) { return handleKeyDown(e); } }, enable: isOperational() && loginState.isUser()">
<button class="btn" type="button" id="terminal-send" data-bind="click: sendCommand, enable: isOperational() && loginState.isUser()">Send</button>
</div>
</div>
@ -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">
@ -553,6 +554,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>

View file

@ -191,6 +191,24 @@ def silentRemove(file):
pass
def sanitizeAscii(line):
return unicode(line, 'ascii', 'replace').encode('ascii', 'replace').rstrip()
def filterNonAscii(line):
"""
Returns True if the line contains non-ascii characters, false otherwise
@param line the line to test
"""
try:
unicode(line, 'ascii').encode('ascii')
return False
except ValueError:
return True
def getJsonCommandFromRequest(request, valid_commands):
if not "application/json" in request.headers["Content-Type"]:
return None, None, make_response("Expected content-type JSON", 400)

View file

@ -23,7 +23,7 @@ from octoprint.settings import settings
from octoprint.events import eventManager, Events
from octoprint.filemanager.destinations import FileDestinations
from octoprint.gcodefiles import isGcodeFileName
from octoprint.util import getExceptionString, getNewTimeout
from octoprint.util import getExceptionString, getNewTimeout, sanitizeAscii, filterNonAscii
from octoprint.util.virtual import VirtualPrinter
try:
@ -341,6 +341,7 @@ class MachineCom(object):
if self._currentFile is not None:
payload = {
"file": self._currentFile.getFilename(),
"filename": os.path.basename(self._currentFile.getFilename()),
"origin": self._currentFile.getFileLocation()
}
eventManager().fire(Events.PRINT_FAILED, payload)
@ -372,6 +373,7 @@ class MachineCom(object):
self._changeState(self.STATE_PRINTING)
eventManager().fire(Events.PRINT_STARTED, {
"file": self._currentFile.getFilename(),
"filename": os.path.basename(self._currentFile.getFilename()),
"origin": self._currentFile.getFileLocation()
})
@ -438,6 +440,7 @@ class MachineCom(object):
eventManager().fire(Events.PRINT_CANCELLED, {
"file": self._currentFile.getFilename(),
"filename": os.path.basename(self._currentFile.getFilename()),
"origin": self._currentFile.getFileLocation()
})
@ -454,6 +457,7 @@ class MachineCom(object):
eventManager().fire(Events.PRINT_RESUMED, {
"file": self._currentFile.getFilename(),
"filename": os.path.basename(self._currentFile.getFilename()),
"origin": self._currentFile.getFileLocation()
})
elif pause and self.isPrinting():
@ -463,6 +467,7 @@ class MachineCom(object):
eventManager().fire(Events.PRINT_PAUSED, {
"file": self._currentFile.getFilename(),
"filename": os.path.basename(self._currentFile.getFilename()),
"origin": self._currentFile.getFileLocation()
})
@ -600,7 +605,11 @@ class MachineCom(object):
##~~ SD file list
# if we are currently receiving an sd file list, each line is just a filename, so just read it and abort processing
if self._sdFileList and isGcodeFileName(line.strip().lower()) and not 'End file list' in line:
self._sdFiles.append(line.strip().lower())
filename = line.strip().lower()
if filterNonAscii(filename):
self._logger.warn("Got a file from printer's SD that has a non-ascii filename (%s), that shouldn't happen according to the protocol" % filename)
else:
self._sdFiles.append(filename)
continue
##~~ Temperature processing
@ -665,8 +674,9 @@ class MachineCom(object):
self._changeState(self.STATE_OPERATIONAL)
eventManager().fire(Events.PRINT_DONE, {
"file": self._currentFile.getFilename(),
"filename": os.path.basename(self._currentFile.getFilename()),
"origin": self._currentFile.getFileLocation(),
"time": time.time() - self._currentFile.getStartTime()
"time": self.getPrintTime()
})
elif 'Done saving file' in line:
self.refreshSdFiles()
@ -914,7 +924,7 @@ class MachineCom(object):
if ret == '':
#self._log("Recv: TIMEOUT")
return ''
self._log("Recv: %s" % (unicode(ret, 'ascii', 'replace').encode('ascii', 'replace').rstrip()))
self._log("Recv: %s" % sanitizeAscii(ret))
return ret
def _sendNext(self):
@ -928,7 +938,7 @@ class MachineCom(object):
payload = {
"local": self._currentFile.getLocalFilename(),
"remote": self._currentFile.getRemoteFilename(),
"time": time.time() - self._currentFile.getStartTime()
"time": self.getPrintTime()
}
self._currentFile = None
@ -939,8 +949,9 @@ class MachineCom(object):
else:
payload = {
"file": self._currentFile.getFilename(),
"filename": os.path.basename(self._currentFile.getFilename()),
"origin": self._currentFile.getFileLocation(),
"time": time.time() - self._currentFile.getStartTime()
"time": self.getPrintTime()
}
self._callback.mcPrintjobDone()
self._changeState(self.STATE_OPERATIONAL)