From 43448d9ed7a509310c8270861644dbb7b6815c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20W=C3=A4ckerlin?= Date: Wed, 20 Jan 2016 15:55:55 +0000 Subject: [PATCH] possibility to download configuration --- nodejs/public/javascripts/servicedock.js | 676 +++++++++++++--------- nodejs/public/stylesheets/servicedock.css | 2 +- nodejs/sockets/index.js | 71 ++- nodejs/views/index.ejs | 1 + 4 files changed, 471 insertions(+), 279 deletions(-) diff --git a/nodejs/public/javascripts/servicedock.js b/nodejs/public/javascripts/servicedock.js index 1c6b707..484de35 100644 --- a/nodejs/public/javascripts/servicedock.js +++ b/nodejs/public/javascripts/servicedock.js @@ -11,282 +11,403 @@ var socket = io.connect(); var focused = null; -function DockerContainers() { - var Status = Object.freeze({ - Error: {color: "red", action1: "start", action2: "remove", bash: false}, - Terminated: {color: "yellow", action1: "start", action2: "remove", bash: false}, - Restarting: {color: "lightblue", action1: "start", action2: "remove", bash: false}, - Paused: {color: "lightgrey", action1: "unpause", action2: null, bash: false}, - Running: {color: "lightgreen", action1: "pause", action2: "stop", bash: true} - }); - var containers = []; - var nodes = []; - function protocol(port) { - if (port.toString().match("443")) return "https://"; - if (port.toString().match("3304")) return "mysql://"; - if (port.toString().match("22")) return "ssh://"; - return "http://"; +function Docker() { + + function same(array1, array2) { + return (array1.length == array2.length) + && array1.every(function(element, index) { + return element === array2[index]; + }); } - this.exists = function(name) { - if (nodes[name]) return true; - return false; + + var _docker = this; + + this.Images = function() { + + var _images = this; + var images = []; + var nodes = []; + + function setup() { + delete nodes; nodes = []; + images.forEach(function(c, i) { + if (!nodes[c.Id]) nodes[c.Id] = {}; + nodes[c.Id].id = c.Id; + nodes[c.Id].tags = c.RepoTags; + nodes[c.Id].created = c.Created; + nodes[c.Id].author = c.Author; + nodes[c.Id].os = c.Os+"/"+c.Architecture; + nodes[c.Id].parent = c.Parent; + nodes[c.Id].env = c.Config.Env; + nodes[c.Id].cmd = c.Config.Cmd; + nodes[c.Id].entrypoint = c.Config.Entrypoint; + nodes[c.Id].ports = c.Config.ExposedPorts; + nodes[c.Id].volumes = c.Config.Volumes; + if (c.Parent) { + if (!nodes[c.Parent]) nodes[c.Parent] = {}; + if (!nodes[c.Parent].children) nodes[c.Parent].children = []; + nodes[c.Parent].children.push(c.Id); + } + }); + } + this.cleanup = function(id, instance) { + if (!nodes[id]) return + nodes[id].env.forEach(function(e) { + if ((pos=instance.env.indexOf(e))>-1) instance.env.splice(pos, 1) + }) + if (same(nodes[id].cmd, instance.cmd)) instance.cmd = null + else console.log(instance.cmd+" != "+nodes[id].cmd) + if (same(nodes[id].entrypoint, instance.entrypoint)) instance.entrypoint = null + else console.log(instance.entrypoint+" != "+nodes[id].entrypoint) + } + this.set = function(c) { + if (typeof c == "string") c = JSON.parse(c); + if (typeof c != "object") throw "wrong format: "+(typeof c); + images = c; + setup(); + } + } - this.contextmenu = function(selector) { - $('a[xlink\\:href^=#]').click(function(e) { - name = $(this).attr("xlink:href").replace(/^#/, ""); - var n = nodes[name]; - $(selector).prepend('') - $("#popup").empty(); - if (n.status.action1) { - $("#popup").append(''); - $("#popup1").click(function() { - emit(n.status.action1, name); + + this.Containers = function() { + + var _containers = this; + + var Status = Object.freeze({ + Error: {color: "red", action1: "start", action2: "remove", bash: false}, + Terminated: {color: "yellow", action1: "start", action2: "remove", bash: false}, + Restarting: {color: "lightblue", action1: "start", action2: "remove", bash: false}, + Paused: {color: "lightgrey", action1: "unpause", action2: null, bash: false}, + Running: {color: "lightgreen", action1: "pause", action2: "stop", bash: true} + }); + var containers = []; + var nodes = []; + function protocol(port) { + if (port.toString().match("443")) return "https://"; + if (port.toString().match("3304")) return "mysql://"; + if (port.toString().match("22")) return "ssh://"; + return "http://"; + } + this.exists = function(name) { + if (nodes[name]) return true; + return false; + } + function getIps(n, ips) { + n.ports.forEach(function(p) { + if (!ips[p.ip]) ips[p.ip] = []; + ips[p.ip].push(p); + }); + } + function graphIpClusters(ips) { + var res = "newrank=true;\n"; + var i = 0; + for (ip in ips) { + res += "subgraph clusterIp"+(++i)+' {\nlabel="'+ip+'";\n'; + ips[ip].forEach(function(p) { + res += '"'+p.ip+":"+p.external + +'" [label="'+p.external+'",URL="' + +protocol(p.internal)+p.ip+':'+p.external+'",shape=box];\n'; }); + res+="}\n"; } - $("#popup").append(''); - $("#popup2").click(function() { - if (focused) overview(); else details(name); + res += "{rank=same;\n"; + for (ip in ips) { + ips[ip].forEach(function(p) { + res += '"'+p.ip+":"+p.external+'";\n'; + }); + } + res+="}\n"; + return res; + } + function graphNode(n) { + var res = ""; + var label = n.name+'\\n'+n.image.name; + res += '"'+n.name+'"' + +' [label="'+label + +'",URL="#'+n.name + +'",style=filled,fillcolor='+n.status.color+"];\n"; + n.ports.forEach(function(p) { + res += '"'+(p.ip?p.ip+":":"")+p.external+'" -> "'+n.name + +'" [label="'+p.internal+'"];\n'; }); - if (n.status.action2) { - $("#popup").append(''); - $("#popup3").click(function() { - emit(n.status.action2, name); + n.links.forEach(function(l) { + res += '"'+n.name+'" -> "'+l.to+'" [label="link: '+l.link+'"];\n' }); - } - $("#popup").append('
'); - $("#popup").append(''); - $("#popup4").click(function() { - emit("logs", name); + return res; + } + function graphVolumesInside(n) { + var res = ""; + n.volumes.forEach(function(v) { + res += '"'+v.id+'" [label="'+v.inside+'",shape=box];\n'; }); - if (n.status.bash) { - $("#popup").append(''); - $("#popup5").click(function() { - emit("bash-start", name); - $("#console").show(); - $("#main").hide(); - $("#bash").submit(function() { - emit("bash-input", {name: name, text: $("#command").val()+"\n"}); - $("#command").val(""); - }) - }); - } - $("#popup").append(''); - $("#popup").css("position", "fixed"); - $("#popup").css("top", e.pageY-$("#popup").height()/4); - $("#popup").css("left", e.pageX-$("#popup").width()/2); - $("#popup").mouseleave(function() { - $("#popup").hide(); - }).click(function() { - $("#popup").hide(); + return res; + } + function graphVolumesOutside(n) { + var res = ""; + n.volumes.forEach(function(v) { + if (v.host) + res += '"'+v.outside+'" [label="'+v.host+'",shape=box];\n'; }); - $("#popup").show(); - }) - } - function getIps(n, ips) { - n.ports.forEach(function(p) { - if (!ips[p.ip]) ips[p.ip] = []; - ips[p.ip].push(p); - }); - } - function graphIpClusters(ips) { - var res = "newrank=true;\n"; - var i = 0; - for (ip in ips) { - res += "subgraph clusterIp"+(++i)+' {\nlabel="'+ip+'";\n'; - ips[ip].forEach(function(p) { - res += '"'+p.ip+":"+p.external - +'" [label="'+p.external+'",URL="' - +protocol(p.internal)+p.ip+':'+p.external+'",shape=box];\n'; + return res; + } + function graphVolumesConnections(n) { + var res = ""; + n.volumes.forEach(function(v) { + if (v.host) + res += '"'+v.id+'" -> "'+v.outside+'" [label="mounted from"]\n'; + res += '"'+n.name+'" -> "'+v.id+'" [label="volume/'+v.rw+'"]\n'; }); + n.volumesfrom.forEach(function(o) { + res += '"'+n.name+'" -> "'+nodes[o].name+'" [label="volumes from"]\n'; + }); + return res; + } + this.graph = function(n) { + var res = ""; + var ips = []; + n = n || nodes; + for (name in n) getIps(n[name], ips); + res += graphIpClusters(ips); + for (name in n) res += graphNode(n[name]); + res += "{rank=same;\n"; + for (name in n) res += graphVolumesInside(n[name]); + res+="}\n"; + res += "{rank=same;\n"; + for (name in n) res += graphVolumesOutside(n[name]); res+="}\n"; + for (name in n) res += graphVolumesConnections(n[name]); + return res; } - res += "{rank=same;\n"; - for (ip in ips) { - ips[ip].forEach(function(p) { - res += '"'+p.ip+":"+p.external+'";\n'; + function addNodes(ns, name) { + var n = nodes[name]; + ns[name] = n; + n.links.forEach(function(peer) { + if (!ns[peer.to]) addNodes(ns, peer.to); + }); + n.usedby.forEach(function(peer) { + if (!ns[peer]) addNodes(ns, peer); + }); + n.volumesfrom.forEach(function(peer) { + if (!ns[peer]) addNodes(ns, peer); + }); + n.volumesto.forEach(function(peer) { + if (!ns[peer]) addNodes(ns, peer); }); } - res+="}\n"; - return res; - } - function graphNode(n) { - var res = ""; - var label = n.name+'\\n'+n.image; - res += '"'+n.name+'"' - +' [label="'+label - +'",URL="#'+n.name - +'",style=filled,fillcolor='+n.status.color+"];\n"; - n.ports.forEach(function(p) { - res += '"'+(p.ip?p.ip+":":"")+p.external+'" -> "'+n.name - +'" [label="'+p.internal+'"];\n'; - }); - n.links.forEach(function(l) { - res += '"'+n.name+'" -> "'+l.to+'" [label="link: '+l.link+'"];\n' - }); - return res; - } - function graphVolumesInside(n) { - var res = ""; - n.volumes.forEach(function(v) { - res += '"'+v.id+'" [label="'+v.inside+'",shape=box];\n'; - }); - return res; - } - function graphVolumesOutside(n) { - var res = ""; - n.volumes.forEach(function(v) { - if (v.host) - res += '"'+v.outside+'" [label="'+v.host+'",shape=box];\n'; - }); - return res; - } - function graphVolumesConnections(n) { - var res = ""; - n.volumes.forEach(function(v) { - if (v.host) - res += '"'+v.id+'" -> "'+v.outside+'" [label="mounted from"]\n'; - res += '"'+n.name+'" -> "'+v.id+'" [label="volume/'+v.rw+'"]\n'; - }); - n.volumesfrom.forEach(function(o) { - res += '"'+n.name+'" -> "'+nodes[o].name+'" [label="volumes from"]\n'; - }); - return res; - } - this.graph = function(n) { - var res = ""; - var ips = []; - n = n || nodes; - for (name in n) getIps(n[name], ips); - res += graphIpClusters(ips); - for (name in n) res += graphNode(n[name]); - res += "{rank=same;\n"; - for (name in n) res += graphVolumesInside(n[name]); - res+="}\n"; - res += "{rank=same;\n"; - for (name in n) res += graphVolumesOutside(n[name]); - res+="}\n"; - for (name in n) res += graphVolumesConnections(n[name]); - return res; - } - function addNodes(ns, name) { - var n = nodes[name]; - ns[name] = n; - n.links.forEach(function(peer) { - if (!ns[peer.to]) addNodes(ns, peer.to); - }); - n.usedby.forEach(function(peer) { - if (!ns[peer]) addNodes(ns, peer); - }); - n.volumesfrom.forEach(function(peer) { - if (!ns[peer]) addNodes(ns, peer); - }); - n.volumesto.forEach(function(peer) { - if (!ns[peer]) addNodes(ns, peer); - }); - } - this.subgraph = function(name) { - var ns = []; - addNodes(ns, name); - return this.graph(ns); - } - function setup() { - delete nodes; nodes = []; - containers.forEach(function(c, i) { - var name = c.Name.replace(/^\//, ""); - if (!nodes[name]) nodes[name] = {}; - nodes[name].id = i; - nodes[name].name = name; - nodes[name].image = c.Config.Image; - nodes[name].ports = []; - var ports = c.NetworkSettings.Ports || c.NetworkSettings.PortBindings; - if (ports) - for (var port in ports) - if (ports[port]) - for (var expose in ports[port]) { - var ip = ports[port][expose].HostIp; - if (!ip||ip==""||ip=="0.0.0.0"||ip==0) ip=window.location.hostname; - nodes[name].ports.push({ - internal: port, - external: ports[port][expose].HostPort, - ip: ip - }); - } - if (c.State.Paused) nodes[name].status = Status.Paused; - else if (c.State.Running) nodes[name].status = Status.Running; - else if (c.State.Restarting) nodes[name].status = Status.Restarting; - else if (c.State.ExitCode == 0) nodes[name].status = Status.Terminated; - else nodes[name].status = Status.Error; - nodes[name].volumes = []; - var volumes = c.Volumes || c.Config.Volumes; - nodes[name].volumes = []; - if (volumes) - for (var volume in volumes) { - var rw = "rw"; - var outside = (typeof volumes[volume]=="string")?volumes[volume]:null; - if (c.Mounts) c.Mounts.forEach(function(mnt) { - if (mnt.Destination==volume) { - outside = mnt.Source; - rw = mnt.RW ? "rw" : "ro"; + this.subnet = function(name) { + var ns = {}; + addNodes(ns, name); + return ns; + } + this.subgraph = function(name) { + return this.graph(this.subnet(name)); + } + this.creation = function(name) { + var ns = this.subnet(name); + var creates = []; + for (n in ns) { + var instance = { + name: ns[n].name, + image: ns[n].image.name, + ports: ns[n].ports, + env: ns[n].env, + cmd: ns[n].cmd, + entrypoint: ns[n].entrypoint, + volumesfrom: ns[n].volumesfrom, + links: ns[n].links, + volumes: [] + }; + ns[n].volumes.forEach(function(v) { + if (v.host) instance.volumes.push({ + inside: v.inside, + outside: v.host + }); + }); + _docker.images.cleanup(ns[n].image.id, instance); + creates.push(instance); + } + creates.sort(function(a, b) { + if (a.volumesfrom.indexOf(b)>=0) return 1; // a after b + if (b.volumesfrom.indexOf(a)>=0) return -1; // a before b + for (var i=0; i') + $("#popup").empty(); + if (n.status.action1) { + $("#popup").append(''); + $("#popup1").click(function() { + emit(n.status.action1, name); + }); + } + $("#popup").append(''); + $("#popup2").click(function() { + if (focused) overview(); else details(name); + }); + if (n.status.action2) { + $("#popup").append(''); + $("#popup3").click(function() { + emit(n.status.action2, name); }); } - nodes[name].volumesfrom = []; - if (!nodes[name].volumesto) nodes[name].volumesto = []; - if (c.HostConfig.VolumesFrom) c.HostConfig.VolumesFrom.forEach(function(id) { - containers.forEach(function(c) { - if (c.Id == id || c.Name == "/"+id || c.Name == id) { - var src = c.Name.replace(/^\//, ""); - nodes[name].volumesfrom.push(src); - if (!nodes[src]) nodes[src] = {}; - if (!nodes[src].volumesto) nodes[src].volumesto = []; - nodes[src].volumesto.push(name); - } + $("#popup").append('
'); + $("#popup").append(''); + $("#popup4").click(function() { + showLogs(); + emit("logs", name); }); - }); - nodes[name].links = []; - if (!nodes[name].usedby) nodes[name].usedby = []; - if (c.HostConfig && c.HostConfig.Links) - c.HostConfig.Links.forEach(function(l) { - var target = { - to: l.replace(/^\/?([^:]*).*$/, "$1"), - link: l.replace(new RegExp("^.*:/?"+name+"/"), "") - }; - nodes[name].links.push(target); - if (!nodes[target.to]) nodes[target.to] = {}; - if (!nodes[target.to].usedby) nodes[target.to].usedby = []; - nodes[target.to].usedby.push(name); + if (n.status.bash) { + $("#popup").append(''); + $("#popup5").click(function() { + showConsole(); + emit("bash-start", name); + $("#bash").submit(function() { + emit("bash-input", {name: name, text: $("#command").val()+"\n"}); + $("#command").val(""); + }) + }); + } + $("#popup").append(''); + $("#popup6").click(function() { + var download = document.createElement('a'); + download.href = 'data:application/json,' + + encodeURI(JSON.stringify(_containers.creation(name), null, 2)); + download.target = '_blank'; + download.download = name+'.json'; + var clickEvent = new MouseEvent("click", { + "view": window, + "bubbles": true, + "cancelable": false + }); + download.dispatchEvent(clickEvent); }); - }); - for (name in nodes) { // cleanup duplicate links to volumes when using volumes-from - var n = nodes[name]; - n.volumesfrom.forEach(function(other) { - var o = nodes[other]; - o.volumes.forEach(function(ovol) { - n.volumes.reduceRight(function(x, nvol, i, arr) { - if (nvol.id == ovol.id) - arr.splice(i, 1); - }, []) - }) + $("#popup").css("position", "fixed"); + $("#popup").css("top", e.pageY-$("#popup").height()/4); + $("#popup").css("left", e.pageX-$("#popup").width()/2); + $("#popup").mouseleave(function() { + $("#popup").hide(); + }).click(function() { + $("#popup").hide(); + }); + $("#popup").show(); }) } + this.set = function(c) { + if (typeof c == "string") c = JSON.parse(c); + if (typeof c != "object") throw "wrong format: "+(typeof c); + containers = c; + setup(); + } } - this.setContainers = function(c) { - if (typeof c == "string") c = JSON.parse(c); - if (typeof c != "object") throw "wrong format: "+(typeof c); - containers = c; - setup(); - } + + this.images = new this.Images(); + this.containers = new this.Containers(); + } -var dc = new DockerContainers(); +var docker = new Docker(); function htmlenc(html) { return $('
').text(html).html(); @@ -312,7 +433,8 @@ function error(data) { console.log("error: "+data); } else { $("#status").html('unknown error: '+JSON.stringify(data)); - console.log("error: "+JSON.stringify(data)); + console.log("error: ", data); + console.log((new Error('stacktrace'))); } } else { $("#status").html('error'); @@ -372,7 +494,7 @@ function status(text, msg) { zoom(0); $("#main").show(); $("form input:first-child").focus(); - dc.contextmenu("#main"); + docker.containers.contextmenu("#main"); } function emit(signal, data) { @@ -481,23 +603,50 @@ function showviz(vizpath, more) { function details(name) { if (name) focused = name; else if (!focused) return overview(); - showviz(dc.subgraph(focused)); + showviz(docker.containers.subgraph(focused)); +} + +function images(i) { + console.log("->rcv images"); + docker.images.set(i); } function containers(c) { console.log("->rcv containers"); - dc.setContainers(c); - if (focused && dc.exists(focused)) + docker.containers.set(c); + showImage(); + if (focused && docker.containers.exists(focused)) details(focused); else overview(); } -function logs(data) { - console.log("->rcv logs("+data.name+")"); - $("#main").hide(); +function showImage() { + $("#close").hide(); + $("#logs").hide(); $("#console").hide(); + $("#main").show(); +} + +function showConsole() { + $("#main").hide(); + $("#logs").hide(); + $("#console").show(); + $("#close").show(); + $("#command").focus(); + $("#command").val(""); + if ($("#screen").val()!="") $("#screen").append("\n"); +} + +function showLogs() { + $("#main").hide(); $("#logs").show(); + $("#console").hide(); + $("#close").show(); +} + +function logs(data) { + console.log("->rcv logs("+data.name+")"); if (data.type=='done') { $("#logs").append('\nDONE'); } else { @@ -586,9 +735,6 @@ function ansifilter(data) { function bash_data(data) { console.log("->rcv bash-data("+data.name+")", data); - $("#main").hide(); - $("#logs").hide(); - $("#console").show(); if (data.type=='done') { $("#screen").append('\nDONE'); } else { @@ -598,7 +744,7 @@ function bash_data(data) { function overview() { focused = null; - showviz(dc.graph()); + showviz(docker.containers.graph()); } /// Initial Function: Startup @@ -609,6 +755,7 @@ function start() { $("#username").html(window.location.hostname) try { status("Starting up ..."); + emit("images"); emit("containers"); } catch (m) { error(m); @@ -624,6 +771,7 @@ function init() { socket .on("fail", error) .on("containers", containers) + .on("images", images) .on("logs", logs) .on("bash-data", bash_data); start(); diff --git a/nodejs/public/stylesheets/servicedock.css b/nodejs/public/stylesheets/servicedock.css index 2aff43a..65ce395 100644 --- a/nodejs/public/stylesheets/servicedock.css +++ b/nodejs/public/stylesheets/servicedock.css @@ -195,7 +195,7 @@ table.docker li+li { color: black; } -#menuicon, #imagetools img { +#menuicon, #imagetools img, #close { cursor: pointer; height: 1em; width: auto; diff --git a/nodejs/sockets/index.js b/nodejs/sockets/index.js index b6c4c19..c12058d 100644 --- a/nodejs/sockets/index.js +++ b/nodejs/sockets/index.js @@ -70,6 +70,46 @@ module.exports = function() { updatecontainers(); } + function imageinspect(error, stdout, stderr) { + if (error || stderr) + return fail("inspect docker images failed", { + error: error, stderr: stderr, stdout: stdout + }); + emit("images", stdout); + } + + function imagelist(error, stdout, stderr) { + if (error || stderr) + return fail("list docker images failed", { + error: error, stderr: stderr, stdout: stdout + }); + exec("docker inspect "+stdout.trim().replace(/\n/g, " "), imageinspect); + } + + function updateimages(error, stdout, stderr) { + if (error || stderr) + return fail("update docker images failed", { + error: error, stderr: stderr, stdout: stdout + }); + exec("docker images -q", imagelist); + } + + function modify(cmd, name) { + if (!name.match(/^[a-z0-9][-_:.+a-z0-9]*$/i)) + return fail("illegal instance name"); + exec("docker "+cmd+" "+name, updatecontainers); + } + + function containers() { + console.log("-> containers"); + updatecontainers(); + } + + function images() { + console.log("-> images"); + updateimages(); + } + function start(name) { console.log("-> start("+name+")"); modify("start", name); @@ -111,36 +151,39 @@ module.exports = function() { var bash_connections = {}; - function bash_start(name) { - console.log("-> bash-start("+name+")"); + function new_bash(name) { if (!name.match(/^[a-z0-9][-_:.+a-z0-9]*$/i)) return fail("illegal instance name"); - if (bash_connections[name]) return fail("bash already open"); + if (bash_connections[name]) return; bash_connections[name] = pty.spawn("docker", ["exec", "-it", name, "bash", "-i"]); bash_connections[name].stdout.on('data', function(data) { emit('bash-data', {name: name, type: 'stdout', text: data.toString()}); }); - // bash_connections[name].stderr.on('data', function(data) { - // emit('bash-data', {name: name, type: 'stdout', text: data.toString()}); - // }); + } + + function bash_start(name) { + console.log("-> bash-start("+name+")"); + new_bash(name); } function bash_input(data) { console.log("-> bash-input("+data.name+", "+data.text+")"); - if (!bash_connections[data.name]) return fail("bash not open"); + new_bash(name); bash_connections[data.name].stdin.resume(); bash_connections[data.name].stdin.write(data.text); } - // function bash_end(name, text) { - // console.log("-> bash-end("+name+")"); - // if (!bash_connections[name]) return fail("bash not open"); - // bash_connections[name].stdin.close(); - // } + function bash_end(name, text) { + console.log("-> bash-end("+name+")"); + if (!bash_connections[name]) return; + bash_connections[name].stdin.close(); + delete bash_connections[name]; bash_connections[name] = null; + } socket .on("containers", containers) + .on("images", images) .on("start", start) .on("stop", stop) .on("pause", pause) @@ -148,8 +191,8 @@ module.exports = function() { .on("remove", remove) .on('logs', logs) .on('bash-start', bash_start) - .on('bash-input', bash_input); - //.on('bash-end', bash_end); + .on('bash-input', bash_input) + .on('bash-end', bash_end); } diff --git a/nodejs/views/index.ejs b/nodejs/views/index.ejs index 7e38332..8db0b8e 100644 --- a/nodejs/views/index.ejs +++ b/nodejs/views/index.ejs @@ -27,6 +27,7 @@ +