Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Mint Source!

  • Loading branch information...
commit 2eb070b44e7f472625589c6c1500a8a07bbbf818 0 parents
Andrew Appleton authored
4 .gitignore
@@ -0,0 +1,4 @@
+.DS_Store
+.monitor
+/node_modules
+settings.coffee
2  Procfile
@@ -0,0 +1,2 @@
+web: node index.js
+
63 README.md
@@ -0,0 +1,63 @@
+Mint Source
+=========
+
+A simple Node.js status board showing:
+
+- Recent Github commits
+- Jenkins CI build status
+- Current song playing through Last.fm
+
+Dependencies
+------------
+
+- node.js 0.4
+- npm
+ - To install run: `curl http://npmjs.org/install.sh | sh`
+- Redis (local install for development)
+- Heroku account (for production deploy)
+
+Development
+------------
+
+1. Install dependencies `npm install`
+2. Copy `settings.coffee.example` to `settings.coffee`.
+3. Add relevant details to the settings.coffee
+4. Start redis `redis-server`
+5. Run the app `node index.js`
+6. Visit `localhost:1337` in a browser.
+
+Deploy to Heroku
+------------
+
+1. Create a new app on Heroku: `heroku create appname --stack cedar`
+2. Deploy to heroku `git push heroku master`
+3. Scale the web process on Heroku `heroku ps:scale web=1`
+4. Add Redis to go to your app `heroku addons:add redistogo`
+5. Add your app settings:
+ - `heroku config:add NODE_ENV=production`
+ - `heroku config:add AUTH_ENABLED=true` => enable/disable basic HTTP auth
+ - `heroku config:add AUTH_USER=username` => optional basic auth username
+ - `heroku config:add AUTH_PASS=password` => optional basic auth password
+ - `heroku config:add JENKINS_ENABLED=true` => enable build monitoring with Jenkins?
+ - `heroku config:add JENKINS_IP=10.20.30.40` => set if you'd like to IP filter the Jenkins receive action
+ - `heroku config:add LASTFM_ENABLED=true` => optional - show current Last.fm song
+ - `heroku config:add LASTFM_KEY=apikey` => your Last.fm api key
+ - `heroku config:add LASTFM_USER=username` => the Last.fm account you'd like to track
+
+Wait, I have NDA projects which I can't display on my status board!
+---------------
+
+We totally have your back, substitute sensitive names by adding them to Redis (there will be a page to administer this eventually):
+
+ LPUSH Discretions "{\"orig\":\"secret-name\",\"subs\":\"public-name\"}"
+
+Testing locally
+---------------
+
+You can manually add mock Github post receive hook data to Redis.
+
+ redis-cli
+
+ LPUSH Commits "{\"message\":\"update pricing a tad\",\"project\":\"github\",\"timestamp\":\"2008-02-15T14:36:34-08:00\",\"author\":\"Chris Wanstrath\"}"
+
+ LPUSH Commits "{\"message\":\"woo\! it works\",\"project\":\"github\",\"timestamp\":\"2011-02-15T14:36:34-08:00\",\"author\":\"Chris Wanstrath\"}"
207 app.coffee
@@ -0,0 +1,207 @@
+connect = require('connect')
+express = require('express')
+url = require('url')
+fs = require('fs')
+qs = require('qs')
+async = require('async')
+moment = require('moment')
+gravatar = require('gravatar')
+template = require('jqtpl')
+Jenkins = require('./jenkins')
+Lastfm = require('./lastfm')
+helpers = require('./helpers/helpers')
+settings = {}
+redis = {}
+app = module.exports = express.createServer()
+io = require('socket.io').listen(app)
+
+# Configuration
+
+app.configure( ()->
+ app.set('views', __dirname + '/views')
+ app.set('view engine', 'html')
+ app.register('.html', require('jqtpl').express)
+ app.use(app.router)
+ app.use(express.static(__dirname + '/public'))
+ return
+)
+
+app.configure('development', () ->
+ app.use(express.errorHandler({ dumpExceptions: true, showStack: true }))
+ settings = require('./settings')
+ redis = require('redis').createClient()
+ return
+)
+
+app.configure('production', () ->
+ app.use(express.errorHandler())
+ settings = require('./settings-heroku')
+ # Redis to go on Heroku, local Redis in development
+ rtg = url.parse(process.env.REDISTOGO_URL)
+ redis = require('redis').createClient(rtg.port, rtg.hostname)
+ redis.auth(rtg.auth.split(":")[1])
+ # No websockets for Heroku
+ io.configure () ->
+ io.set('transports', ['xhr-polling'])
+ io.set('polling duration', 10)
+ return
+)
+
+app.helpers({
+ gravatar:gravatar,
+ moment:moment
+})
+
+# get discretionList from Redis
+settings.discretionList = []
+getDiscretionList = (listName) ->
+ redis.llen(listName, (err, i) ->
+ redis.lrange(listName, 0, i, (err, replies) ->
+ for reply in replies
+ settings.discretionList.push(JSON.parse(reply))
+ )
+ )
+getDiscretionList('Discretions')
+
+
+# Middleware functions
+
+basicAuth = (req, res, next) ->
+ if settings.auth.enabled
+ connect.basicAuth(settings.auth.user, settings.auth.pass)(req, res, next)
+ else
+ next()
+
+ipWhitelist = (req, res, next) ->
+ remote = req.connection.remoteAddress
+ if settings.jenkins.ip and remote != settings.jenkins.ip
+ res.writeHead 403
+ res.end '403 Forbidden'
+ console.log "IP whitelist: blocked request to '#{req.url}' from '#{remote}'"
+ else
+ console.log "IP whitelist: allowed request to '#{req.url}' from '#{remote}'"
+ next()
+
+# Routes
+
+app.get('/javascripts/:resource.js', (req, res) ->
+ # Compile and serve client side CoffeeScript on the fly
+ filePath = "./public/javascripts/#{req.params.resource}.coffee"
+ fs.readFile(filePath, 'utf-8', (err, data) ->
+ res.writeHead(200, {'Content-Type': 'application/javascript'})
+ res.write(coffee.compile(data))
+ res.end()
+ )
+)
+
+app.get('/', basicAuth, (req, res) ->
+ commits = []
+ statuses = []
+ getStatuses = ->
+ redis.hgetall('Jenkins', (err, replies) ->
+ for project, status of replies
+ project = helpers.discretify(project, settings.discretionList)
+ if status != 'SUCCESS'
+ statuses.push({project: project, status: status})
+ res.header('Cache-Control', 'no-cache')
+ res.render('index', {
+ title: 'Mint Source',
+ commits: commits,
+ statuses: statuses,
+ songsEnabled: settings.lastfm.enabled
+ })
+ )
+ app.emit 'pageload'
+ redis.lrange('Commits', 0, 5, (err, replies) ->
+ for reply in replies
+ reply = JSON.parse(reply)
+ reply.project = helpers.discretify(reply.project, settings.discretionList)
+ commits.push(reply)
+
+ async.sortBy(commits, (commit, callback) ->
+ callback(null, 1 / Date.parse(commit.timestamp))
+ ,(err, results) ->
+ commits = results
+ getStatuses()
+ )
+ )
+)
+
+app.post('/github_prh', basicAuth, (req, res) ->
+ # GitHub post recieve hook
+ # Remember to post to http://user:password@your-app/github_prh
+ # if using basic auth - https://github.com/blog/237-basic-auth-post-receives
+ body = ''
+
+ req.on('data', (data) ->
+ body += data
+ )
+
+ req.on('end', () ->
+ payload = JSON.parse(qs.parse(body).payload)
+ commits = payload.commits
+ project = helpers.discretify(payload.repository.name, settings.discretionList)
+ out = []
+ for commit in commits
+ data =
+ message: commit.message
+ project: project
+ timestamp: commit.timestamp
+ author: commit.author.name
+ image: gravatar.url(commit.author.email, {s:120})
+ data.relTime = moment(data.timestamp).fromNow()
+ out.push(data)
+
+ async.sortBy(out, (commit, callback) ->
+ callback(null, 1 / Date.parse(commit.timestamp))
+ ,(err, results) ->
+ out = results
+ )
+
+ for commit in out
+ redis.lpush('Commits', JSON.stringify(commit))
+
+ redis.ltrim('Commits', 0, 5)
+
+ fs.readFile('./views/_commit.html', 'utf-8', (err, rawTemplate) ->
+ io.sockets.emit('message', template.tmpl(rawTemplate, out))
+ )
+
+ res.writeHead(200, {'Content-Type': 'text/html'})
+ res.end('OK')
+ )
+)
+
+app.post('/jenkins_pbh', ipWhitelist, (req, res) ->
+ # Jenkins post build hook
+ # Requires https://wiki.jenkins-ci.org/display/JENKINS/Notification+Plugin
+ if !settings.jenkins.enabled
+ console.log('Jenkins disabled')
+ res.writeHead(404)
+ res.end()
+ else
+ body = ''
+ req.on('data', (data) ->
+ body += data
+ )
+ req.on('end', () ->
+ post = JSON.parse(body)
+ return unless post.build.phase == 'FINISHED'
+ j = new Jenkins(post)
+ j.on('data', (data) -> io.sockets.emit('jenkins', data))
+ return
+ )
+ res.writeHead(200, {'Content-Type': 'text/html'})
+ res.end('OK')
+)
+
+if settings.lastfm.enabled
+ lfm = new Lastfm({
+ user: settings.lastfm.user
+ apiKey: settings.lastfm.apiKey
+ })
+ app.on 'pageload', () -> lfm.createRequest()
+ lfm.on('song', (data) -> io.sockets.emit('lastfm', data))
+
+app.listen(process.env.PORT || 1337)
+console.log("Express server listening on port %d in %s mode", app.address().port, app.settings.env)
7 cakefile
@@ -0,0 +1,7 @@
+{exec} = require 'child_process'
+
+# Should never need this as everything is compiled on the fly...
+task 'compile', 'Compile CoffeeScript into JavaScipt', (options) ->
+ exec 'coffee --compile --output compiled/ .', (err, stdout, stderr) ->
+ throw err if err
+ console.log stdout + stderr
9 helpers/helpers.coffee
@@ -0,0 +1,9 @@
+exports.discretify = (name, substitutes) ->
+ discreteName = name
+ if substitutes
+ for sub of substitutes
+ if substitutes[sub].orig == name
+ discreteName = substitutes[sub].subs
+ return discreteName
+
+ return discreteName
2  index.js
@@ -0,0 +1,2 @@
+coffee = require('coffee-script')
+require('./app')
47 jenkins.coffee
@@ -0,0 +1,47 @@
+util = require('util')
+events = require('events')
+https = require('https')
+url = require('url')
+if process.env.REDISTOGO_URL
+ rtg = url.parse(process.env.REDISTOGO_URL)
+ redis = require('redis').createClient(rtg.port, rtg.hostname)
+ redis.auth(rtg.auth.split(":")[1])
+else
+ redis = require('redis').createClient()
+
+class Jenkins extends events.EventEmitter
+ constructor: (data) ->
+ events.EventEmitter.call(this)
+ @project = data.name
+ @status = data.build.status
+
+ @parseStatus()
+
+ parseStatus: () ->
+ prevStatus = ''
+
+ statusLogic = =>
+ redis.hset('Jenkins', @project, @status)
+ @status = 'RECENT FAILURE' if prevStatus == 'FAILURE' and @status == 'SUCCESS'
+ @sendOutput()
+
+ if redis.hexists('Jenkins', @project)
+ redis.hget('Jenkins', @project, (err, result) ->
+ prevStatus = result
+ statusLogic()
+ )
+ else
+ statusLogic()
+
+ sendOutput: () ->
+ if @status is 'FAILURE' or 'RECENT FAILURE'
+ output = {
+ project: @project,
+ status: @status
+ }
+ console.log('Sending: ', output)
+ @emit('data', output);
+ return
+
+module.exports = Jenkins
+
66 lastfm.coffee
@@ -0,0 +1,66 @@
+events = require("events")
+http = require("http")
+
+class Lastfm extends events.EventEmitter
+ constructor: (opts)->
+ events.EventEmitter.call this
+ @user = opts.user
+ @apiKey = opts.apiKey
+ @data = []
+ @responseData = ''
+ @startPolling()
+ @createRequest()
+
+ buildPath: ->
+ "/2.0/?method=user.getrecenttracks&api_key=#{@apiKey}&user=#{@user}&format=json"
+
+ onData: (data)->
+ @responseData = @responseData + data
+ return
+
+ onEnd: ->
+ @data = JSON.parse(@responseData)["recenttracks"]["track"]
+ @responseData = ''
+ @sendOutput()
+ return
+
+ startPolling: ->
+ if !@refreshInterval
+ @refreshInterval = setInterval (=> @createRequest()), 30e3
+ return
+
+ stopPolling: ->
+ if @refreshInterval
+ clearInterval @refreshInterval
+ return
+
+ createRequest: ->
+ opts =
+ host: 'ws.audioscrobbler.com'
+ port: 80
+ path: @buildPath()
+ method: 'GET'
+ request = http.request opts, (response)=>
+ if response.statusCode is 200
+ response.setEncoding 'utf8'
+ response.on 'data', @onData.bind(this)
+ response.on 'end', @onEnd.bind(this)
+ return
+ else
+ @emit 'song', []
+ return
+ request.end()
+ return
+
+ sendOutput: ->
+ output = []
+
+ for play in @data
+ if play['@attr'] and play['@attr']['nowplaying']
+ output.push
+ artist: play.artist['#text']
+ name: play.name
+ @emit 'song', output
+ return
+
+module.exports = Lastfm
17 package.json
@@ -0,0 +1,17 @@
+{
+ "name": "mint-source",
+ "version": "0.1.0",
+ "dependencies": {
+ "coffee-script":"latest",
+ "express":"2.5.0",
+ "connect":"1.7.2",
+ "qs":"0.3.2",
+ "async":"0.1.15",
+ "jqtpl":"1.0.7",
+ "redis":"0.6.7",
+ "socket.io":"0.8.6",
+ "moment":"1.1.1",
+ "gravatar":"1.0.3"
+ }
+}
+
33 public/javascripts/application.coffee
@@ -0,0 +1,33 @@
+window.App = {} unless window.App
+
+$ () ->
+ socket = io.connect window.location.origin
+
+ setDates = () ->
+ moment.lang('fr')
+ $(".commit").each((index, elm) ->
+ timeElm = $(".message .time", elm)
+ timeElm.html(moment(timeElm.attr("data-time-stamp")).fromNow())
+ )
+ setDates();
+
+ socket.on 'connect', ->
+ App.Health().init(socket)
+
+ socket.on 'message', (commit) ->
+ timeElm = $(".message .time", commit)
+ timeElm.html(moment(timeElm.attr("data-time-stamp")).fromNow())
+ $('#wrapper').prepend(commit)
+ $('#wrapper .commit:gt(9)').remove()
+
+ if App.songsEnabled
+ App.Songs.init(socket)
+
+ reloadCSS = () ->
+ `var h,a,f,g;a=window.document.getElementsByTagName('link');for(h=0;h<a.length;h++){f=a[h];if(f.rel.toLowerCase().match(/stylesheet/)&&f.href){g=f.href.replace(/(&|\?)forceReload=\d+/,'');f.href=g+(g.match(/\?/)?'&':'?')+'forceReload='+(new Date().valueOf());}}`
+ console.log('Reloading CSS...') if window.console and window.console.log
+
+ $(window.document).keyup (ev) ->
+ # Enables hitting alt-W to refresh CSS in every browser.
+ # Source: http://gist.github.com/221905
+ reloadCSS() if ev.which is 87 and ev.altKey
111 public/javascripts/health.coffee
@@ -0,0 +1,111 @@
+# Health.coffee
+# returns App.Health for visualising Jenkins health.
+
+window.App = {} unless window.App
+
+App.Health = () ->
+ _cache = {}
+ Health =
+ init: (socketObject) ->
+ @$body = $('body')
+ @$failWrapper = $('.fail-wrapper')
+ @$failText = @$failWrapper.find('.fail-message')
+ @socket = socketObject
+
+ @bootstrap() if App.JenkinsBootstrap.length > 0
+ @listen()
+
+ listen: () ->
+ @socket.on 'jenkins', (msg) => @parseMessage(msg)
+
+ bootstrap: () ->
+ for status in App.JenkinsBootstrap
+ @parseMessage(status)
+
+ stopListening: () ->
+ @socket.off 'jenkins'
+
+ get: (project) ->
+ _cache[project]
+
+ set: (project, status) ->
+ _cache[project] = status
+
+ parseMessage: (msg) ->
+ @set(msg.project, msg.status)
+ failing = @getFailing()
+ recentFailing = @getRecentFailing()
+
+ if failing
+ @writeMessages failing, 'failing to build'
+ else if recentFailing
+ @writeMessages recentFailing, 'recently failed to build'
+ else
+ @updateState(null)
+
+ writeMessages: (msgs, doing) ->
+ length = msgs.length
+ tense = if doing == 'failing to build' then 'present' else 'past'
+
+ joiner = (tense, num) ->
+ if tense == 'present' and num == 1
+ ' is '
+ else if tense == 'present' and num > 1
+ ' are '
+ else
+ ' '
+
+ messages = (msgs, num) ->
+ out = ''
+ $.each msgs, (i, msg) ->
+ if i == num - 2
+ out += "#{msg} and "
+ else if i == num - 1
+ out += msg
+ else
+ out += "#{msg}, "
+ return out
+
+ txt = messages(msgs, length) + joiner(tense, length) + doing
+ @updateState(txt)
+
+ updateState: (txt) ->
+ if @getFailing()
+ @$body.addClass 'health-fail'
+ @$body.removeClass 'health-recent-fail'
+ @$failText.text txt
+ @$body.css 'margin-top', @$failWrapper.outerHeight()
+ else if @getRecentFailing()
+ @$body.removeClass 'health-fail'
+ @$body.addClass 'health-recent-fail'
+ @$failText.text txt
+ @$body.css 'margin-top', @$failWrapper.outerHeight()
+ else
+ @$body.removeClass 'health-recent-fail health-fail'
+ @$failText.text ''
+ @$body.css 'margin-top', 0
+
+ getFailing: () ->
+ result = []
+
+ $.each _cache, (item) =>
+ if @get(item) == 'FAILURE'
+ result.push(item)
+
+ if result.length > 0
+ return result
+ else
+ return false
+
+ getRecentFailing: () ->
+ result = []
+
+ $.each _cache, (item) =>
+ if @get(item) == 'RECENT FAILURE'
+ result.push(item)
+
+ if result.length > 0
+ return result
+ else
+ return false
+ return Health
28 public/javascripts/songs.coffee
@@ -0,0 +1,28 @@
+# Songs.js
+# returns App.Songs for visualising currently playing songs.
+
+window.App = {} unless window.App
+
+App.Songs =
+ init: (socketObject) ->
+ $('div.now-playing').removeClass('hidden')
+ @$nowPlaying = $ 'div.now-playing marquee'
+ @socket = socketObject
+
+ @parseResponse(false)
+ @listen()
+
+ listen: ->
+ @socket.on 'lastfm', (msg) => @parseResponse(msg)
+
+ stopListening: () ->
+ @socket.off 'lastfm'
+
+ parseResponse: (msg) ->
+ if msg[0]
+ text = "\u266b #{msg[0].artist} - #{msg[0].name} \u266b"
+ else
+ text = 'Nothing playing... quick, get some tunes on!'
+ @$nowPlaying.text text
+ return
+
1  public/javascripts/vendor/jquery.tmpl.min.js
@@ -0,0 +1 @@
+(function(a){var r=a.fn.domManip,d="_tmplitem",q=/^[^<]*(<[\w\W]+>)[^>]*$|\{\{\! /,b={},f={},e,p={key:0,data:{}},h=0,c=0,l=[];function g(e,d,g,i){var c={data:i||(d?d.data:{}),_wrap:d?d._wrap:null,tmpl:null,parent:d||null,nodes:[],calls:u,nest:w,wrap:x,html:v,update:t};e&&a.extend(c,e,{nodes:[],parent:d});if(g){c.tmpl=g;c._ctnt=c._ctnt||c.tmpl(a,c);c.key=++h;(l.length?f:b)[h]=c}return c}a.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(f,d){a.fn[f]=function(n){var g=[],i=a(n),k,h,m,l,j=this.length===1&&this[0].parentNode;e=b||{};if(j&&j.nodeType===11&&j.childNodes.length===1&&i.length===1){i[d](this[0]);g=this}else{for(h=0,m=i.length;h<m;h++){c=h;k=(h>0?this.clone(true):this).get();a.fn[d].apply(a(i[h]),k);g=g.concat(k)}c=0;g=this.pushStack(g,f,i.selector)}l=e;e=null;a.tmpl.complete(l);return g}});a.fn.extend({tmpl:function(d,c,b){return a.tmpl(this[0],d,c,b)},tmplItem:function(){return a.tmplItem(this[0])},template:function(b){return a.template(b,this[0])},domManip:function(d,l,j){if(d[0]&&d[0].nodeType){var f=a.makeArray(arguments),g=d.length,i=0,h;while(i<g&&!(h=a.data(d[i++],"tmplItem")));if(g>1)f[0]=[a.makeArray(d)];if(h&&c)f[2]=function(b){a.tmpl.afterManip(this,b,j)};r.apply(this,f)}else r.apply(this,arguments);c=0;!e&&a.tmpl.complete(b);return this}});a.extend({tmpl:function(d,h,e,c){var j,k=!c;if(k){c=p;d=a.template[d]||a.template(null,d);f={}}else if(!d){d=c.tmpl;b[c.key]=c;c.nodes=[];c.wrapped&&n(c,c.wrapped);return a(i(c,null,c.tmpl(a,c)))}if(!d)return[];if(typeof h==="function")h=h.call(c||{});e&&e.wrapped&&n(e,e.wrapped);j=a.isArray(h)?a.map(h,function(a){return a?g(e,c,d,a):null}):[g(e,c,d,h)];return k?a(i(c,null,j)):j},tmplItem:function(b){var c;if(b instanceof a)b=b[0];while(b&&b.nodeType===1&&!(c=a.data(b,"tmplItem"))&&(b=b.parentNode));return c||p},template:function(c,b){if(b){if(typeof b==="string")b=o(b);else if(b instanceof a)b=b[0]||{};if(b.nodeType)b=a.data(b,"tmpl")||a.data(b,"tmpl",o(b.innerHTML));return typeof c==="string"?(a.template[c]=b):b}return c?typeof c!=="string"?a.template(null,c):a.template[c]||a.template(null,q.test(c)?c:a(c)):null},encode:function(a){return(""+a).split("<").join("&lt;").split(">").join("&gt;").split('"').join("&#34;").split("'").join("&#39;")}});a.extend(a.tmpl,{tag:{tmpl:{_default:{$2:"null"},open:"if($notnull_1){_=_.concat($item.nest($1,$2));}"},wrap:{_default:{$2:"null"},open:"$item.calls(_,$1,$2);_=[];",close:"call=$item.calls();_=call._.concat($item.wrap(call,_));"},each:{_default:{$2:"$index, $value"},open:"if($notnull_1){$.each($1a,function($2){with(this){",close:"}});}"},"if":{open:"if(($notnull_1) && $1a){",close:"}"},"else":{_default:{$1:"true"},open:"}else if(($notnull_1) && $1a){"},html:{open:"if($notnull_1){_.push($1a);}"},"=":{_default:{$1:"$data"},open:"if($notnull_1){_.push($.encode($1a));}"},"!":{open:""}},complete:function(){b={}},afterManip:function(f,b,d){var e=b.nodeType===11?a.makeArray(b.childNodes):b.nodeType===1?[b]:[];d.call(f,b);m(e);c++}});function i(e,g,f){var b,c=f?a.map(f,function(a){return typeof a==="string"?e.key?a.replace(/(<\w+)(?=[\s>])(?![^>]*_tmplitem)([^>]*)/g,"$1 "+d+'="'+e.key+'" $2'):a:i(a,e,a._ctnt)}):e;if(g)return c;c=c.join("");c.replace(/^\s*([^<\s][^<]*)?(<[\w\W]+>)([^>]*[^>\s])?\s*$/,function(f,c,e,d){b=a(e).get();m(b);if(c)b=j(c).concat(b);if(d)b=b.concat(j(d))});return b?b:j(c)}function j(c){var b=document.createElement("div");b.innerHTML=c;return a.makeArray(b.childNodes)}function o(b){return new Function("jQuery","$item","var $=jQuery,call,_=[],$data=$item.data;with($data){_.push('"+a.trim(b).replace(/([\\'])/g,"\\$1").replace(/[\r\t\n]/g," ").replace(/\$\{([^\}]*)\}/g,"{{= $1}}").replace(/\{\{(\/?)(\w+|.)(?:\(((?:[^\}]|\}(?!\}))*?)?\))?(?:\s+(.*?)?)?(\(((?:[^\}]|\}(?!\}))*?)\))?\s*\}\}/g,function(m,l,j,d,b,c,e){var i=a.tmpl.tag[j],h,f,g;if(!i)throw"Template command not found: "+j;h=i._default||[];if(c&&!/\w$/.test(b)){b+=c;c=""}if(b){b=k(b);e=e?","+k(e)+")":c?")":"";f=c?b.indexOf(".")>-1?b+c:"("+b+").call($item"+e:b;g=c?f:"(typeof("+b+")==='function'?("+b+").call($item):("+b+"))"}else g=f=h.$1||"null";d=k(d);return"');"+i[l?"close":"open"].split("$notnull_1").join(b?"typeof("+b+")!=='undefined' && ("+b+")!=null":"true").split("$1a").join(g).split("$1").join(f).split("$2").join(d?d.replace(/\s*([^\(]+)\s*(\((.*?)\))?/g,function(d,c,b,a){a=a?","+a+")":b?")":"";return a?"("+c+").call($item"+a:d}):h.$2||"")+"_.push('"})+"');}return _;")}function n(c,b){c._wrap=i(c,true,a.isArray(b)?b:[q.test(b)?b:a(b).html()]).join("")}function k(a){return a?a.replace(/\\'/g,"'").replace(/\\\\/g,"\\"):null}function s(b){var a=document.createElement("div");a.appendChild(b.cloneNode(true));return a.innerHTML}function m(o){var n="_"+c,k,j,l={},e,p,i;for(e=0,p=o.length;e<p;e++){if((k=o[e]).nodeType!==1)continue;j=k.getElementsByTagName("*");for(i=j.length-1;i>=0;i--)m(j[i]);m(k)}function m(j){var p,i=j,k,e,m;if(m=j.getAttribute(d)){while(i.parentNode&&(i=i.parentNode).nodeType===1&&!(p=i.getAttribute(d)));if(p!==m){i=i.parentNode?i.nodeType===11?0:i.getAttribute(d)||0:0;if(!(e=b[m])){e=f[m];e=g(e,b[i]||f[i],null,true);e.key=++h;b[h]=e}c&&o(m)}j.removeAttribute(d)}else if(c&&(e=a.data(j,"tmplItem"))){o(e.key);b[e.key]=e;i=a.data(j.parentNode,"tmplItem");i=i?i.key:0}if(e){k=e;while(k&&k.key!=i){k.nodes.push(j);k=k.parent}delete e._ctnt;delete e._wrap;a.data(j,"tmplItem",e)}function o(a){a=a+n;e=l[a]=l[a]||g(e,b[e.parent.key+n]||e.parent,null,true)}}}function u(a,d,c,b){if(!a)return l.pop();l.push({_:a,tmpl:d,item:this,data:c,options:b})}function w(d,c,b){return a.tmpl(a.template(d),c,b,this)}function x(b,d){var c=b.options||{};c.wrapped=d;return a.tmpl(a.template(b.tmpl),b.data,c,b.item)}function v(d,c){var b=this._wrap;return a.map(a(a.isArray(b)?b.join(""):b).filter(d||"*"),function(a){return c?a.innerText||a.textContent:a.outerHTML||s(a)})}function t(){var b=this.nodes;a.tmpl(null,null,null,this).insertBefore(b[0]);a(b).remove()}})(jQuery)
2  public/javascripts/vendor/moment.min.js
@@ -0,0 +1,2 @@
+/* Moment.js | version : 1.1.1 | author : Tim Wood | license : MIT */
+(function(a,b){function k(a,b){var c=a+"";while(c.length<b)c="0"+c;return c}function l(b,c,d,e){var f=typeof c=="string",g=f?{}:c,h,i,j;return f&&e&&(g[c]=e),h=(g.ms||g.milliseconds||0)+(g.s||g.seconds||0)*1e3+(g.m||g.minutes||0)*6e4+(g.h||g.hours||0)*36e5+(g.d||g.days||0)*864e5+(g.w||g.weeks||0)*6048e5,i=(g.M||g.months||0)+(g.y||g.years||0)*12,h&&b.setTime(+b+h*d),i&&(j=b.getDate(),b.setDate(1),b.setMonth(b.getMonth()+i*d),b.setDate(Math.min((new a(b.getFullYear(),b.getMonth()+1,0)).getDate(),j))),b}function m(a){return Object.prototype.toString.call(a)==="[object Array]"}function n(b){return new a(b[0],b[1]||0,b[2]||1,b[3]||0,b[4]||0,b[5]||0,b[6]||0)}function o(b,d){function r(d){var m,s;switch(d){case"M":return e+1;case"Mo":return e+1+q(e+1);case"MM":return k(e+1,2);case"MMM":return c.monthsShort[e];case"MMMM":return c.months[e];case"D":return f;case"Do":return f+q(f);case"DD":return k(f,2);case"DDD":return m=new a(g,e,f),s=new a(g,0,1),~~((m-s)/864e5+1.5);case"DDDo":return m=r("DDD"),m+q(m);case"DDDD":return k(r("DDD"),3);case"d":return h;case"do":return h+q(h);case"ddd":return c.weekdaysShort[h];case"dddd":return c.weekdays[h];case"w":return m=new a(g,e,f-h+5),s=new a(m.getFullYear(),0,4),~~((m-s)/864e5/7+1.5);case"wo":return m=r("w"),m+q(m);case"ww":return k(r("w"),2);case"YY":return k(g%100,2);case"YYYY":return g;case"a":return i>11?"pm":"am";case"A":return i>11?"PM":"AM";case"H":return i;case"HH":return k(i,2);case"h":return i%12||12;case"hh":return k(i%12||12,2);case"m":return j;case"mm":return k(j,2);case"s":return l;case"ss":return k(l,2);case"zz":case"z":return(b.toString().match(p)||[""])[0].replace(n,"");case"L":case"LL":case"LLL":case"LLLL":return o(b,c.longDateFormat[d]);default:return d.replace("\\","")}}var e=b.getMonth(),f=b.getDate(),g=b.getFullYear(),h=b.getDay(),i=b.getHours(),j=b.getMinutes(),l=b.getSeconds(),m=/(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|dddd?|do?|w[o|w]?|YYYY|YY|a|A|hh?|HH?|mm?|ss?|zz?|LL?L?L?)/g,n=/[^A-Z]/g,p=/\([A-Za-z ]+\)|:[0-9]{2} [A-Z]{3} /g,q=c.ordinal;return d.replace(m,r)}function p(a,b){function j(a,b){switch(a){case"M":case"MM":c[1]=~~b-1;break;case"D":case"DD":case"DDD":case"DDDD":c[2]=~~b;break;case"YY":b=~~b,c[0]=b+(b>70?1900:2e3);break;case"YYYY":c[0]=~~b;break;case"a":case"A":i=b.toLowerCase()==="pm";break;case"H":case"HH":case"h":case"hh":c[3]=~~b;break;case"m":case"mm":c[4]=~~b;break;case"s":case"ss":c[5]=~~b}}var c=[0],d=/(\\)?(MM?|DD?D?D?|YYYY|YY|a|A|hh?|HH?|mm?|ss?)/g,e=/(\\)?([0-9]+|am|pm)/gi,f=a.match(e),g=b.match(d),h,i;for(h=0;h<g.length;h++)j(g[h],f[h]);return i&&c[3]<12&&(c[3]+=12),n(c)}function q(a,b){var c=Math.min(a.length,b.length),d=Math.abs(a.length-b.length),e=0,f;for(f=0;f<c;f++)~~a[f]!==~~b[f]&&e++;return e+d}function r(a,b){var c,d=/(\\)?([0-9]+|am|pm)/gi,e=a.match(d),f=[],g=99,h,i,j;for(h=0;h<b.length;h++)i=p(a,b[h]),j=q(e,o(i,b[h]).match(d)),j<g&&(g=j,c=i);return c}function s(a){this._d=a}function t(a,b){return c.relativeTime[a].replace(/%d/i,b||1)}function u(a){var b=Math.abs(a)/1e3,c=b/60,e=c/60,f=e/24,g=f/365;return b<45&&t("s",d(b))||d(c)===1&&t("m")||c<45&&t("mm",d(c))||d(e)===1&&t("h")||e<22&&t("hh",d(e))||d(f)===1&&t("d")||f<=25&&t("dd",d(f))||f<=45&&t("M")||f<345&&t("MM",d(f/30))||d(g)===1&&t("y")||t("yy",d(g))}function v(a,b){c.fn[a]=function(a){return a!=null?(this._d["set"+b](a),this):this._d["get"+b]()}}var c,d=Math.round,e={},f=typeof module!="undefined",g="months|monthsShort|weekdays|weekdaysShort|longDateFormat|relativeTime|ordinal".split("|"),h,i="1.1.1",j="Month|Date|Hours|Minutes|Seconds".split("|");c=function(c,d){var e;return c&&c._d instanceof a?e=c._d:d?m(d)?e=r(c,d):e=p(c,d):e=c===b?new a:c instanceof a?c:m(c)?n(c):new a(c),new s(e)},c.version=i,c.lang=function(a,b){var d,h,i;b&&(e[a]=b);if(e[a])for(d=0;d<g.length;d++)h=g[d],c[h]=e[a][h]||c[h];else f&&(i=require("./lang/"+a),c.lang(a,i))},c.lang("en",{months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),monthsShort:"Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),weekdaysShort:"Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),longDateFormat:{L:"MM/DD/YYYY",LL:"MMMM D YYYY",LLL:"MMMM D YYYY h:mm A",LLLL:"dddd, MMMM D YYYY h:mm A"},relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},ordinal:function(a){var b=a%10;return~~(a%100/10)===1?"th":b===1?"st":b===2?"nd":b===3?"rd":"th"}}),c.fn=s.prototype={valueOf:function(){return+this._d},"native":function(){return this._d},format:function(a){return o(this._d,a)},add:function(a,b){return this._d=l(this._d,a,1,b),this},subtract:function(a,b){return this._d=l(this._d,a,-1,b),this},diff:function(a,b,e){var f=c(a),g=this._d-f._d,h=this.year()-f.year(),i=this.month()-f.month(),j=this.day()-f.day(),k;return b==="months"?k=h*12+i+j/30:b==="years"?k=h+i/12:k=b==="seconds"?g/1e3:b==="minutes"?g/6e4:b==="hours"?g/36e5:b==="days"?g/864e5:b==="weeks"?g/6048e5:b==="days"?g/3600:g,e?k:d(k)},from:function(a,b){var d=this.diff(a),e=c.relativeTime,f=u(d);return b?f:(d<=0?e.past:e.future).replace(/%s/i,f)},fromNow:function(a){return this.from(c(),a)},isLeapYear:function(){var a=this._d.getFullYear();return a%4===0&&a%100!==0||a%400===0}};for(h=0;h<j.length;h++)v(j[h].toLowerCase(),j[h]);v("year","FullYear"),c.fn.day=function(){return this._d.getDay()},f&&(module.exports=c),typeof window!="undefined"&&(window.moment=c)})(Date)
182 public/stylesheets/application.css
@@ -0,0 +1,182 @@
+* {
+ padding: 0;
+ margin: 0;
+}
+body {
+ background: #efefef;
+ color: #333;
+ font: 85%/1.25 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ overflow: hidden;
+ -webkit-transition: background,margin 0.8s ease-out;
+}
+
+/*** Layout > Wrapper ***/
+div#wrapper {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ text-align: center;
+}
+
+/*** Objects > Messages ***/
+.health-fail div.fail-wrapper,
+.health-recent-fail div.fail-wrapper {
+ display: block;
+ -webkit-animation-name: fadeInAndDown;
+ -webkit-animation-delay: 0s;
+ -webkit-animation-duration: 0.8s;
+ padding:0.2em 2em;
+ margin-bottom: 18px;
+ color: #404040;
+ background-color: #eedc94;
+ background-repeat: repeat-x;
+ background-image: -khtml-gradient(linear, left top, left bottom, from(#fceec1), to(#eedc94));
+ background-image: -moz-linear-gradient(top, #fceec1, #eedc94);
+ background-image: -ms-linear-gradient(top, #fceec1, #eedc94);
+ background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #fceec1), color-stop(100%, #eedc94));
+ background-image: -webkit-linear-gradient(top, #fceec1, #eedc94);
+ background-image: -o-linear-gradient(top, #fceec1, #eedc94);
+ background-image: linear-gradient(top, #fceec1, #eedc94);
+ text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+ border-color: #eedc94 #eedc94 #e4c652;
+ border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ border-width: 1px;
+ border-style: solid;
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
+ -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
+}
+body.health-fail div.fail-wrapper{
+ color:#fff;
+ background-color: #c43c35;
+ background-repeat: repeat-x;
+ background-image: -khtml-gradient(linear, left top, left bottom, from(#ee5f5b), to(#c43c35));
+ background-image: -moz-linear-gradient(top, #ee5f5b, #c43c35);
+ background-image: -ms-linear-gradient(top, #ee5f5b, #c43c35);
+ background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #ee5f5b), color-stop(100%, #c43c35));
+ background-image: -webkit-linear-gradient(top, #ee5f5b, #c43c35);
+ background-image: -o-linear-gradient(top, #ee5f5b, #c43c35);
+ background-image: linear-gradient(top, #ee5f5b, #c43c35);
+ text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+ border-color: #c43c35 #c43c35 #882a25;
+ border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
+}
+div.message-wrapper {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ display:none;
+ padding: 0.3em 1.7em;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ font-size: 3.5em;
+ background: #555555; /* Old browsers */
+ background: -moz-linear-gradient(top, #555555 0%, #393939 50%, #333333 50%, #111111 100%); /* FF3.6+ */
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#555555), color-stop(50%,#393939), color-stop(50%,#333333), color-stop(100%,#111111)); /* Chrome,Safari4+ */
+ background: -webkit-linear-gradient(top, #555555 0%,#393939 50%,#333333 50%,#111111 100%); /* Chrome10+,Safari5.1+ */
+ background: -o-linear-gradient(top, #555555 0%,#393939 50%,#333333 50%,#111111 100%); /* Opera11.10+ */
+ background: -ms-linear-gradient(top, #555555 0%,#393939 50%,#333333 50%,#111111 100%); /* IE10+ */
+ background: linear-gradient(top, #555555 0%,#393939 50%,#333333 50%,#111111 100%); /* W3C */
+ color: #fff;
+ z-index:2;
+}
+
+/*** Objects > Commit ***/
+div.commit{
+ width: 95%;
+ margin: 20px auto;
+ text-align: left;
+ -webkit-animation-name: fadeInAndDown;
+ -webkit-animation-delay: 0s;
+ -webkit-animation-duration: 0.8s;
+}
+div.commit blockquote {
+ background-color: #e6e6e6;
+ background-repeat: no-repeat;
+ background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));
+ background-image: -webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ background-image: -moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);
+ background-image: -ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ background-image: -o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ background-image: linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ padding: 20px;
+ text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
+ color: #333;
+ line-height: normal;
+ border: 1px solid #ccc;
+ border-bottom-color: #bbb;
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+div.commit blockquote img{
+ float:left;
+ margin-right:2em;
+}
+div.commit blockquote p {
+ margin-left:140px;
+ font-size: 5em;
+}
+div.commit footer p {
+ font-size: 2.8em;
+}
+
+/*** Objects > Footer Gradient ***/
+div.footer-gradient {
+ position: absolute;
+ bottom: 84px;
+ width: 100%;
+ height: 10em;
+ background: -moz-linear-gradient(top, rgba(255,255,255,0) 0%, #fff 100%); /* FF3.6+ */
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,255,255,0)), color-stop(100%,#fff)); /* Chrome,Safari4+ */
+ background: -webkit-linear-gradient(top, rgba(255,255,255,0) 0%,#fff 100%); /* Chrome10+,Safari5.1+ */
+ background: -o-linear-gradient(top, rgba(255,255,255,0) 0%,#fff 100%); /* Opera11.10+ */
+ background: -ms-linear-gradient(top, rgba(255,255,255,0) 0%,#fff 100%); /* IE10+ */
+ background: linear-gradient(top, rgba(255,255,255,0) 0%,#fff 100%); /* W3C */
+}
+
+/*** Pages > Now playing ***/
+div.now-playing{
+ position:absolute;
+ bottom:0;
+ left:0;
+ right:0;
+ padding: .2em 0 0;
+ line-height:1.2;
+ z-index:2;
+ font-size: 5.4em;
+ font-weight:bold;
+ color:#fff;
+ background-color: #c43c35;
+ background-repeat: repeat-x;
+ background-image: -khtml-gradient(linear, left top, left bottom, from(#3e86cb), to(#194c81));
+ background-image: -moz-linear-gradient(top, #3e86cb, #194c81);
+ background-image: -ms-linear-gradient(top, #3e86cb, #194c81);
+ background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #3e86cb), color-stop(100%, #194c81));
+ background-image: -webkit-linear-gradient(top, #3e86cb, #194c81);
+ background-image: -o-linear-gradient(top, #3e86cb, #194c81);
+ background-image: linear-gradient(top, #3e86cb, #194c81);
+ text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+ border-color: #c43c35 #c43c35 #882a25;
+ border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
+}
+div.now-playing.hidden {
+ visibility:hidden;
+}
+
+/*** Transitions ***/
+@-webkit-keyframes fadeInAndDown {
+ from {
+ opacity: 0;
+ margin-top: -20em;
+ -webkit-animation-timing-function: ease-out;
+ }
+ 100% {
+ opacity: 1;
+ margin-top: 0em;
+ -webkit-animation-timing-function: ease-out;
+ }
+}
26 public/stylesheets/scroller.css
@@ -0,0 +1,26 @@
+body {
+ background: #000;
+}
+div#scroller {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 1440px;
+ height: 70px;
+ margin: -35px 0 0 -720px;
+ background:#000;
+ overflow:hidden;
+}
+div#scroller div {
+ width: 7px;
+ height: 7px;
+ float: left;
+ margin: 1px 1px 2px 2px;
+ background: #222;
+ -webkit-border-radius: 3px;
+ -moz-border-radius: 3px;
+ border-radius: 3px;
+}
+div#scroller div.on{
+ background: #f00;
+}
14 settings-heroku.coffee
@@ -0,0 +1,14 @@
+# Get settings from Heroku environment variables
+exports.auth =
+ enabled: process.env.AUTH_ENABLED || false
+ user: process.env.AUTH_USER || ''
+ pass: process.env.AUTH_PASS || ''
+
+exports.jenkins =
+ enabled: process.env.JENKINS_ENABLED || false
+ ip: process.env.JENKINS_IP || false
+
+exports.lastfm =
+ enabled: process.env.LASTFM_ENABLED || false
+ apiKey: process.env.LASTFM_KEY || ''
+ user: process.env.LASTFM_USER || ''
12 settings.coffee.example
@@ -0,0 +1,12 @@
+exports.auth =
+ user: '' # Basic HTTP Auth username
+ pass: '' # Basic HTTP Auth password
+
+exports.jenkins =
+ enabled: false # Enable authorisation?
+ ip: '' # Your Jenkins box IP
+
+exports.lastfm =
+ apiKey: '' # Your Last.fm API key
+ user: '' # The Last.fm username you want to follow
+ enabled: false # Show currently listening
12 views/_commit.html
@@ -0,0 +1,12 @@
+<div class="commit">
+ <blockquote class="message">
+ <img src="${image}" />
+ <p>${message}</p>
+ <footer>
+ <p>
+ ${project} - ${author}
+ <span class="time"> - ${relTime}</span>
+ </p>
+ </footer>
+ </blockquote>
+</div>
14 views/_commits.html
@@ -0,0 +1,14 @@
+{{each(i, commit) commits}}
+ <div class="commit">
+ <blockquote class="message">
+ <img src="${image}" />
+ <p>${commit.message}</p>
+ <footer>
+ <p>
+ ${commit.project} - ${commit.author}
+ <span class="time" data-time-stamp="${commit.timestamp}"> - ${moment(commit.timestamp).fromNow()}</span>
+ </p>
+ </footer>
+ </blockquote>
+ </div>
+{{/each}}
26 views/index.html
@@ -0,0 +1,26 @@
+<div class="fail-wrapper message-wrapper">
+ <h2 class="fail-message"></h2>
+</div>
+
+<div class="now-playing hidden">
+ <marquee scrollamount="4"></marquee>
+</div>
+
+<div id="wrapper">
+{{partial "commits"}}
+</div>
+
+</div>
+
+<script>
+ if(!window.App){ window.App = {}; }
+ window.App.JenkinsBootstrap = [
+ {{each(i, status) statuses}}
+ {
+ project: "${project}",
+ status: "${status}"
+ },
+ {{/each}}
+ ]
+ window.App.songsEnabled = ${songsEnabled};
+</script>
17 views/layout.html
@@ -0,0 +1,17 @@
+<!doctype html>
+<html>
+<head>
+ <title>${title}</title>
+ <link rel="stylesheet" href="/stylesheets/application.css" />
+</head>
+<body>
+{{html body}}
+<script src='https://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js'></script>
+<script src='/socket.io/socket.io.js'></script>
+<script src='/javascripts/health.js'></script>
+<script src='/javascripts/songs.js'></script>
+<script src='/javascripts/vendor/moment.min.js'></script>
+<script src='/javascripts/application.js'></script>
+</body>
+</html>
+
Please sign in to comment.
Something went wrong with that request. Please try again.