diff --git a/Gemfile b/Gemfile index 3fe9f1091..e1ac40aa9 100644 --- a/Gemfile +++ b/Gemfile @@ -58,3 +58,6 @@ gem 'simple-rss' # To use debugger # gem 'ruby-debug' +# event machine +gem 'eventmachine' +gem 'em-websocket' diff --git a/app/assets/javascripts/app/controllers/_application_controller.js.coffee b/app/assets/javascripts/app/controllers/_application_controller.js.coffee index fa835db0d..291daf466 100644 --- a/app/assets/javascripts/app/controllers/_application_controller.js.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller.js.coffee @@ -2,7 +2,8 @@ class App.Controller extends Spine.Controller # add @title methode to set title title: (name) -> - $('html head title').html( Config.product_name + ' - ' + Ti(name) ) +# $('html head title').html( Config.product_name + ' - ' + Ti(name) ) + document.title = Config.product_name + ' - ' + Ti(name) # add @notify methode to create notification notify: (data) -> @@ -545,6 +546,13 @@ class App.Controller extends Spine.Controller return newInstance + clearInterval: (interval_id) => + # check global var + if !@intervalID + @intervalID = {} + + clearInterval( @intervalID[interval_id] ) if @intervalID[interval_id] + interval: (action, interval, interval_id) => # check global var @@ -778,11 +786,14 @@ class App.ControllerModal extends App.Controller super(options) modalShow: (params) => - @el.modal({ + defaults = { backdrop: true, keyboard: true, - show: true - }) + show: true, + } + data = $.extend({}, defaults, params) + @el.modal(data) + @el.bind('hidden', => # navigate back to home page @@ -805,3 +816,17 @@ class App.ControllerModal extends App.Controller submit: (e) => e.preventDefault() @log 'You need to implement your own "submit" method!' + +class App.ErrorModal extends App.ControllerModal + constructor: -> + super + @render() + + render: -> + @html App.view('error')( + message: @message + ) + @modalShow( + backdrop: false, + keyboard: false, + ) diff --git a/app/assets/javascripts/app/controllers/_dashboard/activity_stream.js.coffee b/app/assets/javascripts/app/controllers/_dashboard/activity_stream.js.coffee index 4df1ce12e..9ec2ac570 100644 --- a/app/assets/javascripts/app/controllers/_dashboard/activity_stream.js.coffee +++ b/app/assets/javascripts/app/controllers/_dashboard/activity_stream.js.coffee @@ -9,25 +9,29 @@ class App.DashboardActivityStream extends App.Controller @items = [] # refresh list ever 140 sec. - @interval( @fetch, 1400000, 'dashboard_activity_stream' ) - +# @interval( @fetch, 1400000, 'dashboard_activity_stream' ) + @fetch() + Spine.bind 'activity_stream_rebuild', (data) => + @log 'a_stream', data + @fetch() + fetch: => # use cache of first page - if window.LastRefresh[ 'dashboard_activity_stream' ] - @render( window.LastRefresh[ 'dashboard_activity_stream' ] ) + if window.LastRefresh[ 'activity_stream' ] + @load( window.LastRefresh[ 'activity_stream' ] ) - # get data - App.Com.ajax( - id: 'dashoard_activity_stream', - type: 'GET', - url: '/activity_stream', - data: { - limit: @limit, - } - processData: true, - success: @load - ) +# # get data +# App.Com.ajax( +# id: 'dashoard_activity_stream', +# type: 'GET', +# url: '/activity_stream', +# data: { +# limit: @limit, +# } +# processData: true, +# success: @load +# ) load: (data) => items = data.activity_stream diff --git a/app/assets/javascripts/app/controllers/_dashboard/rss.js.coffee b/app/assets/javascripts/app/controllers/_dashboard/rss.js.coffee index 3b5ec613c..111cdaf07 100644 --- a/app/assets/javascripts/app/controllers/_dashboard/rss.js.coffee +++ b/app/assets/javascripts/app/controllers/_dashboard/rss.js.coffee @@ -3,36 +3,18 @@ $ = jQuery.sub() class App.DashboardRss extends App.Controller constructor: -> super - - - # refresh list ever 600 sec. - @interval( @fetch, 6000000, 'dashboard_rss' ) - fetch: => + # refresh list ever 600 sec. + Spine.bind 'rss_rebuild', (data) => + @load(data) # use cache of first page if window.LastRefresh[ 'dashboard_rss' ] - @render( window.LastRefresh[ 'dashboard_rss' ] ) - - # get data - App.Com.ajax( - id: 'dashboard_rss', - type: 'GET', - url: '/rss_fetch', - data: { - limit: @limit, - url: @url, - } - processData: true, - success: @load - ) + @load( window.LastRefresh[ 'dashboard_rss' ] ) load: (data) => items = data.items || [] - - # set cache - window.LastRefresh[ 'dashboard_rss' ] = items - + @head = data.head || '?' @render(items) render: (items) -> diff --git a/app/assets/javascripts/app/controllers/agent_ticket_view.js.coffee b/app/assets/javascripts/app/controllers/agent_ticket_view.js.coffee index 45a29b77d..fa592c37a 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_view.js.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_view.js.coffee @@ -271,7 +271,7 @@ class Index extends App.Controller @tickets = [] # rebuild navbar with updated ticket count of overviews - Spine.trigger 'navupdate_remote' + App.WebSocket.send( event: 'navupdate_ticket_overview' ) # fetch overview data again @fetch() diff --git a/app/assets/javascripts/app/controllers/getting_started.js.coffee b/app/assets/javascripts/app/controllers/getting_started.js.coffee index 185aad2d2..cd2c78c8a 100644 --- a/app/assets/javascripts/app/controllers/getting_started.js.coffee +++ b/app/assets/javascripts/app/controllers/getting_started.js.coffee @@ -9,13 +9,12 @@ class Index extends App.Controller constructor: -> super - + # set title @title 'Get Started' @navupdate '#get_started' - + @master_user = 0 - # @render() @fetch() @@ -43,7 +42,7 @@ class Index extends App.Controller ) render: -> - + # check authentication, redirect to login if master user already exists if !@master_user && !@authenticate() @navigate '#login' @@ -59,7 +58,7 @@ class Index extends App.Controller submit: (e) -> e.preventDefault() @params = @formParam(e.target) - + # if no login is given, use emails as fallback if !@params.login && @params.email @params.login = @params.email @@ -102,28 +101,24 @@ class Index extends App.Controller # rerender page @render() - # error: => # @modalHide() ) - relogin: (data, status, xhr) => @log 'login:success', data # login check App.Auth.loginCheck() - + # add notify Spine.trigger 'notify:removeall' # @notify # type: 'success', # msg: 'Thanks for joining. Email sent to "' + @params.email + '". Please verify your email address.' - + @el.find('.master_user').fadeOut('slow', => @el.find('.agent_user').fadeIn() ) - - Config.Routes['getting_started'] = Index diff --git a/app/assets/javascripts/app/controllers/login.js.coffee b/app/assets/javascripts/app/controllers/login.js.coffee index 4c15d2319..2a20aa0e4 100644 --- a/app/assets/javascripts/app/controllers/login.js.coffee +++ b/app/assets/javascripts/app/controllers/login.js.coffee @@ -3,7 +3,7 @@ $ = jQuery.sub() class Index extends App.Controller events: 'submit #login': 'login', - + constructor: -> super @title 'Sign in' @@ -37,12 +37,12 @@ class Index extends App.Controller for key, provider of auth_provider_all if Config[provider.config] is true || Config[provider.config] is "true" auth_providers.push provider - + @html App.view('login')( item: data, auth_providers: auth_providers, ) - + # set focus if !$(@el).find('[name="username"]').val() $(@el).find('[name="username"]').focus() @@ -55,17 +55,17 @@ class Index extends App.Controller login: (e) -> e.preventDefault() params = @formParam(e.target) - + # remember username @username = params['username'] - + # session create with login/password App.Auth.login( data: params, success: @success error: @error, ) - + success: (data, status, xhr) => @log 'login:success', data @@ -85,10 +85,14 @@ class Index extends App.Controller for key, value of data.default_collections App[key].refresh( value, options: { clear: true } ) + # rebuild navbar with user data Spine.trigger 'navrebuild', data.session - # rebuild navbar with updated ticket count of overviews - Spine.trigger 'navupdate_remote' + # update websocked auth info + App.WebSocket.auth() + + # rebuild navbar with ticket overview counter + App.WebSocket.send( event: 'navupdate_ticket_overview' ) # add notify Spine.trigger 'notify:removeall' @@ -100,7 +104,7 @@ class Index extends App.Controller # redirect to # if window.Config['requested_url'] isnt '' @navigate window.Config['requested_url'] - + # reset window.Config['requested_url'] = '' else @@ -108,14 +112,14 @@ class Index extends App.Controller error: (xhr, statusText, error) => console.log 'login:error' - + # add notify Spine.trigger 'notify:removeall' Spine.trigger 'notify', { type: 'error', msg: T('Wrong Username and Password combination.'), } - + # rerender login page @render( username: @username diff --git a/app/assets/javascripts/app/controllers/logout.js.coffee b/app/assets/javascripts/app/controllers/logout.js.coffee index ef6ef6d98..e3ff8aa7e 100644 --- a/app/assets/javascripts/app/controllers/logout.js.coffee +++ b/app/assets/javascripts/app/controllers/logout.js.coffee @@ -4,11 +4,10 @@ class Index extends Spine.Controller constructor: -> super - @signout() - + signout: -> - + # remove remote session App.Auth.logout() @@ -17,7 +16,7 @@ class Index extends Spine.Controller window.Session = {} @log 'Session', window.Session Spine.trigger 'navrebuild' - + # redirect to login @navigate 'login' diff --git a/app/assets/javascripts/app/controllers/navigation.js.coffee b/app/assets/javascripts/app/controllers/navigation.js.coffee index f71c179f2..dcf281cf1 100644 --- a/app/assets/javascripts/app/controllers/navigation.js.coffee +++ b/app/assets/javascripts/app/controllers/navigation.js.coffee @@ -5,36 +5,47 @@ class App.Navigation extends App.Controller super @log 'nav...' @render() - - sync_ticket_overview = => - @interval( @ticket_overview, 30000, 'nav_ticket_overview' ) - sync_recent_viewed = => - @interval( @recent_viewed, 40000, 'nav_recent_viewed' ) - + # update selected item Spine.bind 'navupdate', (data) => @update(arguments[0]) - + + # rebuild nav bar with given user data Spine.bind 'navrebuild', (user) => @log 'navbarrebuild', user @render(user) - Spine.bind 'navupdate_remote', (user) => - @log 'navupdate_remote' - @delay( sync_ticket_overview, 500 ) - @delay( sync_recent_viewed, 1000 ) - - # rerender if new overview data is there - @delay( sync_ticket_overview, 800 ) - @delay( sync_recent_viewed, 1000 ) - + # rebuild ticket overview data + Spine.bind 'navupdate_ticket_overview', (data) => + @ticket_overview_build(data) + + # rebuild recent viewd data + Spine.bind 'update_recent_viewed', (data) => + @recent_viewed_build(data) + render: (user) -> nav_left = @getItems( navbar: Config.NavBar ) nav_right = @getItems( navbar: Config.NavBarRight ) + # get open tabs to repopen on rerender + open_tab = {} + @el.find('.open').children('a').each( (i,d) => + href = $(d).attr('href') + open_tab[href] = true + ) + + # get active tabs to reactivate on rerender + active_tab = {} + @el.find('.active').children('a').each( (i,d) => + href = $(d).attr('href') + active_tab[href] = true + ) + @html App.view('navigation')( navbar_left: nav_left, navbar_right: nav_right, + open_tab: open_tab, + active_tab: active_tab, user: user, ) @@ -119,118 +130,65 @@ class App.Navigation extends App.Controller update: (url) => @el.find('li').removeClass('active') -# if url isnt '#' @el.find("[href=\"#{url}\"]").parents('li').addClass('active') -# @el.find("[href*=\"#{url}\"]").parents('li').addClass('active') - # get data - ticket_overview: => + ticket_overview_build: (data) => - # do no load and rerender if sub-menu is open - open = @el.find('.open').val() - if open isnt undefined - return - - # do no load and rerender if user is not logged in - if !window.Session['id'] - return + # remove old views + for key of Config.NavBar + if Config.NavBar[key].parent is '#ticket/view' + delete Config.NavBar[key] - # only of lod request is already done + # add new views + for item in data + Config.NavBar['TicketOverview' + item.url] = { + prio: item.prio, + parent: '#ticket/view', + name: item.name, + count: item.count, + target: '#ticket/view/' + item.url, + role: ['Agent'], + } - if !@req_overview - @req_overview = App.Com.ajax( - id: 'navbar_ticket_overviews', - type: 'GET', - url: '/ticket_overviews', - data: {}, - processData: true, - success: (data, status, xhr) => - - # remove old views - for key of Config.NavBar - if Config.NavBar[key].parent is '#ticket/view' - delete Config.NavBar[key] - - # add new views - for item in data - Config.NavBar['TicketOverview' + item.url] = { - prio: item.prio, - parent: '#ticket/view', - name: item.name, - count: item.count, - target: '#ticket/view/' + item.url, - role: ['Agent'], - } - - # rebuild navbar - Spine.trigger 'navrebuild', window.Session + # rebuild navbar + Spine.trigger 'navrebuild', window.Session - # reset ajax call - @req_overview = undefined - ) + recent_viewed_build: (data) => - # get data - recent_viewed: => + items = data.recent_viewed - # do no load and rerender if sub-menu is open - open = @el.find('.open').val() - if open isnt undefined - return - - # do no load and rerender if user is not logged in - if !window.Session['id'] - return + # load user collection + @loadCollection( type: 'User', data: data.users ) - # only of lod request is already done - if !@req_recent_viewed - @req_recent_viewed = App.Com.ajax( - id: 'navbar_recent_viewed', - type: 'GET', - url: '/recent_viewed', - data: { - limit: 5, - } - processData: true, - success: (data, status, xhr) => - - items = data.recent_viewed - - # load user collection - @loadCollection( type: 'User', data: data.users ) - - # load ticket collection - @loadCollection( type: 'Ticket', data: data.tickets ) - - # remove old views - for key of Config.NavBarRight - if Config.NavBarRight[key].parent is '#current_user' - part = Config.NavBarRight[key].target.split '::' - if part is 'RecendViewed' - delete Config.NavBarRight[key] - - # add new views - prio = 5000 - for item in items - divider = false - navheader = false - if prio is 5000 - divider = true - navheader = 'Recent Viewed' - ticket = App.Ticket.find(item.o_id) - prio++ - Config.NavBarRight['RecendViewed::' + ticket.id] = { - prio: prio, - parent: '#current_user', - name: item.history_object.name + ' (' + ticket.title + ')', - target: '#ticket/zoom/' + ticket.id, - role: ['Agent'], - divider: divider, - navheader: navheader - } - - # rebuild navbar - Spine.trigger 'navrebuild', window.Session + # load ticket collection + @loadCollection( type: 'Ticket', data: data.tickets ) - # reset ajax call - @req_recent_viewed = undefined - ) + # remove old views + for key of Config.NavBarRight + if Config.NavBarRight[key].parent is '#current_user' + part = key.split '::' + if part[0] is 'RecendViewed' + delete Config.NavBarRight[key] + + # add new views + prio = 8000 + for item in items + divider = false + navheader = false + if prio is 8000 + divider = true + navheader = 'Recent Viewed' + ticket = App.Ticket.find(item.o_id) + prio++ + Config.NavBarRight['RecendViewed::' + ticket.id + '-' + prio ] = { + prio: prio, + parent: '#current_user', + name: item.history_object.name + ' (' + ticket.title + ')', + target: '#ticket/zoom/' + ticket.id, + role: ['Agent'], + divider: divider, + navheader: navheader + } + + # rebuild navbar + Spine.trigger 'navrebuild', window.Session \ No newline at end of file diff --git a/app/assets/javascripts/app/index.js.coffee b/app/assets/javascripts/app/index.js.coffee index 88d8ac55b..b9edf83cc 100644 --- a/app/assets/javascripts/app/index.js.coffee +++ b/app/assets/javascripts/app/index.js.coffee @@ -11,6 +11,7 @@ #= require ./lib/bootstrap-popover.js #= require ./lib/bootstrap-modal.js #= require ./lib/bootstrap-tab.js +#= require ./lib/bootstrap-transition.js #= require ./lib/underscore.coffee #= require ./lib/ba-linkify.js @@ -23,6 +24,7 @@ #not_used= require_tree ./lib #= require_self #= require ./lib/ajax.js.coffee +#= require ./lib/websocket.js.coffee #= require ./lib/auth.js.coffee #= require ./lib/i18n.js.coffee #= require_tree ./models @@ -57,13 +59,15 @@ Config2.set( 'a', 123) console.log '1112222', Config2.get( 'a') ### - class App.Run extends Spine.Controller constructor: -> super - @log 'RUN app'#, @ + @log 'RUN app' @el = $('#app') + # create web socket connection + App.WebSocket.connect() + # init of i18n new App.i18n @@ -84,27 +88,6 @@ class App.Run extends Spine.Controller window.Session['UISelection'] = @getSelected() + '' ) -# @ws = new WebSocket("ws://localhost:3001/"); - - # Set event handlers. -# @ws.onopen = -> -# console.log("onopen") - -# @ws.onmessage = (e) -> - # e.data contains received string. -# console.log("onmessage: " + e.data) -# eval e.data - -# Spine.bind 'ws:send', (data) => -# @log 'ws:send', data -# @ws.send(data); - -# @ws.onclose = -> -# console.log("onclose") - -# @ws.onerror = -> -# console.log("onerror") - getSelected: -> text = ''; if window.getSelection @@ -125,24 +108,35 @@ class App.Content extends Spine.Controller for route, callback of Config.Routes do (route, callback) => @route(route, (params) -> + + # remember current controller Config['ActiveController'] = route - Spine.trigger( 'ws:send', JSON.stringify( { action: 'active_controller', controller: route, params: params } ) ) + + # send current controller + params_only = {} + for i of params + if typeof params[i] isnt 'object' + params_only[i] = params[i] + App.WebSocket.send( + action: 'active_controller', + controller: route, + params: params_only, + ) # unbind in controller area @el.unbind() @el.undelegate() - + # remove waypoints $('footer').waypoint('remove') - + params.el = @el - params.auth = @auth new callback( params ) # scroll to top # window.scrollTo(0,0) ) - Spine.Route.setup() + Spine.Route.setup() window.App = App \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/auth.js.coffee b/app/assets/javascripts/app/lib/auth.js.coffee index 5296767ab..b93ca7cc8 100644 --- a/app/assets/javascripts/app/lib/auth.js.coffee +++ b/app/assets/javascripts/app/lib/auth.js.coffee @@ -34,6 +34,9 @@ class App.Auth # empty session window.Session = {} + # update websocked auth info + App.WebSocket.auth() + # rebuild navbar with new navbar items Spine.trigger 'navrebuild' @@ -50,6 +53,9 @@ class App.Auth # store user data for key, value of data.session window.Session[key] = value + + # update websocked auth info + App.WebSocket.auth() # refresh/load default collections for key, value of data.default_collections @@ -61,12 +67,14 @@ class App.Auth # rebuild navbar with updated ticket count of overviews Spine.trigger 'navupdate_remote' - error: (xhr, statusText, error) => console.log 'loginCheck:error'#, error, statusText, xhr.statusCode - + # empty session window.Session = {} + + # update websocked auth info + App.WebSocket.auth() ) @logout: -> @@ -75,4 +83,13 @@ class App.Auth id: 'logout', type: 'DELETE', url: '/signout', + success: => + + # update websocked auth info + App.WebSocket.auth() + + error: (xhr, statusText, error) => + + # update websocked auth info + App.WebSocket.auth() ) \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/bootstrap-transition.js b/app/assets/javascripts/app/lib/bootstrap-transition.js new file mode 100644 index 000000000..534182622 --- /dev/null +++ b/app/assets/javascripts/app/lib/bootstrap-transition.js @@ -0,0 +1,61 @@ +/* =================================================== + * bootstrap-transition.js v2.0.4 + * http://twitter.github.com/bootstrap/javascript.html#transitions + * =================================================== + * Copyright 2012 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================== */ + + +!function ($) { + + $(function () { + + "use strict"; // jshint ;_; + + + /* CSS TRANSITION SUPPORT (http://www.modernizr.com/) + * ======================================================= */ + + $.support.transition = (function () { + + var transitionEnd = (function () { + + var el = document.createElement('bootstrap') + , transEndEventNames = { + 'WebkitTransition' : 'webkitTransitionEnd' + , 'MozTransition' : 'transitionend' + , 'OTransition' : 'oTransitionEnd' + , 'msTransition' : 'MSTransitionEnd' + , 'transition' : 'transitionend' + } + , name + + for (name in transEndEventNames){ + if (el.style[name] !== undefined) { + return transEndEventNames[name] + } + } + + }()) + + return transitionEnd && { + end: transitionEnd + } + + })() + + }) + +}(window.jQuery); \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/websocket.js.coffee b/app/assets/javascripts/app/lib/websocket.js.coffee new file mode 100644 index 000000000..41b8dbe87 --- /dev/null +++ b/app/assets/javascripts/app/lib/websocket.js.coffee @@ -0,0 +1,114 @@ +$ = jQuery.sub() + +class App.WebSocket + _instance = undefined # Must be declared here to force the closure on the class + @connect: (args) -> # Must be a static method + if _instance == undefined + _instance ?= new _Singleton + _instance + + @send: (args) -> # Must be a static method + @connect() + _instance.send(args) + + @auth: (args) -> # Must be a static method + @connect() + _instance.auth(args) + +# The actual Singleton class +class _Singleton extends Spine.Controller + queue: [] + + constructor: (@args) -> + @connect() + + send: (data) => + console.log 'ws:send trying', data, @ws, @ws.readyState + + # A value of 0 indicates that the connection has not yet been established. + # A value of 1 indicates that the connection is established and communication is possible. + # A value of 2 indicates that the connection is going through the closing handshake. + # A value of 3 indicates that the connection has been closed or could not be opened. + if @ws.readyState is 0 + @queue.push data + else + console.log( 'ws:send', data ) + string = JSON.stringify( data ) + @ws.send(string) + + auth: (data) => + + # logon websocket + data = { + action: 'login', + session: window.Session + } + @send(data) + + close: => + @ws.close() + + connect: => +# console.log '------------ws connect....--------------' + + if !window.WebSocket + @error = new App.ErrorModal( + message: 'Sorry, no websocket support!' + ) + return + + @ws = new window.WebSocket( "ws://" + window.location.hostname + ":6042/" ) + + # Set event handlers. + @ws.onopen = => + console.log( "onopen" ) + + # close error message if exists + if @error + @error.modalHide() + @error = undefined + + @auth() + + # empty queue + for item in @queue + console.log( 'ws:send queue', item ) + @send(item) + @queue = [] + + @ws.onmessage = (e) -> + pipe = JSON.parse( e.data ) +# console.log( "ws:onmessage", pipe ) + + # go through all blocks + for item in pipe + + # fill collection + if item['collection'] + console.log( "ws:onmessage collection:" + item['collection'] ) + window.LastRefresh[ item['collection'] ] = item['data'] + + # fire event + if item['event'] + console.log( "ws:onmessage event:" + item['event'] ) + Spine.trigger( item['event'], item['data'] ) + + # bind to send messages + Spine.bind 'ws:send', (data) => + @send(data) + + @ws.onclose = (e) => + console.log( "onclose", e ) + + # show error message + if !@error + @error = new App.ErrorModal( + message: 'No connection to websocket, trying to reconnect...' + ) + + # try reconnect after 5 sec. + @delay @connect, 5000 + + @ws.onerror = -> + console.log( "onerror" ) + diff --git a/app/assets/javascripts/app/views/error.jst.eco b/app/assets/javascripts/app/views/error.jst.eco new file mode 100644 index 000000000..dd82e6cca --- /dev/null +++ b/app/assets/javascripts/app/views/error.jst.eco @@ -0,0 +1,17 @@ +