Skip to content
GitLab
Projects
Groups
Snippets
Help
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Q
QMLBench Regression Finder
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Service Desk
Milestones
Packages & Registries
Packages & Registries
Container Registry
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
Daniel Smith
QMLBench Regression Finder
Commits
4e463b12
Commit
4e463b12
authored
Jul 30, 2020
by
Daniel Smith
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Bugfixes and documentation of REST API schema
parent
6fc69131
Changes
7
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
240 additions
and
55 deletions
+240
-55
REST_API.md
REST_API.md
+150
-0
jobNotificationHandler.js
jobNotificationHandler.js
+0
-2
server.js
server.js
+70
-47
testHandler.js
testHandler.js
+6
-3
thrift/__pycache__/__init__.cpython-36.pyc
thrift/__pycache__/__init__.cpython-36.pyc
+0
-0
toolbox.js
toolbox.js
+14
-2
web_index/job.mustache
web_index/job.mustache
+0
-1
No files found.
REST_API.md
0 → 100644
View file @
4e463b12
# REST API
This tool provides a JSON REST API inteface which can be used to schedule new jobs
or query for current jobs. All requests should be directed to the host acting
as the scheduler unless otherwise specified.
***Important**
*
: This REST API presently performs no validation on incoming requests. Malformed requests
may produce undefined behavior.
#
## Endpoints
### **POST** /remoteSchedule
Schedule a new job.
Note optional and conditionally required fields. Conditionally required fields
are marked with the testType they are required for.
#### Request:
```
{
testType: <str>[ bisect | singleCommit | twoCommit ],
owner: (optional) <str>, // If set as an email address, the system will notify about jobs.
title: (optional) <str>, // If not set, the subject of the first sha to test is used.
status: <str>[ staged ],
branch: <str>[ any qt X.XX branch ],
module: <str>[ qtbase | qtdeclarative | qtquickcontrols | qtquickcontrols2 | qtgraphicaleffects ],
commit: (singleCommit) <str> [ any sha1 in codereview ],
patches: (optional) <str>[ comma separated list of sha1s or patchrefs from codereview ],
firstCommitBuildOnHead: (singleCommit | twoCommit) <bool>,
secondCommitBuildOnHead: (twoCommit) <bool>,
firstCommit: (twoCommit) <str> [any sha from codereview],
secondCommit: (twoCommit) <str> [any sha from codereview],
bad_commit: (bisect) <str> [any sha from codereview],
good_commit: (bisect) <str> [any sha from codereview],
openGLBackend: (win32 platforms) <str> [ desktop | Angle | software ], // ignored on non-win32 platforms
test_name: (exclusive with custom_benchmark_file) <str> [ benchmark path from qmlbench repo ],
custom_benchmark_file: (exclusive with test_name) <Buffer> [ custom qml file as binary data ],
expected_regression: (bisect) <int> [ > 5 ], // percent
test_hosts: <array<str>> | <str> [ hostname(s) of agents which should run the test ],
environment: <array<str>> | <str> [ 'key=value' pair(s) of environment variables to apply to agents ]
}
```
#### Example:
```
POST 127.0.0.1:8080/remoteSchedule
{
testType: 'singleCommit',
branch: 'dev',
module: 'qtquickcontrols2',
openGLBackend: 'desktop',
commit: '49ffc6e6af83b295c67fd119b79c925879cc292e',
test_name: 'auto/creation/quick.controls2/delegates_combobox.qml',
test_hosts: 'myTestHost1',
patches: '39d99c714bb87bd39e92d85fb1f46c52eb9f8d33,5bdf48f6c9e125c2528e3af16eda257ab0fef9c0'
}
```
#### Response:
```
200: OK```
#
### **GET** /currentJobs
Get all current running and queued jobs. Request does not accept parameters or a request body.
#### Response:
```
200: OK
{
"localJobs": {
"runningJob": {},
"queuedJobs": []
},
"remoteRunningJobs": [],
"remoteQueuedJobs": []
}
```
#### Job schema:
Note: Not all fields may be populated.
```
{
jobHash:
<str>
SHA256
platform:
<str>
[ win32 | linux | macOS ]
startTime:
<int>
milliseconds // Time the job actually started on the agent
expectedDuration:
<int>
milliseconds
timestamp:
<int>
// Time the job was scheduled.
testType:
<str>
[ bisect | singleCommit | twoCommit ]
owner:
<str>
title:
<str>
status:
<str>
[ staged | running | cancelling | cancelled | finished ]
status_msg:
<str>
branch:
<str>
module:
<str>
[ qtbase | qtdeclarative | qtquickcontrols | qtquickcontrols2 | qtgraphicaleffects ]
commit:
<str>
40-char sha1
patches:
<array
<
str
>
> |
<str>
firstCommitBuildOnHead:
<bool>
secondCommitBuildOnHead:
<bool>
firstCommit:
<str>
40-char sha1
secondCommit:
<str>
40-char sha1
bad_commit:
<str>
40-char sha1
good_commit:
<str>
40-char sha1
openGLBackend:
<str>
[ desktop | Angle | software ]
test_name:
<str>
custom_benchmark_file:
<buffer>
expected_regression:
<int>
test_hosts:
<array
<
str
>
> |
<str>
environment:
<array
<
str
>
> |
<str>
[ 'key=value' pair(s)]
remote_job:
<bool>
master_host:
<str>
IP Address
}
```
#
### **GET** /cancelJob
Cancel a currently running or queued job.
#### Request:
```
URL Parameters:
jobHash: [SHA256 job hash to cancel]
```
#### Example:
```
GET /cancelJob?jobHash=deb167217eff98d8cad0a7baca73a8565324f3f9cd332faebbccc12eb30afb11
```
#### Response:
```
200: OK
```
#
### **GET** /status
Get the basic operational status of an agent. This request may be directed to agents other than
the scheduler.
#### Response:
```
200: OK
{
"freediskspace":
<int>
bytes free
"uptime":
<float>
seconds
"hostname":
<str>
hostname
"ip":
<str>
IPV4 address
}
```
jobNotificationHandler.js
View file @
4e463b12
...
...
@@ -226,7 +226,6 @@ class jobNotificationHandler {
firstCommit
:
req
.
body
.
firstCommit
,
secondCommit
:
req
.
body
.
secondCommit
,
bad_commit
:
req
.
body
.
bad_commit
,
good_date
:
req
.
body
.
good_date
,
good_commit
:
req
.
body
.
good_commit
,
openGLBackend
:
req
.
body
.
openGLBackend
,
test_name
:
req
.
body
.
test_name
,
...
...
@@ -237,7 +236,6 @@ class jobNotificationHandler {
?
req
.
files
.
custom_benchmark_file
:
req
.
body
.
custom_benchmark_file
,
expected_regression
:
req
.
body
.
expected_regression
,
test_all
:
req
.
body
.
test_all
,
// Only tell the agent which will execute the job about itself.
test_hosts
:
Array
.
isArray
(
req
.
body
.
test_hosts
)
?
[
req
.
body
.
test_hosts
[
i
]]
...
...
server.js
View file @
4e463b12
/* eslint-disable max-len */
"
use strict
"
;
const
{
spawn
}
=
require
(
"
child_process
"
);
const
express
=
require
(
"
express
"
);
const
fileUpload
=
require
(
"
express-fileupload
"
);
const
events
=
require
(
"
events
"
);
...
...
@@ -17,7 +16,6 @@ const axios = require("axios");
const
prettyBytes
=
require
(
"
pretty-bytes
"
);
const
fs
=
require
(
"
fs
"
);
const
serveIndex
=
require
(
"
serve-index
"
);
const
{
scheduleJob
}
=
require
(
"
node-schedule
"
);
const
fetch
=
require
(
"
node-fetch
"
);
...
...
@@ -49,7 +47,8 @@ let globals ={
webPort
:
envOrConfig
(
"
HTTP_PORT
"
),
baseURL
:
envOrConfig
(
"
BASE_URL
"
),
agents
:
envOrConfig
(
"
agents
"
),
expectedDurations
:
envOrConfig
(
"
expectedDurations
"
)
expectedDurations
:
envOrConfig
(
"
expectedDurations
"
),
refreshRepos
:
false
}
...
...
@@ -80,38 +79,21 @@ if (globals.standalone && globals.nolocal) {
console
.
log
(
"
Starting in standalone mode. Ignoring all agent hosts.
"
);
globals
.
isPrimaryHost
=
true
;
}
else
if
(
globals
.
nolocal
)
{
parseAgentsFromArgs
();
if
(
!
globals
.
agents
)
{
// verify we have at least one agent worker
console
.
log
(
"
ERROR: Use of 'nolocal' parameter requires addition of at least one agent
, and cannot be combined with 'standalone' mode
"
);
console
.
log
(
"
ERROR: Use of 'nolocal' parameter requires addition of at least one agent
.
"
);
process
.
exit
(
1
);
}
console
.
log
(
"
Starting in nolocal mode. Will not offer self as a agent host.
"
);
globals
.
isPrimaryHost
=
true
;
console
.
log
(
"
Starting in nolocal mode.
"
);
}
else
if
(
process
.
argv
.
some
((
e
)
=>
e
===
"
agent
"
))
{
// Override and empty any config of agent lists if this is not the scheduler.
console
.
log
(
"
Starting in agent mode.
"
)
globals
.
isPrimaryHost
=
false
;
globals
.
agents
=
[];
}
else
if
(
process
.
argv
.
some
((
e
)
=>
e
===
"
refreshrepos
"
))
{
// Refresh test names and exit.
console
.
log
(
"
Refreshing qmlbench test list. Process will exit after completion!
"
)
gatherTestNames
(
true
,
function
()
{
process
.
exit
(
0
);
});
}
else
if
(
process
.
argv
[
2
]
!=
undefined
)
{
// Assume all our arguments are agent worker IPs.
globals
.
isPrimaryHost
=
true
;
process
.
argv
.
forEach
((
val
,
index
)
=>
{
if
(
index
>
1
)
{
if
(
globals
.
agents
.
all
((
e
)
=>
e
!=
val
))
{
globals
.
agents
.
push
(
val
);
console
.
log
(
`Got additional agent
${
index
-
1
}
:
${
val
}
`
);
}
parseAgentsFromArgs
();
}
});
}
console
.
log
(
`I am
${
globals
.
isPrimaryHost
?
""
:
"
not
"
}
a primary host, and I
${
globals
.
nolocal
?
"
will not
"
:
"
will
"
}
run local jobs.`
);
if
(
!
globals
.
nolocal
)
globals
.
agents
.
push
(
ip
.
address
());
// List self as a agent
...
...
@@ -119,6 +101,20 @@ if (globals.standalone && globals.nolocal) {
if
(
!
globals
.
agents
)
{
console
.
error
(
"
Error: No agents! Exiting...
"
);
process
.
exit
(
1
);
}
else
{
console
.
log
(
`Have agents:
${
globals
.
agents
}
`
);
}
if
(
globals
.
agents
)
globals
.
isPrimaryHost
=
true
;
console
.
log
(
`I am
${
globals
.
isPrimaryHost
?
""
:
"
not
"
}
a primary host, and I
${
globals
.
nolocal
?
"
will not
"
:
"
will
"
}
run local jobs.`
);
if
(
process
.
argv
.
some
((
e
)
=>
e
===
"
refreshrepos
"
))
{
// Refresh test names and exit.
console
.
log
(
"
Refreshing qmlbench test list.
"
);
globals
.
refreshRepos
=
true
;
}
}
...
...
@@ -129,6 +125,19 @@ const testHandler = new TestHandler(notifyJob, globals);
const
notifyHandler
=
new
NotifyHandler
(
notifyJob
,
testHandler
,
globals
);
// Parse command line args for valid IPs.
function
parseAgentsFromArgs
()
{
process
.
argv
.
forEach
((
val
,
index
)
=>
{
if
(
(
ip
.
isV4Format
(
val
)
||
ip
.
isV6Format
(
val
))
&&
globals
.
agents
.
every
((
e
)
=>
e
!=
val
)
)
{
globals
.
agents
.
push
(
val
);
console
.
log
(
`Got additional agent
${
index
-
1
}
:
${
val
}
`
);
}
});
}
function
clearErrors
(
req
,
res
)
{
// Clear system errors on the web interface when running in scheduler mode.
globals
.
recentErrors
.
length
=
0
;
...
...
@@ -174,20 +183,28 @@ function queryJobs(req, res) {
firstCommit
:
globals
.
runningJob
.
firstCommit
,
secondCommit
:
globals
.
runningJob
.
secondCommit
,
bad_commit
:
globals
.
runningJob
.
bad_commit
,
good_date
:
globals
.
runningJob
.
good_date
,
good_commit
:
globals
.
runningJob
.
good_commit
,
openGLBackend
:
req
.
body
.
openGLBackend
,
openGLBackend
:
globals
.
runningJob
.
openGLBackend
,
test_name
:
globals
.
runningJob
.
test_name
,
custom_benchmark_file
:
globals
.
runningJob
.
custom_benchmark_file
,
expected_regression
:
globals
.
runningJob
.
expected_regression
,
test_all
:
globals
.
runningJob
.
test_all
,
test_hosts
:
globals
.
runningJob
.
test_hosts
,
environment
:
globals
.
runningJob
.
environment
,
remote_job
:
globals
.
runningJob
.
remote_job
,
master_host
:
globals
.
runningJob
.
master_host
};
if
(
res
)
res
.
send
({
runningJob
:
tempRunningJob
,
queuedJobs
:
globals
.
jobQueue
});
else
return
{
runningJob
:
tempRunningJob
,
queuedJobs
:
globals
.
jobQueue
};
}
function
serveJobsJSON
(
req
,
res
)
{
res
.
send
({
localJobs
:
queryJobs
(),
remoteRunningJobs
:
globals
.
remoteRunningJobs
,
remoteQueuedJobs
:
globals
.
remoteQueuedJobs
})
}
// Dump the scheduler's local cache of currently running and queued remote
...
...
@@ -253,8 +270,10 @@ function cancelJob(req, res) {
);
}
else
{
// Job must be remote. Forward the cancel request to the agent.
let
jobFound
=
false
;
[
global
.
remoteRunningJobs
,
global
.
remoteQueuedJobs
].
forEach
((
targetArray
)
=>
{
let
targetIndex
=
toolbox
.
getIndex
(
targetArray
,
req
.
query
.
jobHash
);
let
targetIndex
=
-
1
;
toolbox
.
getIndex
(
targetArray
,
req
.
query
.
jobHash
);
if
(
targetIndex
==
-
1
)
{
return
;
}
else
{
...
...
@@ -276,6 +295,8 @@ function cancelJob(req, res) {
});
}
});
// End of foreach
if
(
!
jobFound
)
res
.
status
(
400
).
send
(
`Not found on any agent:
${
req
.
query
.
jobHash
}
`
)
}
// End of local/remote if
}
...
...
@@ -477,6 +498,8 @@ function serveHomePage(req, res) {
</tr>`
;
readyEmitter
.
emit
(
"
agentFinished
"
);
}
}
else
{
res
.
send
(
"
This agent is not running a scheduler.
"
);
}
}
...
...
@@ -586,18 +609,19 @@ function runServer() {
serveIndex
(
`
${
__dirname
}
/QtBuild/qmlbench/benchmarks/`
,
{
view
:
"
details
"
})
);
server
.
all
(
"
/remoteSchedule
"
,
(
req
,
res
)
=>
notifyHandler
.
scheduleJobRequest
(
req
,
res
));
server
.
all
(
"
/scheduleJob
"
,
(
req
,
res
)
=>
serveSchedulerPage
(
req
,
res
));
server
.
all
(
"
/queryJobs
"
,
(
req
,
res
)
=>
queryJobs
(
req
,
res
));
server
.
all
(
"
/refreshRemoteJobs
"
,
(
req
,
res
)
=>
queryAgentJobs
(
req
,
res
));
server
.
all
(
"
/pushJobUpdate
"
,
(
req
,
res
)
=>
notifyHandler
.
addUpdateToQueue
(
req
,
res
));
server
.
all
(
"
/updateLocalRunningJobStatus
"
,
(
req
,
res
)
=>
notifyHandler
.
updateRunningJobStatus
(
req
,
res
));
server
.
all
(
"
/cancelJob
"
,
(
req
,
res
)
=>
cancelJob
(
req
,
res
));
server
.
all
(
"
/recentErrors
"
,
(
req
,
res
)
=>
errorsPage
(
req
,
res
));
server
.
all
(
"
/clearRecentErrors
"
,
(
req
,
res
)
=>
clearErrors
(
req
,
res
));
server
.
all
(
"
/clearFinishedJobs
"
,
(
req
,
res
)
=>
clearFinishedJobs
(
req
,
res
));
server
.
all
(
"
/status
"
,
(
req
,
res
)
=>
serveStatus
(
req
,
res
));
server
.
all
(
"
/
"
,
(
req
,
res
)
=>
serveHomePage
(
req
,
res
));
server
.
post
(
"
/remoteSchedule
"
,
(
req
,
res
)
=>
notifyHandler
.
scheduleJobRequest
(
req
,
res
));
server
.
get
(
"
/scheduleJob
"
,
(
req
,
res
)
=>
serveSchedulerPage
(
req
,
res
));
server
.
get
(
"
/queryJobs
"
,
(
req
,
res
)
=>
queryJobs
(
req
,
res
));
server
.
get
(
"
/refreshRemoteJobs
"
,
(
req
,
res
)
=>
queryAgentJobs
(
req
,
res
));
server
.
get
(
"
/currentJobs
"
,
(
req
,
res
)
=>
serveJobsJSON
(
req
,
res
));
server
.
post
(
"
/pushJobUpdate
"
,
(
req
,
res
)
=>
notifyHandler
.
addUpdateToQueue
(
req
,
res
));
server
.
post
(
"
/updateLocalRunningJobStatus
"
,
(
req
,
res
)
=>
notifyHandler
.
updateRunningJobStatus
(
req
,
res
));
server
.
get
(
"
/cancelJob
"
,
(
req
,
res
)
=>
cancelJob
(
req
,
res
));
server
.
get
(
"
/recentErrors
"
,
(
req
,
res
)
=>
errorsPage
(
req
,
res
));
server
.
get
(
"
/clearRecentErrors
"
,
(
req
,
res
)
=>
clearErrors
(
req
,
res
));
server
.
get
(
"
/clearFinishedJobs
"
,
(
req
,
res
)
=>
clearFinishedJobs
(
req
,
res
));
server
.
get
(
"
/status
"
,
(
req
,
res
)
=>
serveStatus
(
req
,
res
));
server
.
get
(
"
/
"
,
(
req
,
res
)
=>
serveHomePage
(
req
,
res
));
console
.
log
(
`Starting server... Listening on port
${
globals
.
webPort
}
`
);
...
...
@@ -607,7 +631,7 @@ function runServer() {
// Gather test names for partial match suggestions on the scheduler page. If QMLBench isn't already initialized, do it now and block the scheduler.
function
gatherTestNames
(
forceUpdate
,
callback
)
{
if
(
!
fs
.
existsSync
(
pathmodule
.
join
(
__dirname
,
"
QtBuild
"
,
"
qmlbench
"
))
||
forceUpdate
)
{
if
(
forceUpdate
||
!
fs
.
existsSync
(
pathmodule
.
join
(
__dirname
,
"
QtBuild
"
,
"
qmlbench
"
))
)
{
globals
.
locked
=
true
;
if
(
forceUpdate
)
console
.
log
(
"
Updating local repositories and pulling new qmlbench tests.
"
);
...
...
@@ -650,7 +674,7 @@ function queryAgents(callback) {
for
(
let
agent
in
globals
.
agents
)
{
axios
.
get
(
`http://
${
globals
.
agents
[
agent
]}
:
${
globals
.
webPort
}
/status`
,
{
timeout
:
2000
})
// Query everyone else
.
get
(
`http://
${
globals
.
agents
[
agent
]}
:
${
globals
.
webPort
}
/status`
,
{
timeout
:
1000
})
.
then
((
response
)
=>
{
if
(
!
globals
.
hosts
.
some
((
o
)
=>
o
.
ip
==
response
.
data
.
ip
))
{
console
.
log
(
`Adding
${
response
.
data
.
hostname
}
to the agent list`
);
...
...
@@ -675,6 +699,7 @@ function queryAgents(callback) {
// listening so the data is already there, if the remote hosts are online first.
function
startup
()
{
toolbox
.
createLocalDirs
(
globals
.
myname
);
gatherTestNames
(
globals
.
refreshRepos
,
(
tests
)
=>
globals
.
testNames
=
tests
);
if
(
globals
.
agents
)
{
console
.
log
(
"
Querying agent IPs for hostname info...
"
);
queryAgents
(
function
(
finished
)
{
...
...
@@ -689,8 +714,6 @@ function startup() {
console
.
error
(
"
Cannot continue with 0 agents. Please verify your configuration.
"
);
process
.
exit
(
1
);
}
gatherTestNames
(
undefined
,
(
tests
)
=>
globals
.
testNames
=
tests
);
}
// Call pre-flight checks, which calls the server-startup, which runs the scheduler and web interface
...
...
testHandler.js
View file @
4e463b12
...
...
@@ -20,7 +20,7 @@ class testHandler {
this
.
notifyJob
.
on
(
"
jobDone
"
,
this
.
jobDone
.
bind
(
this
));
}
runTest
(
test
,
callback
)
{
runTest
(
test
,
callback
,
callbackArgs
)
{
let
_this
=
this
;
// Run the given test on this host
console
.
log
(
"
Now executing test with params:
"
);
...
...
@@ -174,8 +174,11 @@ class testHandler {
}
// Provide a callback option to do custom processing on job completion.
if
(
callback
)
callback
();
if
(
callback
)
{
typeof
(
callbackArgs
)
===
Array
?
callback
(...
callbackArgs
)
:
callback
(
callbackArgs
)
}
// Emit the JobDone signal to do some more tasks and prep for the next job in queue.
_this
.
notifyJob
.
emit
(
"
jobDone
"
);
...
...
thrift/__pycache__/__init__.cpython-36.pyc
deleted
100644 → 0
View file @
6fc69131
File deleted
toolbox.js
View file @
4e463b12
...
...
@@ -30,7 +30,13 @@ let smtpPort = envOrConfig(Number("SMTP_PORT"));
exports
.
getIndex
=
getIndex
;
function
getIndex
(
targetArray
,
jobHash
)
{
return
targetArray
[
targetArray
.
findIndex
((
j
)
=>
j
.
jobHash
==
jobHash
)]
let
index
=
-
1
;
try
{
targetArray
[
targetArray
.
findIndex
((
j
)
=>
j
.
jobHash
==
jobHash
)]
}
catch
(
exception
)
{
return
index
;
}
return
index
;
}
exports
.
addJob
=
addJobToArray
;
...
...
@@ -47,7 +53,7 @@ function addJobRecursive(viewElement, joblist, index, hosts, callback) {
viewElement
+=
job
;
index
+=
1
;
if
(
index
<
joblist
.
length
)
addJobRecursive
(
viewElement
,
joblist
,
index
,
callback
);
addJobRecursive
(
viewElement
,
joblist
,
index
,
hosts
,
callback
);
else
callback
(
viewElement
);
});
...
...
@@ -123,6 +129,7 @@ function jobFormatter(job, hosts, callback) {
<br>
${
bisectResult
}
`
;
}
view
.
resultString
+=
"
<br>
"
}
if
(
job
.
test_name
)
...
...
@@ -152,7 +159,12 @@ function jobFormatter(job, hosts, callback) {
view
.
timeEstimate
=
`Expected to run for about
${
moment
.
duration
(
job
.
expectedDuration
,
"
ms
"
)
.
format
(
"
h [hrs], m [min]
"
)}
`
;
}
else
if
(
job
.
actualDuration
)
{
view
.
timeEstimate
=
`Ran for
${
moment
.
duration
(
job
.
actualDuration
,
"
ms
"
)
.
format
(
"
h [hrs], m [min]
"
)}
`
}
view
.
timeEstimate
+=
"
<br>
"
if
(
job
.
status
!=
"
staged
"
)
{
view
.
logButton
=
`<button onclick="window.location.href='
${
...
...
web_index/job.mustache
View file @
4e463b12
...
...
@@ -13,7 +13,6 @@ Last status message: <font color="#454545">{{statusMsg}}</font>
{{&
testString
}}
{{&
timeEstimate
}}
{{&
resultString
}}
<br>
{{&
logButton
}}
{{&
cancelButton
}}
</li>
<br>
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment