to each participant in the end (answers, computing grade).
Once a series of exam is over, the teacher can get all grades in CSV format from course page.
-*Note:* for now the monitoring + socket part is still unimplemented,
-and exams composition is limited to single question exercises.
+*Note:* for now exams composition is limited to single question exercises.
Automatic grades are also not available.
## Installation
-permettre temps par question : gestion côté serveur, y réfléchir...
-auto grading + finish erdiag + corr tp1
+Replace underscore by lodash (or better: ES6)
+Replace socket.io by Websockets ( https://www.npmjs.com/package/websocket )
+
+time per question (in mode "one question at a time" from server...)
+compute grades after exam (in teacher's view)
+factorize redundant code in course.js, monitor.js and (TOWRITE) grade.js
+ (showing students list + grades or papers)
+
-----
TODO: format général TXT: (compilé en JSON)
// Error handler
app.use(function(err, req, res, next) {
- // Set locals, only providing error in development
- res.locals.message = err.message;
+ // Set locals, only providing error in development (TODO: difference req.app and app ?!)
+ res.locals.message = err.message;
+ if (app.get('env') === 'development')
+ console.log(err);
res.locals.error = req.app.get('env') === 'development' ? err : {};
res.status(err.status || 500);
res.render('error');
// Lifespan of a (login) cookie
Parameters.cookieExpire = 183*24*3600*1000; //6 months in milliseconds
+// Secret string used in monitoring page + review
+Parameters.secret = "ImNotSoSecretChangeMe";
+
// Characters in a login token, and period of validity (in milliseconds)
Parameters.token = {
length: 16,
});
},
+ checkPassword: function(aid, number, password, cb)
+ {
+ AssessmentEntity.getById(aid, (err,assessment) => {
+ if (!!err || !assessment)
+ return cb(err, assessment);
+ const paperIdx = assessment.papers.findIndex( item => { return item.number == number; });
+ if (paperIdx === -1)
+ return cb({errmsg: "Paper not found"}, false);
+ cb(null, assessment.papers[paperIdx].password == password);
+ });
+ },
+
add: function(uid, cid, name, cb)
{
// 1) Check that course is owned by user of ID uid
update: function(uid, assessment, cb)
{
- const qid = ObjectId(assessment._id);
+ const aid = ObjectId(assessment._id);
// 1) Check that assessment is owned by user of ID uid
- AssessmentEntity.getById(qid, (err,assessmentOld) => {
+ AssessmentEntity.getById(aid, (err,assessmentOld) => {
if (!!err || !assessmentOld)
return cb({errmsg: "Assessment retrieval failure"});
CourseEntity.getById(ObjectId(assessmentOld.cid), (err2,course) => {
// 2) Replace assessment
delete assessment["_id"];
assessment.cid = ObjectId(assessment.cid);
- AssessmentEntity.replace(qid, assessment, cb);
+ AssessmentEntity.replace(aid, assessment, cb);
});
});
},
AssessmentEntity.getPaperByNumber(aid, number, (err,paper) => {
if (!!err)
return cb(err,null);
+ if (!paper && !!password)
+ return cb({errmsg: "Cannot start a new exam before finishing current"},null);
if (!!paper)
{
if (!password)
- return cb({errmsg:"Missing password"});
+ return cb({errmsg: "Missing password"});
if (paper.password != password)
- return cb({errmsg:"Wrong password"});
+ return cb({errmsg: "Wrong password"});
}
AssessmentEntity.getQuestions(aid, (err,questions) => {
if (!!err)
return window.innerWidth >= screen.width-3 && window.innerHeight >= screen.height-3;
};
-new Vue({
+let V = new Vue({
el: "#assessment",
data: {
assessment: assessment,
},
methods: {
// In case of AJAX errors
- warning: function(message) {
+ showWarning: function(message) {
this.warnMsg = message;
$("#warning").modal("open");
},
},
trySendCurrentAnswer: function() {
if (this.stage == 2)
- this.sendAnswer(assessment.indices[assessment.index]);
+ this.sendAnswer();
},
// stage 0 --> 1
getStudent: function(cb) {
dataType: "json",
success: s => {
if (!!s.errmsg)
- return this.warning(s.errmsg);
+ return this.showWarning(s.errmsg);
this.stage = 1;
this.student = s.student;
Vue.nextTick( () => { Materialize.updateTextFields(); });
assessment.questions = questions;
this.answers.inputs = [ ];
for (let q of assessment.questions)
- this.inputs.push( _(q.options.length).times( _.constant(false) ) );
+ this.answers.inputs.push( _(q.options.length).times( _.constant(false) ) );
if (!paper)
{
this.answers.indices = assessment.fixed
this.answers.indices = indices.concat( _.shuffle(remainingIndices) );
}
this.answers.index = !!paper ? paper.inputs.length : 0;
- Vue.nextTick(libsRefresh);
+ Vue.nextTick(statementsLibsRefresh);
this.stage = 2;
};
if (assessment.mode == "open")
dataType: "json",
success: s => {
if (!!s.errmsg)
- return this.warning(s.errmsg);
+ return this.showWarning(s.errmsg);
if (!!s.paper)
{
// Resuming: receive stored answers + startTime
// action (power failure, computer down, ...)
}
socket = io.connect("/" + assessment.name, {
- query: "number=" + this.student.number + "&password=" + this.password
+ query: "aid=" + assessment._id + "&number=" + this.student.number + "&password=" + this.student.password
});
socket.on(message.allAnswers, this.setAnswers);
initializeStage2(s.questions, s.paper);
let self = this;
setInterval( function() {
self.remainingTime--;
- if (self.remainingTime <= 0 || self.stage >= 4)
- self.endAssessment();
+ if (self.remainingTime <= 0)
+ {
+ if (self.stage == 2)
+ self.endAssessment();
clearInterval(this);
+ }
}, 1000);
},
// stage 2
- // TODO: currentIndex ? click: () => this.sendAnswer(assessment.indices[assessment.index]),
- // De même, cette condition sur le display d'une question doit remonter (résumée dans 'index' property) :
- // à faire par ici : "hide": this.stage == 2 && assessment.display == 'one' && assessment.indices[assessment.index] != i,
- sendAnswer: function(realIndex) {
+ sendAnswer: function() {
+ const realIndex = this.answers.indices[this.answers.index];
let gotoNext = () => {
- if (assessment.index == assessment.questions.length - 1)
+ if (this.answers.index == assessment.questions.length - 1)
this.endAssessment();
else
- assessment.index++;
- this.$forceUpdate(); //TODO: shouldn't be required
+ this.answers.index++;
+ this.$children[0].$forceUpdate(); //TODO: bad HACK, and shouldn't be required...
};
if (assessment.mode == "open")
return gotoNext(); //only local
let answerData = {
aid: assessment._id,
answer: JSON.stringify({
- index:realIndex.toString(),
- input:this.inputs[realIndex]
+ index: realIndex.toString(),
+ input: this.answers.inputs[realIndex]
.map( (tf,i) => { return {val:tf,idx:i}; } )
.filter( item => { return item.val; })
.map( item => { return item.idx; })
dataType: "json",
success: ret => {
if (!!ret.errmsg)
- return this.$emit("warning", ret.errmsg);
- else
- gotoNext();
+ return this.showWarning(ret.errmsg);
+ gotoNext();
socket.emit(message.newAnswer, answerData);
},
});
dataType: "json",
success: ret => {
if (!!ret.errmsg)
- return this.warning(ret.errmsg);
+ return this.showWarning(ret.errmsg);
assessment.conclusion = ret.conclusion;
this.stage = 3;
delete this.student["password"]; //unable to send new answers now
});
},
// stage 3 --> 4 (on socket message "feedback")
- setAnswers: function(answers) {
- for (let i=0; i<answers.length; i++)
- assessment.questions[i].answer = answers[i];
+ setAnswers: function(m) {
+ for (let i=0; i<m.answers.length; i++)
+ assessment.questions[i].answer = m.answers[i];
this.stage = 4;
},
},
// Full questions tree is rendered, but some parts hidden depending on display settings
render(h) {
// TODO: render nothing if answers is empty
- let domTree = this.questions.map( (q,i) => {
+ let domTree = (this.questions || [ ]).map( (q,i) => {
let questionContent = [ ];
questionContent.push(
h(
"class": {
option: true,
choiceCorrect: this.answers.showSolution && this.questions[i].answer.includes(idx),
- choiceWrong: this.answers.showSolution && this.inputs[i][idx] && !q.answer.includes(idx),
+ choiceWrong: this.answers.showSolution && this.answers.inputs[i][idx] && !q.answer.includes(idx),
},
},
option
{
"class": {
"question": true,
- "hide": !this.answers.displayAll && this.answers.index != i,
+ "hide": !this.answers.displayAll && this.answers.indices[this.answers.index] != i,
},
},
questionContent
id: "statements",
},
},
- questions
+ domTree
);
},
updated: function() {
return ""; //no grade yet
return this.grades[assessmentIndex][studentNumber];
},
- groupId: function(group, hash) {
- return (!!hash?"#":"") + "group" + group;
+ groupId: function(group, prefix) {
+ return (prefix || "") + "group" + group;
},
togglePresence: function(number, index) {
// UNIMPLEMENTED
--- /dev/null
+//TODO: similar to monitor / course (need to factor some code)
-// TODO: onglets pour chaque groupe + section déroulante questionnaire (chargé avec réponses)
-// NOM Prenom (par grp, puis alphabétique)
-// réponse : vert si OK (+ choix), rouge si faux, gris si texte (clic pour voir)
-// + temps total ?
-// click sur en-tête de colonne : tri alphabétique, tri décroissant...
-// Affiché si (hash du) mdp du cours est correctement entré
-// Doit reprendre les données en base si refresh (sinon : sockets)
-
let socket = null; //monitor answers in real time
new Vue({
el: "#monitor",
data: {
password: "", //from password field
- assessment: null, //obtained after authentication
+ assessment: { }, //obtained after authentication
// Stage 0: unauthenticated (password),
// 1: authenticated (password hash validated), start monitoring
stage: 0,
inputs: [ ],
index : -1,
},
+ students: [ ], //to know their names
+ display: "assessment", //or student's answers
},
methods: {
+ // TODO: redundant code, next 4 funcs already exist in course.js
+ toggleDisplay: function(area) {
+ if (this.display == area)
+ this.display = "";
+ else
+ this.display = area;
+ },
+ studentList: function(group) {
+ return this.students
+ .filter( s => { return group==0 || s.group == group; })
+ .map( s => { return Object.assign({}, s); }) //not altering initial array
+ .sort( (a,b) => {
+ let res = a.name.localeCompare(b.name);
+ if (res == 0)
+ res += a.forename.localeCompare(b.forename);
+ return res;
+ });
+ },
+ groupList: function() {
+ let maxGrp = 1;
+ this.students.forEach( s => {
+ if (s.group > maxGrp)
+ maxGrp = s.group;
+ });
+ return _.range(1,maxGrp+1);
+ },
+ groupId: function(group, prefix) {
+ return (prefix || "") + "group" + group;
+ },
+ getColor: function(number, qIdx) {
+ // For the moment, green if correct and red if wrong; grey if unanswered yet
+ // TODO: in-between color for partially right (especially for multi-questions)
+ const paperIdx = this.assessment.papers.findIndex( item => { return item.number == number; });
+ if (paperIdx === -1)
+ return "grey"; //student didn't start yet
+ const inputIdx = this.assessment.papers[paperIdx].inputs.findIndex( item => {
+ const qNum = parseInt(item.index.split(".")[0]); //indexes separated by dots
+ return qIdx == qNum;
+ });
+ if (inputIdx === -1)
+ return "grey";
+ if (_.isEqual(this.assessment.papers[paperIdx].inputs[inputIdx].input, this.assessment.questions[qIdx].answer))
+ return "green";
+ return "red";
+ },
+ seeDetails: function(number, i) {
+ // UNIMPLEMENTED: see question details, with current answer(s)
+ },
// stage 0 --> 1
startMonitoring: function() {
$.ajax("/start/monitoring", {
method: "GET",
data: {
- password: this.password,
+ password: Sha1.Compute(this.password),
aname: examName,
- cname: courseName,
+ ccode: courseCode,
initials: initials,
},
dataType: "json",
success: s => {
if (!!s.errmsg)
- return this.warning(s.errmsg);
- this.assessment = JSON.parse(s.assessment);
+ return alert(s.errmsg);
+ this.assessment = s.assessment;
+ this.answers.inputs = s.assessment.questions.map( q => { return q.answer; });
+ this.students = s.students;
this.stage = 1;
socket = io.connect("/", {
query: "aid=" + this.assessment._id + "&secret=" + s.secret
let paperIdx = this.assessment.papers.findIndex( item => {
return item.number == m.number;
});
- this.assessment.papers[paperIdx].inputs.push(m.input); //answer+index
+ if (paperIdx === -1)
+ {
+ // First answer
+ paperIdx = this.assessment.papers.length;
+ this.assessment.papers.push({
+ number: m.number,
+ inputs: [ ], //other fields irrelevant here
+ });
+ }
+ // TODO: notations not coherent (input / answer... when, which ?)
+ this.assessment.papers[paperIdx].inputs.push(m.answer); //input+index
});
},
});
},
+ endMonitoring: function() {
+ // In the end, send answers to students
+ socket.emit(
+ message.allAnswers,
+ { answers: this.assessment.questions.map( q => { return q.answer; }) }
+ );
+ },
},
});
.timer {
font-size: 2rem;
}
+
+table.in-question {
+ border: 1px solid black;
+ width: auto;
+ margin: 10px auto;
+}
+
+table.in-question th, table.in-question td {
+ padding: 3px;
+ border-bottom: 1px solid grey;
+}
.conclusion {
margin-bottom: 20px;
}
+
+table.in-question {
+ border: 1px solid black;
+ width: auto;
+ margin: 10px auto;
+}
+
+table.in-question th, table.in-question td {
+ padding: 3px;
+ border-bottom: 1px solid grey;
+}
-/* TODO */
+/* TODO: factor this piece of code from assessment (and course, and here...) */
+.question {
+ margin: 20px 5px;
+ padding: 15px 0;
+}
+.question label {
+ color: black;
+}
+.question .choiceCorrect {
+ background-color: lightgreen;
+}
+.question .choiceWrong {
+ background-color: peachpuff;
+}
+.question .wording {
+ margin-bottom: 10px;
+}
+.question .option {
+ margin-left: 15px;
+}
+.question p {
+ margin-top: 10px;
+}
+.questionInactive {
+ background-color: lightgrey;
+}
+.introduction {
+ padding: 20px 5px;
+}
+.conclusion {
+ padding: 20px 5px;
+}
+table.in-question {
+ border: 1px solid black;
+ width: auto;
+ margin: 10px auto;
+}
+table.in-question th, table.in-question td {
+ padding: 3px;
+ border-bottom: 1px solid grey;
+}
const ObjectId = require("bson-objectid");
const sanitizeHtml = require('sanitize-html');
const sanitizeOpts = {
- allowedTags: sanitizeHtml.defaults.allowedTags.concat([ 'img' ]),
- allowedAttributes: { code: [ 'class' ] },
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat([ 'img', 'u' ]),
+ allowedAttributes: {
+ img: [ 'src' ],
+ code: [ 'class' ],
+ table: [ 'class' ],
+ },
};
router.get("/add/assessment", access.ajax, access.logged, (req,res) => {
});
});
+router.get("/start/monitoring", access.ajax, (req,res) => {
+ const password = req.query["password"];
+ const examName = req.query["aname"];
+ const courseCode = req.query["ccode"];
+ const initials = req.query["initials"];
+ // TODO: sanity checks
+ CourseModel.getByRefs(initials, courseCode, (err,course) => {
+ access.checkRequest(res,err,course,"Course not found", () => {
+ if (password != course.password)
+ return res.json({errmsg: "Wrong password"});
+ AssessmentModel.getByRefs(initials, courseCode, examName, (err2,assessment) => {
+ access.checkRequest(res,err2,assessment,"Assessment not found", () => {
+ res.json({
+ students: course.students,
+ assessment: assessment,
+ secret: params.secret,
+ });
+ });
+ });
+ });
+ });
+});
+
router.get("/send/answer", access.ajax, (req,res) => {
let aid = req.query["aid"];
let number = req.query["number"];
});
});
+// TODO: grading page (for at least partially open-questions exams)
+
module.exports = router;
});
});
-// Monitor: --> after identification (password), always send password hash with requests
+// Monitor: --> after identification (password), always send secret with requests
router.get("/:initials([a-z0-9]+)/:courseCode([a-z0-9._-]+)/:assessmentName([a-z0-9._-]+)/monitor", (req,res) => {
let initials = req.params["initials"];
let code = req.params["courseCode"];
let name = req.params["assessmentName"];
+ // TODO: if (main) teacher, also send secret, saving one request
res.render("monitor", {
title: "monitor assessment " + code + "/" + name,
initials: initials,
- code: code,
- name: name,
+ courseCode: code,
+ examName: name,
});
});
const message = require("./public/javascripts/utils/socketMessages");
const params = require("./config/parameters");
-const AssessmentEntity = require("./entities/assessment");
+const AssessmentModel = require("./models/assessment");
const ObjectId = require("bson-objectid");
module.exports = function(io)
socket.join(aid);
// Student or monitor connexion
const isTeacher = !!socket.handshake.query.secret && socket.handshake.query.secret == params.secret;
+
if (isTeacher)
{
socket.on(message.newAnswer, m => { //got answer from student
socket.emit(message.newAnswer, m);
});
socket.on(message.allAnswers, m => { //send feedback to student (answers)
- if (!!students[m.number]) //TODO: namespace here... room quiz
- socket.broadcast.to(aid).emit(message.allAnswers, m);
+ socket.broadcast.to(aid).emit(message.allAnswers, m);
});
}
else //student
{
const number = socket.handshake.query.number;
const password = socket.handshake.query.password;
- AssessmentEntity.checkPassword(ObjectId(aid), number, password, (err,ret) => {
+ AssessmentModel.checkPassword(ObjectId(aid), number, password, (err,ret) => {
if (!!err || !ret)
return; //wrong password, or some unexpected error...
- // TODO: Prevent socket connection (just ignore) if student already connected
-// io.of('/').in(aid).clients((error, clients) => {
-// if (error)
-// throw error;
-// if (clients.some( c => { return c. .. == number; }))
-// // Problem: we just have a list of socket IDs (not handshakes)
-// });
- // TODO: next is conditional to "student not already taking the exam"
- socket.on(message.allAnswers, () => { //got all answers from teacher
- socket.emit(message.allAnswers, m);
- });
socket.on("disconnect", () => {
//TODO: notify monitor (grey low opacity background)
//Also send to server: discoTime in assessment.papers ...
if assessment.mode != "open"
button.waves-effect.waves-light.btn.on-left(@click="cancelStudent") Cancel
button.waves-effect.waves-light.btn(@click="startAssessment") Start!
- #stage1_2_4(v-show="stage==1 || stage==2 || stage == 4")
+ #stage0_1_4(v-show="[0,1,4].includes(stage)")
.card
.introduction(v-html="assessment.introduction")
- #stage2_4(v-show="stage==2 || stage==4")
+ #stage2_4(v-show="[2,4].includes(stage)")
if assessment.time > 0
- .card
- .timer.center(v-if="stage==2") {{ countdown }}
+ .card(v-if="stage==2")
+ .timer.center {{ countdown }}
.card
- button#sendAsnwer.waves-effect.waves-light.btn(@click="sendAnswer") Send
+ button#sendAnswer.waves-effect.waves-light.btn(@click="sendAnswer") Send
statements(:questions="assessment.questions" :answers="answers")
#stage3(v-show="stage==3")
.card
.finish Exam completed ☺ ...don't close the window!
- #stage3_4(v-show="stage==3 || stage==4")
+ #stage3_4(v-show="[3,4].includes(stage)")
.card
.conclusion(v-html="assessment.conclusion")
script.
let assessment = !{JSON.stringify(assessment)};
const monitoring = false;
+ script(src="//cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.4/socket.io.js")
+ script(src="/javascripts/utils/libsRefresh.js")
script(src="/javascripts/components/statements.js")
script(src="/javascripts/assessment.js")
li.tab
a(href="#group0") All
li.tab(v-for="group in groupList()")
- a(:href="groupId(group,'hash')") G.{{ group }}
+ a(:href="groupId(group,'#')") G.{{ group }}
table.result(:id="groupId(group)" v-for="group in [0].concat(groupList())" @click="showDetails(group)")
thead
tr
--- /dev/null
+extends withQuestions
+
+block content
+ .container#grading
+ .row
+ .col.s12.m10.offset-m1.l8.offset-l2.xl6.offset-xl3
+ h4= examName
+ h4.title(@click="toggleDisplay('grading')") Grading
+ // TODO: Allow grading per student, per question or sub-question
+ .card(v-show="display=='grading'")
+ ul.tabs.tabs-fixed-width
+ li.tab
+ a(href="#group0") All
+ li.tab(v-for="group in groupList()")
+ a(:href="groupId(group,'#')") G.{{ group }}
+ table(:id="groupId(group)" v-for="group in [0].concat(groupList())")
+ thead
+ tr
+ th Forename
+ th Name
+ th(v-for="(q,i) in assessment.questions") Q.{{ (i+1) }}
+ tbody
+ tr.assessment(v-for="s in studentList(group)")
+ td {{ s.forename }}
+ td {{ s.name }}
+ td(v-for="(q,i) in assessment.questions" :style="{background-color: getColor(number,i)}" @click="seeDetails(number,i)")
+ h4.title(@click="toggleDisplay('assessment')") Assessment
+ div(v-show="display=='assessment'")
+ .card
+ .introduction(v-html="assessment.introduction")
+ .card
+ statements(:questions="assessment.questions" :answers:"answers")
+ .card
+ .conclusion(v-html="assessment.conclusion")
+
+block append javascripts
+ script.
+ const examName = "#{examName}";
+ const courseCode = "#{courseName}";
+ const initials = "#{initials}";
+ script(src="/javascripts/components/statements.js")
+ script(src="/javascripts/utils/sha1.js")
+ script(src="/javascripts/grading.js")
extends withQuestions
- //TODO: step 1: ask password (client side, store hash)
- // step 2: when got hash, send request (with hash) to get monitoring page:
- // array with results + quiz details (displayed in another tab) + init socket (with hash too)
- // buttons "start quiz" and "stop quiz" for teacher only: trigger actions (impacting sockets)
-
- // TODO: data = papers (modified after socket messages + retrived at start)
- // But get examName by server at loading
+block append stylesheets
+ link(rel="stylesheet" href="/stylesheets/monitor.css")
block content
- .container#assessment
+ .container#monitor
.row
.col.s12.m10.offset-m1.l8.offset-l2.xl6.offset-xl3
h4= examName
input#password(type="password" v-model="password" @keyup.enter="startMonitoring()")
button.waves-effect.waves-light.btn(@click="startMonitoring()") Send
#stage1(v-show="stage==1")
- .card
- .introduction(v-html="assessment.introduction")
- .card
- statements(:questions="assessment.questions" :answers:"answers")
- .card
- .conclusion(v-html="assessment.conclusion")
+ button.waves-effect.waves-light.btn(@click="endMonitoring()") Send feedback
+ h4.title(@click="toggleDisplay('answers')") Anwers
+ // TODO: aussi afficher stats, permettre tri par colonnes
+ .card(v-show="display=='answers'")
+ ul.tabs.tabs-fixed-width
+ li.tab
+ a(href="#group0") All
+ li.tab(v-for="group in groupList()")
+ a(:href="groupId(group,'#')") G.{{ group }}
+ table(:id="groupId(group)" v-for="group in [0].concat(groupList())")
+ thead
+ tr
+ th Forename
+ th Name
+ th(v-for="(q,i) in assessment.questions") Q.{{ (i+1) }}
+ tbody
+ tr.assessment(v-for="s in studentList(group)")
+ td {{ s.forename }}
+ td {{ s.name }}
+ td(v-for="(q,i) in assessment.questions" :style="{backgroundColor: getColor(s.number,i)}" @click="seeDetails(s.number,i)")
+ h4.title(@click="toggleDisplay('assessment')") Assessment
+ div(v-show="display=='assessment'")
+ .card
+ .introduction(v-html="assessment.introduction")
+ .card
+ statements(:questions="assessment.questions" :answers="answers")
+ .card
+ .conclusion(v-html="assessment.conclusion")
block append javascripts
script.
+ const examName = "#{examName}";
+ const courseCode = "#{courseCode}";
+ const initials = "#{initials}";
const monitoring = true;
+ script(src="/javascripts/utils/libsRefresh.js")
script(src="/javascripts/components/statements.js")
+ script(src="//cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.4/socket.io.js")
+ script(src="/javascripts/utils/sha1.js")
script(src="/javascripts/monitor.js")