Merge branch 'fix/terminalLoad' into devel
Conflicts: src/octoprint/static/css/octoprint.css src/octoprint/static/js/app/dataupdater.js src/octoprint/static/js/app/main.js
This commit is contained in:
commit
45e159742e
10 changed files with 281 additions and 30 deletions
|
|
@ -12,7 +12,7 @@ Push updates
|
|||
|
||||
.. contents::
|
||||
|
||||
To enable real time information exchange between client and server, OctoPrint uses
|
||||
To enable real time information exchange between client and server, OctoPrint uses
|
||||
`SockJS <https://github.com/sockjs/sockjs-protocol>`_ to push
|
||||
status updates, temperature changes etc to connected web interface instances.
|
||||
|
||||
|
|
@ -43,6 +43,24 @@ Clients must ignore any unknown messages.
|
|||
|
||||
The data model of the attached payloads is described further below.
|
||||
|
||||
OctoPrint's SockJS socket also accepts one command from the client to the server,
|
||||
the ``throttle`` command. Usually, OctoPrint will push the general state update
|
||||
in the ``current`` message twice per second. For some clients that might still
|
||||
be too fast, so they can signal a different factor to OctoPrint utilizing the
|
||||
``throttle`` message. OctoPrint expects a single integer here which represents
|
||||
the multiplier for the base rate limit of one message every 500ms. A value of
|
||||
1 hence will produce the default behaviour of getting every update. A value of
|
||||
2 will set the rate limit to maximally one message every 1s, 3 to maximally one
|
||||
message every 1.5s and so on.
|
||||
|
||||
Example for a ``throttle`` client-server-message:
|
||||
|
||||
.. sourcecode:: javascript
|
||||
|
||||
{
|
||||
"throttle": 2
|
||||
}
|
||||
|
||||
.. _sec-api-push-datamodel:
|
||||
|
||||
Datamodel
|
||||
|
|
|
|||
|
|
@ -897,6 +897,7 @@ def collect_plugin_assets(enable_gcodeviewer=True, preferred_stylesheet="css"):
|
|||
'js/app/bindings/popover.js',
|
||||
'js/app/bindings/qrcode.js',
|
||||
'js/app/bindings/slimscrolledforeach.js',
|
||||
'js/app/bindings/toggle.js',
|
||||
'js/app/bindings/togglecontent.js',
|
||||
'js/app/viewmodels/appearance.js',
|
||||
'js/app/viewmodels/connection.js',
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection, octoprint.printer.
|
|||
|
||||
self._remoteAddress = None
|
||||
|
||||
self._throttleFactor = 1
|
||||
self._lastCurrent = 0
|
||||
self._baseRateLimit = 0.5
|
||||
|
||||
def _getRemoteAddress(self, info):
|
||||
forwardedFor = info.headers.get("X-Forwarded-For")
|
||||
if forwardedFor is not None:
|
||||
|
|
@ -94,9 +98,31 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection, octoprint.printer.
|
|||
self._eventManager.unsubscribe(event, self._onEvent)
|
||||
|
||||
def on_message(self, message):
|
||||
pass
|
||||
try:
|
||||
import json
|
||||
message = json.loads(message)
|
||||
except:
|
||||
self._logger.warn("Invalid JSON received from client {}, ignoring: {!r}".format(self._remoteAddress, message))
|
||||
return
|
||||
|
||||
if "throttle" in message:
|
||||
try:
|
||||
throttle = int(message["throttle"])
|
||||
if throttle < 1:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
self._logger.warn("Got invalid throttle factor from client {}, ignoring: {!r}".format(self._remoteAddress, message["throttle"]))
|
||||
else:
|
||||
self._throttleFactor = throttle
|
||||
self._logger.debug("Set throttle factor for client {} to {}".format(self._remoteAddress, self._throttleFactor))
|
||||
|
||||
def on_printer_send_current_data(self, data):
|
||||
# make sure we rate limit the updates according to our throttle factor
|
||||
now = time.time()
|
||||
if now < self._lastCurrent + self._baseRateLimit * self._throttleFactor:
|
||||
return
|
||||
self._lastCurrent = now
|
||||
|
||||
# add current temperature, log and message backlogs to sent data
|
||||
with self._temperatureBacklogMutex:
|
||||
temperatures = self._temperatureBacklog
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
24
src/octoprint/static/js/app/bindings/toggle.js
Normal file
24
src/octoprint/static/js/app/bindings/toggle.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Originally from Knockstrap
|
||||
// https://github.com/faulknercs/Knockstrap/blob/master/src/bindings/toggleBinding.js
|
||||
// License: MIT
|
||||
ko.bindingHandlers.toggle = {
|
||||
init: function (element, valueAccessor) {
|
||||
var value = valueAccessor();
|
||||
|
||||
if (!ko.isObservable(value)) {
|
||||
throw new Error('toggle binding should be used only with observable values');
|
||||
}
|
||||
|
||||
$(element).on('click', function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
var previousValue = ko.utils.unwrapObservable(value);
|
||||
value(!previousValue);
|
||||
});
|
||||
},
|
||||
|
||||
update: function (element, valueAccessor) {
|
||||
ko.utils.toggleDomNodeCssClass(element, 'active', ko.utils.unwrapObservable(valueAccessor()));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -8,7 +8,8 @@
|
|||
var exports = {};
|
||||
|
||||
exports.options = {
|
||||
timeouts: [0, 1, 1, 2, 3, 5, 8, 13, 20, 40, 100]
|
||||
timeouts: [0, 1, 1, 2, 3, 5, 8, 13, 20, 40, 100],
|
||||
rateSlidingWindowSize: 20
|
||||
};
|
||||
|
||||
var normalClose = 1000;
|
||||
|
|
@ -18,6 +19,10 @@
|
|||
var reconnectTrial = 0;
|
||||
var registeredHandlers = {};
|
||||
|
||||
var rateThrottleFactor = 1;
|
||||
var rateBase = 500;
|
||||
var rateLastMeasurements = [];
|
||||
|
||||
var onOpen = function() {
|
||||
reconnecting = false;
|
||||
reconnectTrial = 0;
|
||||
|
|
@ -52,6 +57,8 @@
|
|||
return;
|
||||
}
|
||||
|
||||
var start = new Date().getTime();
|
||||
|
||||
var eventObj = {event: event, data: data};
|
||||
|
||||
var catchAllHandlers = registeredHandlers["*"];
|
||||
|
|
@ -67,6 +74,51 @@
|
|||
handler(eventObj);
|
||||
});
|
||||
}
|
||||
|
||||
var end = new Date().getTime();
|
||||
analyzeTiming(end - start);
|
||||
};
|
||||
|
||||
var analyzeTiming = function(measurement) {
|
||||
while (rateLastMeasurements.length >= exports.options.rateSlidingWindowSize) {
|
||||
rateLastMeasurements.shift();
|
||||
}
|
||||
rateLastMeasurements.push(measurement);
|
||||
|
||||
var processingLimit = rateThrottleFactor * rateBase;
|
||||
if (measurement > processingLimit) {
|
||||
exports.onRateTooHigh(measurement, processingLimit);
|
||||
} else if (rateThrottleFactor > 1) {
|
||||
var maxProcessingTime = Math.max.apply(null, rateLastMeasurements);
|
||||
var lowerProcessingLimit = (rateThrottleFactor - 1) * rateBase;
|
||||
if (maxProcessingTime < lowerProcessingLimit) {
|
||||
exports.onRateTooLow(maxProcessingTime, lowerProcessingLimit);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var increaseRate = function() {
|
||||
if (rateThrottleFactor <= 1) {
|
||||
rateThrottleFactor = 1;
|
||||
return;
|
||||
}
|
||||
rateThrottleFactor--;
|
||||
sendThrottleFactor();
|
||||
};
|
||||
|
||||
var decreaseRate = function() {
|
||||
rateThrottleFactor++;
|
||||
sendThrottleFactor();
|
||||
};
|
||||
|
||||
var sendThrottleFactor = function() {
|
||||
sendMessage("throttle", rateThrottleFactor);
|
||||
};
|
||||
|
||||
var sendMessage = function(type, payload) {
|
||||
var data = {};
|
||||
data[type] = payload;
|
||||
socket.send(JSON.stringify(data));
|
||||
};
|
||||
|
||||
exports.connect = function(opts) {
|
||||
|
|
@ -108,5 +160,16 @@
|
|||
exports.onReconnectAttempt = function(trial) {};
|
||||
exports.onReconnectFailed = function() {};
|
||||
|
||||
exports.onRateTooLow = function(measured, minimum) {
|
||||
increaseRate();
|
||||
};
|
||||
exports.onRateTooHigh = function(measured, maximum) {
|
||||
decreaseRate();
|
||||
};
|
||||
|
||||
exports.increaseRate = increaseRate;
|
||||
exports.decreaseRate = decreaseRate;
|
||||
exports.sendMessage = sendMessage;
|
||||
|
||||
OctoPrint.socket = exports;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,35 @@ function DataUpdater(allViewModels) {
|
|||
self._pluginHash = undefined;
|
||||
self._configHash = undefined;
|
||||
|
||||
self._throttleFactor = 1;
|
||||
self._baseProcessingLimit = 500.0;
|
||||
self._lastProcessingTimes = [];
|
||||
self._lastProcessingTimesSize = 20;
|
||||
|
||||
self.increaseThrottle = function() {
|
||||
self.setThrottle(self._throttleFactor + 1);
|
||||
};
|
||||
|
||||
self.decreaseThrottle = function() {
|
||||
if (self._throttleFactor <= 1) {
|
||||
return;
|
||||
}
|
||||
self.setThrottle(self._throttleFactor - 1);
|
||||
};
|
||||
|
||||
self.setThrottle = function(throttle) {
|
||||
self._throttleFactor = throttle;
|
||||
|
||||
self._send("throttle", self._throttleFactor);
|
||||
log.debug("DataUpdater: New SockJS throttle factor:", self._throttleFactor, " new processing limit:", self._baseProcessingLimit * self._throttleFactor);
|
||||
};
|
||||
|
||||
self._send = function(message, data) {
|
||||
var payload = {};
|
||||
payload[message] = data;
|
||||
self._socket.send(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
self.connect = function() {
|
||||
OctoPrint.socket.connect({debug: !!SOCKJS_DEBUG});
|
||||
};
|
||||
|
|
@ -232,8 +261,20 @@ function DataUpdater(allViewModels) {
|
|||
callViewModels(self.allViewModels, "onDataUpdaterPluginMessage", [event.data.plugin, event.data.data]);
|
||||
};
|
||||
|
||||
self._onIncreaseRate = function(measurement, minimum) {
|
||||
log.debug("We are fast (" + measurement + " < " + minimum + "), increasing refresh rate");
|
||||
OctoPrint.socket.increaseRate();
|
||||
};
|
||||
|
||||
self._onDecreaseRate = function(measurement, maximum) {
|
||||
log.debug("We are slow (" + measurement + " > " + maximum + "), reducing refresh rate");
|
||||
OctoPrint.socket.decreaseRate();
|
||||
};
|
||||
|
||||
OctoPrint.socket.onReconnectAttempt = self._onReconnectAttempt;
|
||||
OctoPrint.socket.onReconnectFailed = self._onReconnectFailed;
|
||||
OctoPrint.socket.onRateTooHigh = self._onDecreaseRate;
|
||||
OctoPrint.socket.onRateTooLow = self._onIncreaseRate;
|
||||
OctoPrint.socket
|
||||
.onMessage("connected", self._onConnected)
|
||||
.onMessage("history", self._onHistoryData)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@ $(function() {
|
|||
self.loginState = parameters[0];
|
||||
self.settings = parameters[1];
|
||||
|
||||
self.tabActive = false;
|
||||
|
||||
self.log = ko.observableArray([]);
|
||||
self.log.extend({ throttle: 500 });
|
||||
self.plainLogLines = ko.observableArray([]);
|
||||
self.plainLogLines.extend({ throttle: 500 });
|
||||
|
||||
self.buffer = ko.observable(300);
|
||||
self.upperLimit = ko.observable(3000);
|
||||
self.upperLimit = ko.observable(1499);
|
||||
|
||||
self.command = ko.observable(undefined);
|
||||
|
||||
|
|
@ -27,7 +33,26 @@ $(function() {
|
|||
self.cmdHistory = [];
|
||||
self.cmdHistoryIdx = -1;
|
||||
|
||||
self.enableFancyFunctionality = ko.observable(true);
|
||||
self.disableTerminalLogDuringPrinting = ko.observable(false);
|
||||
self.acceptableTime = 500;
|
||||
self.acceptableUnfancyTime = 300;
|
||||
|
||||
self.forceFancyFunctionality = ko.observable(false);
|
||||
self.forceTerminalLogDuringPrinting = ko.observable(false);
|
||||
|
||||
self.fancyFunctionality = ko.computed(function() {
|
||||
return self.enableFancyFunctionality() || self.forceFancyFunctionality();
|
||||
});
|
||||
self.terminalLogDuringPrinting = ko.computed(function() {
|
||||
return !self.disableTerminalLogDuringPrinting() || self.forceTerminalLogDuringPrinting();
|
||||
});
|
||||
|
||||
self.displayedLines = ko.computed(function() {
|
||||
if (!self.enableFancyFunctionality()) {
|
||||
return self.log();
|
||||
}
|
||||
|
||||
var regex = self.filterRegex();
|
||||
var lineVisible = function(entry) {
|
||||
return regex == undefined || !entry.line.match(regex);
|
||||
|
|
@ -49,7 +74,18 @@ $(function() {
|
|||
return result;
|
||||
});
|
||||
|
||||
self.plainLogOutput = ko.computed(function() {
|
||||
if (self.fancyFunctionality()) {
|
||||
return;
|
||||
}
|
||||
return self.plainLogLines().join("\n");
|
||||
});
|
||||
|
||||
self.lineCount = ko.computed(function() {
|
||||
if (!self.fancyFunctionality()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var regex = self.filterRegex();
|
||||
var lineVisible = function(entry) {
|
||||
return regex == undefined || !entry.line.match(regex);
|
||||
|
|
@ -88,7 +124,23 @@ $(function() {
|
|||
|
||||
self.fromCurrentData = function(data) {
|
||||
self._processStateData(data.state);
|
||||
|
||||
var start = new Date().getTime();
|
||||
self._processCurrentLogData(data.logs);
|
||||
var end = new Date().getTime();
|
||||
|
||||
var difference = end - start;
|
||||
if (self.enableFancyFunctionality()) {
|
||||
if (difference > self.acceptableTime) {
|
||||
self.enableFancyFunctionality(false);
|
||||
log.warn("Terminal: Detected slow client (needed " + difference + "ms for processing new log data), disabling fancy terminal functionality");
|
||||
}
|
||||
} else {
|
||||
if (!self.disableTerminalLogDuringPrinting() && difference > self.acceptableUnfancyTime) {
|
||||
self.disableTerminalLogDuringPrinting(true);
|
||||
log.warn("Terminal: Detected very slow client (needed " + difference + "ms for processing new log data), completely disabling terminal output during printing");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.fromHistoryData = function(data) {
|
||||
|
|
@ -99,27 +151,48 @@ $(function() {
|
|||
self._processCurrentLogData = function(data) {
|
||||
var length = self.log().length;
|
||||
if (length >= self.upperLimit()) {
|
||||
var cutoff = "--- too many lines to buffer, cut off ---";
|
||||
var last = self.log()[length-1];
|
||||
if (!last || last.type != "cut" || last.line != cutoff) {
|
||||
self.log(self.log().concat(self._toInternalFormat(cutoff, "cut")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.terminalLogDuringPrinting() && self.isPrinting()) {
|
||||
var last = self.plainLogLines()[self.plainLogLines().length - 1];
|
||||
var disabled = "--- client too slow, log output disabled while printing ---";
|
||||
if (last != disabled) {
|
||||
self.plainLogLines.push(disabled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var newLog = self.log().concat(_.map(data, function(line) { return self._toInternalFormat(line) }));
|
||||
var newData = (data.length + length > self.upperLimit())
|
||||
? data.slice(0, self.upperLimit() - length)
|
||||
: data;
|
||||
if (!newData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.fancyFunctionality()) {
|
||||
// lite version of the terminal - text output only
|
||||
self.plainLogLines(self.plainLogLines().concat(newData).slice(-self.buffer()));
|
||||
self.updateOutput();
|
||||
return;
|
||||
}
|
||||
|
||||
var newLog = self.log().concat(_.map(newData, function(line) { return self._toInternalFormat(line) }));
|
||||
if (newData.length != data.length) {
|
||||
var cutoff = "--- too many lines to buffer, cut off ---";
|
||||
newLog.push(self._toInternalFormat(cutoff, "cut"));
|
||||
}
|
||||
|
||||
if (self.autoscrollEnabled()) {
|
||||
// we only keep the last <buffer> entries
|
||||
newLog = newLog.slice(-self.buffer());
|
||||
} else if (newLog.length > self.upperLimit()) {
|
||||
// we only keep the first <upperLimit> entries
|
||||
newLog = newLog.slice(0, self.upperLimit());
|
||||
}
|
||||
self.log(newLog);
|
||||
self.updateOutput();
|
||||
};
|
||||
|
||||
self._processHistoryLogData = function(data) {
|
||||
self.plainLogLines(data);
|
||||
self.log(_.map(data, function(line) { return self._toInternalFormat(line) }));
|
||||
self.updateOutput();
|
||||
};
|
||||
|
|
@ -152,7 +225,7 @@ $(function() {
|
|||
};
|
||||
|
||||
self.updateOutput = function() {
|
||||
if (self.autoscrollEnabled()) {
|
||||
if (self.tabActive && OctoPrint.coreui.browserTabVisible && self.autoscrollEnabled()) {
|
||||
self.scrollToEnd();
|
||||
}
|
||||
};
|
||||
|
|
@ -162,14 +235,14 @@ $(function() {
|
|||
};
|
||||
|
||||
self.selectAll = function() {
|
||||
var container = $("#terminal-output");
|
||||
var container = self.fancyFunctionality() ? $("#terminal-output") : $("#terminal-output-lowfi");
|
||||
if (container.length) {
|
||||
container.selectText();
|
||||
}
|
||||
};
|
||||
|
||||
self.scrollToEnd = function() {
|
||||
var container = $("#terminal-output");
|
||||
var container = self.fancyFunctionality() ? $("#terminal-output") : $("#terminal-output-lowfi");
|
||||
if (container.length) {
|
||||
container.scrollTop(container[0].scrollHeight);
|
||||
}
|
||||
|
|
@ -236,12 +309,8 @@ $(function() {
|
|||
};
|
||||
|
||||
self.onAfterTabChange = function(current, previous) {
|
||||
if (current != "#term") {
|
||||
return;
|
||||
}
|
||||
if (self.autoscrollEnabled()) {
|
||||
self.scrollToEnd();
|
||||
}
|
||||
self.tabActive = current == "#term";
|
||||
self.updateOutput();
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -657,7 +657,7 @@ ul.dropdown-menu li a {
|
|||
|
||||
#term {
|
||||
.terminal {
|
||||
#terminal-output {
|
||||
#terminal-output, #terminal-output-lowfi {
|
||||
min-height: 340px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
<div class="terminal">
|
||||
<pre id="terminal-output" class="pre-scrollable" data-bind="foreach: displayedLines"><span data-bind="text: line, css: {muted: type == 'filtered' || type == 'cut'}"></span><br></pre>
|
||||
<small class="pull-left"><button class="btn btn-mini" data-bind="click: toggleAutoscroll, css: {active: autoscrollEnabled}">{{ _('Autoscroll') }}</button> <span data-bind="text: lineCount"></span></small>
|
||||
<small class="pull-right"><a href="#" data-bind="click: scrollToEnd">{{ _("Scroll to end") }}</a> | <a href="#" data-bind="click: selectAll">{{ _("Select all") }}</a></small>
|
||||
<pre id="terminal-output" class="pre-scrollable" data-bind="foreach: displayedLines, visible: fancyFunctionality()"><span data-bind="text: line, css: {muted: type == 'filtered' || type == 'cut'}"></span><br></pre>
|
||||
<pre id="terminal-output-lowfi" style="display: none" class="pre-scrollable" data-bind="text: plainLogOutput, visible: !fancyFunctionality()"></pre>
|
||||
<small class="pull-left" data-bind="visible: fancyFunctionality()"><button class="btn btn-mini" data-bind="click: toggleAutoscroll, css: {active: autoscrollEnabled}">{{ _('Autoscroll') }}</button> <span data-bind="text: lineCount, visible: enableFancyFunctionality"></span></small>
|
||||
<small class="pull-right" data-bind="visible: fancyFunctionality()"><a href="#" data-bind="click: scrollToEnd">{{ _("Scroll to end") }}</a> | <a href="#" data-bind="click: selectAll">{{ _("Select all") }}</a></small>
|
||||
<small class="pull-left muted" data-bind="visible: !fancyFunctionality()" style="display: none">{{ _('For performance reasons only a limited amount of terminal functionality is enabled right now.') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="row-fluid">
|
||||
<div class="span6" id="termin-filterpanel">
|
||||
<div data-bind="foreach: filters">
|
||||
<div class="span6" id="terminal-filterpanel">
|
||||
<div data-bind="foreach: filters, visible: fancyFunctionality()">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" data-bind="attr: { value: regex }, checked: $parent.activeFilters"> <span data-bind="text: name"></span>
|
||||
</label>
|
||||
|
|
@ -24,7 +26,14 @@
|
|||
<div>
|
||||
<div><small><a href="#" class="muted" data-bind="toggleContent: { class: 'icon-caret-right icon-caret-down', container: '#term .hide' }"><i class="icon-caret-right"></i> {{ _('Advanced options') }}</a></small></div>
|
||||
<div class="hide">
|
||||
<button class="btn btn-block" type="button" data-bind="click: fakeAck, enable: isOperational() && loginState.isUser()">{{ _("Fake Acknowledgement") }}</button>
|
||||
<small class="muted">{{ _("If acknowledgements (\"ok\"s) sent by the firmware get lost due to issues with the serial communication to your printer, OctoPrint's communication with it can become stuck. If that happens, this can help. Please be advised that such occurences hint at general communication issues with your printer which will probably negatively influence your printing results and which you should therefore try to resolve!") }}</small>
|
||||
<p class="row-fluid">
|
||||
<button class="btn btn-primary btn-block" type="button" data-bind="click: fakeAck, enable: isOperational() && loginState.isUser()">{{ _("Fake Acknowledgement") }}</button>
|
||||
<small class="muted">{{ _("If acknowledgements (\"ok\"s) sent by the firmware get lost due to issues with the serial communication to your printer, OctoPrint's communication with it can become stuck. If that happens, this can help. Please be advised that such occurences hint at general communication issues with your printer which will probably negatively influence your printing results and which you should therefore try to resolve!") }}</small>
|
||||
</p>
|
||||
<p class="row-fluid">
|
||||
<button class="btn btn-danger span6" type="button" data-bind="toggle: forceFancyFunctionality">{{ _('Force fancy functionality') }}</button>
|
||||
<button class="btn btn-danger span6" type="button" data-bind="toggle: forceTerminalLogDuringPrinting">{{ _('Force terminal output during printing') }}</button>
|
||||
<small class="muted">{{ _("Some functionality of the terminal will be disabled if OctoPrint detects that your browser is too slow for that. You may force it back on here, but be aware that this might make your browser unresponsive.") }}</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue