diff --git a/.gitignore b/.gitignore
index 83ff2c2..98259ed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,11 @@
.*
-apps/taptank/frameworks/
+bin/getopt_long
+bin/python_env/
+bin/beanstalkd/
+bin/openresty/
+bin/redis/
+
+tmp/
+logs/
+db/
diff --git a/CHANGELOG b/CHANGELOG
index b709f77..eb631e6 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,113 +1,136 @@
# Change Log
+### 0.8.0
+
+- CHANGES & IMPROVE:
+ - refactor all global functions, wrap they as separated modules
+ - use supervisor control all processes
+ - packages/redis: unified redis driver for NginxLua and LuaSocket
+ - packages/beanstalkd: unified beanstalkd driver for NginxLua and LuaSocket
+ - packages/mysql: unified mysql driver for NginxLua and LuaSocket
+ - packages/event: improved event binding component
+ - packages/gbc: refactor "GameBox Cloud Core" as a package
+ - packages/tests: unittests support
+ - NginxRedisLoop: subscribe any channel without more redis connections
+ - add Vagrant support
+ - upgrade all libs
+ - improved cc.class(), cc.bind(), cc.unbind()
+ - improved cc.import()
+ - remove mysql driver
+
+- BUGFIX:
+ - fix Job Worker
+
+
### 0.7.0
-- CHNAGES:
- - When user writes a new action, an property of the action named `ACCEPTED_REQUEST_TYPE` can be set. It is used for determining which type of the request can access the action.
-- IMPROVE:
- - `Job Worker` only prints job finished info in debug mode.
- - The codes of the actions of `Job Worker` can be updated dynamically.
+- CHNAGES:
+ - When user writes a new action, an property of the action named `ACCEPTED_REQUEST_TYPE` can be set. It is used for determining which type of the request can access the action.
+
+- IMPROVE:
+ - `Job Worker` only prints job finished info in debug mode.
+ - The codes of the actions of `Job Worker` can be updated dynamically.
-- BUGFIX:
- - `luasec-0.5` should be installed correctly in 32-bit linux.
- - The actions of `Job Worker` should be settled only in `worker/actinos`.
+- BUGFIX:
+ - `luasec-0.5` should be installed correctly in 32-bit linux.
+ - The actions of `Job Worker` should be settled only in `worker/actinos`.
### 0.6.0
-- CHNAGES:
- - `Quick Server` rename to `GameBox Cloud Core`, short name is `gbc-core`.
- - `start_quick_server.sh` rename to `start_server`.
- - `stop_quick_server.sh` rename to `stop_server`.
- - `status_quick_server.sh` rename to `check_server`.
- - the `conf/config.lua` option `quickserverRootPath` rename to `serverRootPath`.
+- CHNAGES:
+ - `Quick Server` rename to `GameBox Cloud Core`, short name is `gbc-core`.
+ - `start_quick_server.sh` rename to `start_server`.
+ - `stop_quick_server.sh` rename to `stop_server`.
+ - `status_quick_server.sh` rename to `check_server`.
+ - the `conf/config.lua` option `quickserverRootPath` rename to `serverRootPath`.
### 0.5.1
-- FEATURE:
- - Job Worker is finished as a module of Quick Server.
- - Job Service package is offered. User can use it to add a job easily.
+- FEATURE:
+ - Job Worker is finished as a module of Quick Server.
+ - Job Service package is offered. User can use it to add a job easily.
-- BUGFIX:
- - start/stop/status shells can exit normally, if some errors happend in progress.
- - start/stop/status shells can get the config of Quick Server in any path.
- - Monitor can show correct number of jobs in Beanstalkd.
+- BUGFIX:
+ - start/stop/status shells can exit normally, if some errors happend in progress.
+ - start/stop/status shells can get the config of Quick Server in any path.
+ - Monitor can show correct number of jobs in Beanstalkd.
-- IMPROVE:
- - In error messages, redundant paths can be stripped.
- - Quick Server supports Mac OS now.
- - start/stop/status use more colors for displaying.
- - start/stop/status can show the version and mode of Quick Server.
- - tools.sh can display the result better, even if the result is a lua table.
- - Support job worker module in shells.
- - Add lib luasec-0.5.0.
+- IMPROVE:
+ - In error messages, redundant paths can be stripped.
+ - Quick Server supports Mac OS now.
+ - start/stop/status use more colors for displaying.
+ - start/stop/status can show the version and mode of Quick Server.
+ - tools.sh can display the result better, even if the result is a lua table.
+ - Support job worker module in shells.
+ - Add lib luasec-0.5.0.
### 0.5.0
- UPGRADE:
- - upgrade luasocket to 3.0-rc1.
- - add luainspect lib.
+ - upgrade luasocket to 3.0-rc1.
+ - add luainspect lib.
- IMPROVE: make installation better, improve installation shell.
- - offer an unique install.sh instead of install_ubunutu.sh and install_centos.sh.
- - except fundamental packages from linux distro, such as "git", "zip" and some building tools, all other packages can be installed without online. This can save much time.
- - the install.sh supports parameters, ex. user can set the path of installation.
+ - offer an unique install.sh instead of install_ubunutu.sh and install_centos.sh.
+ - except fundamental packages from linux distro, such as "git", "zip" and some building tools, all other packages can be installed without online. This can save much time.
+ - the install.sh supports parameters, ex. user can set the path of installation.
- IMPROVE: offer all new wiki docs by Sphinx project in ".rst" format.
- - the new wiki can be outputed in many file formats, such as html, pdf, latex, etc.
- - it is easy to generated with make tools.
- - the wiki docs in html format is released with Quick Server, in "docs/" dir.
+ - the new wiki can be outputed in many file formats, such as html, pdf, latex, etc.
+ - it is easy to generated with make tools.
+ - the wiki docs in html format is released with Quick Server, in "docs/" dir.
- CHNAGE: almost all Quick Server codes are refactored.
- - new architecture, all modules with "Server" in name are refactored to "Connect".
- - package mechanism, all functions is offerd as package.
- - Http and WebSocket modules are refactored, inheriting from "ConnectBase".
- - new broadcast mechanism.
- - adjust options in config.lua, more simple, more better.
- - new maintain and manager shells.
- - monitor the processes of Quick Server at any time, and the status can be shown in web.
+ - new architecture, all modules with "Server" in name are refactored to "Connect".
+ - package mechanism, all functions is offerd as package.
+ - Http and WebSocket modules are refactored, inheriting from "ConnectBase".
+ - new broadcast mechanism.
+ - adjust options in config.lua, more simple, more better.
+ - new maintain and manager shells.
+ - monitor the processes of Quick Server at any time, and the status can be shown in web.
### 0.4.0
- UPGRADE: From Openresty 1.7.2.x to 1.7.7.x.
- IMPROVE: make install_ubunutu.sh better in order that user can install Quick Server convenienty, and fix some bugs in it.
- - create a symbol link for nginx, after Openresty installation.
- - Add option "--no-check-certificate" for each "wget" command.
- - "status\_quick\_server.sh" shell file should be copied to installation directory while installation is finished.
- - Change those shell tools director, move them from "/opt" to installation path.
- - Add a new shell tool named "restart\_nginx\_only.sh" in order to restart nginx processes.
- - Add a parameter for install_ubuntu.sh for specifying installation path insted of absolute path.
- - Quick Server configure files, including "redis.conf" and "nginx.conf", are modified automatically via "sed" tool.
+ - create a symbol link for nginx, after Openresty installation.
+ - Add option "--no-check-certificate" for each "wget" command.
+ - "status\_quick\_server.sh" shell file should be copied to installation directory while installation is finished.
+ - Change those shell tools director, move them from "/opt" to installation path.
+ - Add a new shell tool named "restart\_nginx\_only.sh" in order to restart nginx processes.
+ - Add a parameter for install_ubuntu.sh for specifying installation path insted of absolute path.
+ - Quick Server configure files, including "redis.conf" and "nginx.conf", are modified automatically via "sed" tool.
- FEATURE: Support plugin mechanism.
- - Add some methods into "cc" framework, "load" and "bind" etc, for package file(lua file).
- - Give two plugin examples: "Ranklist" and "ChatRoom", converted from "RanklistAction" and "ChatAction".
- - Add simple functionality tests for above plugins.
+ - Add some methods into "cc" framework, "load" and "bind" etc, for package file(lua file).
+ - Give two plugin examples: "Ranklist" and "ChatRoom", converted from "RanklistAction" and "ChatAction".
+ - Add simple functionality tests for above plugins.
- IMPROVE: Add a demo action named "HelloworldAction"
- - include a simple method "/sayhello" to show "hello world".
- - there are two other methods "addandcount" and "sayletters" to show how to write a function with plugins.
- - Add "helloworld" client which supports both html and websocket.
+ - include a simple method "/sayhello" to show "hello world".
+ - there are two other methods "addandcount" and "sayletters" to show how to write a function with plugins.
+ - Add "helloworld" client which supports both html and websocket.
- CHANGE: delete install_mac.sh. The installation of mac env will be supported in next version.
- OTHER MINOR CHANGES:
- - IMPROVE: upgrade Quick Server wiki.
- - BUGFIX: when deploying lua codes defined by user, the target directory in Quick Server shoule be created.
- - IMPROVE: The target directory in Quick Server can be configured via "luaRepoPrefix" in config.lua for deploying lua codes defined by user.
- - CHANGE: obsolete the old interface of uploading user codes.
- - IMPROVE: Add two sql files: "base.sql" and "pre_condition.sql" in conf/sql for configuring MySql.
- - CHANGE: don't need to set "root" privilege in nginx conf file.
- - IMPROVE: Add README file for each sub-dir.
- - CHANGE: change some shells which are used to encapsulate "nginx" command in "openresty/nginx".
+ - IMPROVE: upgrade Quick Server wiki.
+ - BUGFIX: when deploying lua codes defined by user, the target directory in Quick Server shoule be created.
+ - IMPROVE: The target directory in Quick Server can be configured via "luaRepoPrefix" in config.lua for deploying lua codes defined by user.
+ - CHANGE: obsolete the old interface of uploading user codes.
+ - IMPROVE: Add two sql files: "base.sql" and "pre_condition.sql" in conf/sql for configuring MySql.
+ - CHANGE: don't need to set "root" privilege in nginx conf file.
+ - IMPROVE: Add README file for each sub-dir.
+ - CHANGE: change some shells which are used to encapsulate "nginx" command in "openresty/nginx".
### 0.4.0-rc0
- FEATURE: Add an new action "SessionAction" to generate **session_id** for user.
- FEATURE: adjust many interfaces in RanklistAction.
- - Each interface calling needs checking **session_id**.
- - "Add" interface can generate a uid according to the **nickname** when user calls it first time.
- - the format of uid is "nickname+numbers" in order to keep each uid unique.
- - score, remove, getrank and getrevrank should get key from param "uid".
- - "GetRevRank" also replies score.
- - "GetRankAction" also return socre.
- - "AddAction" can return a percent to indicate "rank/total", in other words, it's the user's positiono in a ranklist.
- - Add some test cases for RanklistAction.
+ - Each interface calling needs checking **session_id**.
+ - "Add" interface can generate a uid according to the **nickname** when user calls it first time.
+ - the format of uid is "nickname+numbers" in order to keep each uid unique.
+ - score, remove, getrank and getrevrank should get key from param "uid".
+ - "GetRevRank" also replies score.
+ - "GetRankAction" also return socre.
+ - "AddAction" can return a percent to indicate "rank/total", in other words, it's the user's positiono in a ranklist.
+ - Add some test cases for RanklistAction.
- BUGFIX: if a redis command fails, it replies an int 0 not a string "0".
### 0.3.9
@@ -120,9 +143,9 @@
### 0.3.8
- FEATURE: implement chatting room, ChatAction module.
- - Add a new sub-table as the configuration for ChatAction in config.lua.
- - assign an user to a channel automatically.
- - Add some tests cases for ChatAction.
+ - Add a new sub-table as the configuration for ChatAction in config.lua.
+ - assign an user to a channel automatically.
+ - Add some tests cases for ChatAction.
- IMPROVE: remove BeginSession interface.
- IMPROVE: optimize some lua codes in the initialization of mysql and redis.
diff --git a/README.md b/README.md
index 32ab755..c85716f 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,6 @@ GameBox Cloud Core 为开发者提供一个稳定可靠,可伸缩的服务端
- [OpenResty](http://openresty.org)
- [LuaJIT](http://luajit.org)
-
- 使用 Lua 脚本语言开发服务端功能
@@ -21,7 +20,6 @@ GameBox Cloud Core 为开发者提供一个稳定可靠,可伸缩的服务端
用 Lua 脚本语言开发服务端功能还有一个巨大的好处,那就是可以和使用 Cocos2d-Lua(quick-cocos2d-x)的客户端共享大量代码。比如数据 Schema 定义、数据对象、游戏逻辑等等,都可以在客户端和服务端之间共享同一份代码。做过网络游戏的同学一定对如何保持客户端和服务端代码在数据接口上的一致头疼过。现在使用 GameBox Cloud Core,这些问题统统消失不见。
-
- 支持短连接和长连接,满足从异步网络到实时网络的各种需求
@@ -34,19 +32,12 @@ GameBox Cloud Core 为开发者提供一个稳定可靠,可伸缩的服务端
- [WebSocket RFC 文档](https://tools.ietf.org/html/rfc6455)
- [WebSocket](http://zh.wikipedia.org/wiki/WebSocket)
-
-- 支持插件机制,使用第三方插件加快功能开发
+## Get Started
- GameBox Cloud Core 支持插件机制,开发者可以使用成熟的第三方插件来加快服务端功能开发。未来 GameBox Cloud 团队也将提供插件仓库,让开发者可以分享各种有用的插件。
-
-
-
-### Get Started
-
-- [安装 GameBox Cloud Core](http://gameboxcloud.com/docs/core/install/)
-- [创建 Hello,World 应用程序](http://gameboxcloud.com/docs/core/helloworld/)
-- [更多文档](http://gameboxcloud.com/docs/)
-- [版本日志](http://gameboxcloud.com/docs/core/changelog/)
-- [源代码仓库](https://bitbucket.org/gameboxcloud/gbc-core)
+- [快速开始](https://github.com/dualface/gbc-docs/blob/master/src/guide/get-started.md)
+- [更多文档](https://github.com/dualface/gbc-docs/)
+- [源代码仓库](https://github.com/dualface/gbc-core)
+- [项目管理](https://www.pivotaltracker.com/n/projects/1474648)
+- [Bug 报告](https://github.com/dualface/gbc-core/issues)
- 技术支持: QQ群 <424776815>
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..a3df0a6
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.8.0
diff --git a/Vagrantfile b/Vagrantfile
new file mode 100644
index 0000000..14474b9
--- /dev/null
+++ b/Vagrantfile
@@ -0,0 +1,73 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+# All Vagrant configuration is done below. The "2" in Vagrant.configure
+# configures the configuration version (we support older styles for
+# backwards compatibility). Please don't change it unless you know what
+# you're doing.
+Vagrant.configure(2) do |config|
+ # The most common configuration options are documented and commented below.
+ # For a complete reference, please see the online documentation at
+ # https://docs.vagrantup.com.
+
+ # Every Vagrant development environment requires a box. You can search for
+ # boxes at https://atlas.hashicorp.com/search.
+ config.vm.box = "ubuntu/vivid64"
+
+ # Disable automatic box update checking. If you disable this, then
+ # boxes will only be checked for updates when the user runs
+ # `vagrant box outdated`. This is not recommended.
+ # config.vm.box_check_update = false
+
+ # Create a forwarded port mapping which allows access to a specific port
+ # within the machine from a port on the host machine. In the example below,
+ # accessing "localhost:8080" will access port 80 on the guest machine.
+ config.vm.network "forwarded_port", guest: 8088, host: 8088
+
+ # Create a private network, which allows host-only access to the machine
+ # using a specific IP.
+ # config.vm.network "private_network", ip: "192.168.33.10"
+
+ # Create a public network, which generally matched to bridged network.
+ # Bridged networks make the machine appear as another physical device on
+ # your network.
+ # config.vm.network "public_network"
+
+ # Share an additional folder to the guest VM. The first argument is
+ # the path on the host to the actual folder. The second argument is
+ # the path on the guest to mount the folder. And the optional third
+ # argument is a set of non-required options.
+ # config.vm.synced_folder "../data", "/vagrant_data"
+
+ # Provider-specific configuration so you can fine-tune various
+ # backing providers for Vagrant. These expose provider-specific options.
+ # Example for VirtualBox:
+ #
+ # config.vm.provider "virtualbox" do |vb|
+ # # Display the VirtualBox GUI when booting the machine
+ # vb.gui = true
+ #
+ # # Customize the amount of memory on the VM:
+ # vb.memory = "1024"
+ # end
+ #
+ # View the documentation for the provider you are using for more
+ # information on available options.
+
+ # Define a Vagrant Push strategy for pushing to Atlas. Other push strategies
+ # such as FTP and Heroku are also available. See the documentation at
+ # https://docs.vagrantup.com/v2/push/atlas.html for more information.
+ # config.push.define "atlas" do |push|
+ # push.app = "YOUR_ATLAS_USERNAME/YOUR_APPLICATION_NAME"
+ # end
+
+ # Enable provisioning with a shell script. Additional provisioners such as
+ # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the
+ # documentation for more information about their specific syntax and use.
+ config.vm.provision :shell, run: "always", path: "vagrant-support/bootstrap.sh"
+
+ # config.vm.provision "shell", inline: <<-SHELL
+ # sudo apt-get update
+ # sudo apt-get install -y apache2
+ # SHELL
+end
diff --git a/apps/admin/actions/MonitorAction.lua b/apps/admin/actions/MonitorAction.lua
deleted file mode 100644
index 4b6dee8..0000000
--- a/apps/admin/actions/MonitorAction.lua
+++ /dev/null
@@ -1,207 +0,0 @@
---[[
-
-Copyright (c) 2015 gameboxcloud.com
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-]]
-
-local tonumber = tonumber
-local string_format = string.format
-local string_match = string.match
-local string_sub = string.sub
-local string_lower = string.lower
-local string_split = string.split
-local string_find = string.find
-local table_insert = table.insert
-local math_trunc = math.trunc
-local io_popen = io.popen
-
-local _MONITOR_PROC_DICT_KEY = "_MONITOR_PROC_DICT"
-local _MONITOR_LIST_PATTERN = "_MONITOR_%s_%s_LIST"
-local _MONITOR_MEM_INFO_KEY = "_MONITOR_MEM_INFO"
-local _MONITOR_CPU_INFO_KEY = "_MONITOR_CPU_INFO"
-local _MONITOR_DISK_INFO_KEY = "_MONITOR_DISK_INFO"
-
-local MonitorAction = class("MonitorAction")
-
-function MonitorAction:ctor(connect)
- self.connect = connect
- self._interval = connect.config.monitor.interval
-end
-
-function MonitorAction:getalldataAction(arg)
- local result = {}
- local process = self:_getProcess()
- for _, procName in ipairs(process) do
- result[procName] = self:_fillData(procName, {"SEC", "MINUTE", "HOUR"}, 0)
- end
-
- result.interval = self._interval
- result.cpu_cores = self:_getSystemInfo(_MONITOR_CPU_INFO_KEY)
- result.mem_total, result.mem_free = self:_getSystemInfo(_MONITOR_MEM_INFO_KEY)
- result.disk_total, result.disk_free = self:_getSystemInfo(_MONITOR_DISK_INFO_KEY)
-
- return result
-end
-
-function MonitorAction:getdataAction(arg)
- local timeSpan = self:_convertToSec(arg.time_span)
-
- if not timeSpan or timeSpan <= 0 then
- return self:getalldataAction(arg)
- end
-
- local listType = {}
- local start = 0
- if timeSpan <= 60 then
- table_insert(listType, "SEC")
- start = -math_trunc(timeSpan / self._interval)
- -- indicate that client has a query interval less than monitoring interval
- -- so at least return the latest data.
- if start == 0 then
- start = -1
- end
- elseif timeSpan <= 3600 then
- table_insert(listType, "MINUTE")
- start = -math_trunc(timeSpan / 60)
- else
- table_insert(listType, "HOUR")
- start = -math_trunc(timeSpan / 3600)
- end
-
- local result = {}
- local process = self:_getProcess()
- for _, procName in ipairs(process) do
- result[procName] = self:_fillData(procName, listType, start)
- if procName == "REDIS-SERVER" then
- result["REDIS_SERVER"] = result[procName]
- result[procName] = nil
- end
- end
-
- result.interval = self._interval
- result.cpu_cores = self:_getSystemInfo(_MONITOR_CPU_INFO_KEY)
- result.mem_total, result.mem_free = self:_getSystemInfo(_MONITOR_MEM_INFO_KEY)
- result.disk_total, result.disk_free = self:_getSystemInfo(_MONITOR_DISK_INFO_KEY)
-
- return result
-end
-
-function MonitorAction:_getProcess()
- local redis = self.connect:getRedis()
- local process = redis:command("HKEYS", _MONITOR_PROC_DICT_KEY)
-
- return process
-end
-
-function MonitorAction:_getSystemInfo(key)
- local redis = self.connect:getRedis()
- local res = string_split(redis:command("GET", key), "|")
-
- return res[1], res[2]
-end
-
-function MonitorAction:_convertToSec(timeSpan)
- if not timeSpan then return nil end
-
- local time = string_match(string_lower(timeSpan), "^(%d+[s|h|m])")
- if time == nil then
- throw("time format error.")
- end
- local unit = string_sub(time, -1)
- local number = tonumber(string_sub(time, 1, -2))
- if not number then
- return nil
- end
-
- if unit == "h" then
- return number * 3600
- end
-
- if unit == "m" then
- return number * 60
- end
-
- if unit == "s" then
- return number
- end
-
- return nil
-end
-
-function MonitorAction:_fillData(procName, listType, start)
- local redis = self.connect:getRedis()
- local t = {}
- t.cpu = {}
- if not string_find(procName, "BEANSTALKD") then
- t.mem = {}
- if not string_find(procName, "NGINX_WORKER") then
- t.conn_num = {}
- end
- else
- t.total_jobs = {}
- end
-
- for _, typ in ipairs(listType) do
- local list = string_format(_MONITOR_LIST_PATTERN, procName, typ)
- local data = redis:command("LRANGE", list, start, -1)
- local field = self:_getFiled(typ)
- t.cpu[field] = {}
- if not string_find(procName, "BEANSTALKD") then
- t.mem[field] = {}
- if not string_find(procName, "NGINX_WORKER") then
- t.conn_num[field] = {}
- end
- else
- t.total_jobs[field] = {}
- end
-
- for _, v in ipairs(data) do
- local tmp = string_split(v, "|")
- table_insert(t.cpu[field], tonumber(tmp[1]))
- if not string_find(procName, "BEANSTALKD") then
- table_insert(t.mem[field], tonumber(tmp[2]))
- if not string_find(procName, "NGINX_WORKER") then
- table_insert(t.conn_num[field], tonumber(tmp[3]))
- end
- else
- table_insert(t.total_jobs[field], tonumber(tmp[3]))
- end
- end
- end
-
- return t
-end
-
-function MonitorAction:_getFiled(typ)
- if typ == "SEC" then
- return "last_60s"
- end
-
- if typ == "MINUTE" then
- return "last_hour"
- end
-
- if typ == "HOUR" then
- return "last_day"
- end
-end
-
-return MonitorAction
diff --git a/apps/tests/actions/BeanstalkdAction.lua b/apps/tests/actions/BeanstalkdAction.lua
new file mode 100644
index 0000000..c6d8f61
--- /dev/null
+++ b/apps/tests/actions/BeanstalkdAction.lua
@@ -0,0 +1,207 @@
+--[[
+
+Copyright (c) 2015 gameboxcloud.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+]]
+
+local helper = cc.import(".helper")
+local tests = cc.import("#tests")
+local check = tests.Check
+local Beanstalkd = cc.import("#beanstalkd")
+
+local BeanstalkdTestCase = cc.class("BeanstalkdTestCase", tests.TestCase)
+
+local _newbean, _flush
+
+local _DEFAULT_TUBE = "default"
+local _TEST_TUBE = "_test_"
+local _JOB_PRIORITY = 0
+local _JOB_DELAY = 1
+local _JOB_TTR = 2
+local _JOB_WORD = "hello, number is " .. math.random(1, 100)
+
+function BeanstalkdTestCase:setup()
+ local config = self:getInstanceConfig()
+ self._beanstalkd = _newbean(config.server.beanstalkd)
+ _flush(self._beanstalkd)
+end
+
+function BeanstalkdTestCase:teardown()
+ _flush(self._beanstalkd)
+end
+
+function BeanstalkdTestCase:basicsAction()
+ local bean = self._beanstalkd
+ local errors = bean.ERRORS
+
+ -- add job, reserve it
+ local id = bean:put(_JOB_WORD, _JOB_PRIORITY, _JOB_DELAY, _JOB_TTR)
+ check.isInt(id)
+ local job = bean:reserve()
+ check.equals(job, {id = id, data = _JOB_WORD})
+
+ -- sleep, reserve again with deadline_soon
+ helper.sleep(_JOB_TTR - 1)
+
+ check.equals({bean:reserve(0)}, {nil, errors.DEADLINE_SOON})
+ check.equals({bean:touch(job.id)}, {true})
+
+ -- delete it
+ check.equals({bean:delete(job.id)}, {true})
+ -- delete non exists job
+ check.equals({bean:delete(job.id)}, {nil, errors.NOT_FOUND})
+
+ -- reserve with timeout
+ check.equals({bean:reserve(1)}, {nil, errors.TIMED_OUT})
+
+ return true
+end
+
+function BeanstalkdTestCase:releaseAction()
+ local bean = self._beanstalkd
+ local errors = bean.ERRORS
+
+ -- add job, reserve it, release it
+ local id = bean:put(_JOB_WORD, 0, _JOB_DELAY, _JOB_TTR)
+ check.isInt(id)
+ local job = bean:reserve()
+ check.equals(job, {id = id, data = _JOB_WORD})
+ check.equals({bean:release(job.id, _JOB_PRIORITY, _JOB_DELAY)}, {true})
+
+ -- release non exists job
+ check.equals({bean:release(job.id, _JOB_PRIORITY, _JOB_DELAY)}, {nil, errors.NOT_FOUND})
+ -- delete it
+ check.equals({bean:delete(job.id)}, {true})
+
+ return true
+end
+
+function BeanstalkdTestCase:changestateAction()
+ local bean = self._beanstalkd
+ local errors = bean.ERRORS
+
+ -- add job, peek it
+ local id = bean:put(_JOB_WORD, 0, _JOB_DELAY, _JOB_TTR)
+ check.isInt(id)
+
+ local expected = {id = id, data = _JOB_WORD}
+ local job = bean:peek(id)
+ check.equals(job, expected)
+
+ -- peek delayed job
+ local job = bean:peek("delayed")
+ check.equals(job, expected)
+
+ -- reserve it, bury reserved job, peek buried job
+ check.equals({bean:reserve()}, {expected})
+ check.equals({bean:bury(job.id, _JOB_PRIORITY)}, {true})
+ local job = bean:peek("buried")
+ check.equals(job, expected)
+
+ -- kick it
+ check.equals({bean:kick(100)}, {1})
+ -- wait it ready
+ helper.sleep(_JOB_DELAY)
+ local job = bean:peek("ready")
+ check.equals(job, expected)
+
+ return true
+end
+
+function BeanstalkdTestCase:statsAction()
+ local bean = self._beanstalkd
+ local errors = bean.ERRORS
+
+ -- add job
+ local id = bean:put(_JOB_WORD, 0, _JOB_DELAY, _JOB_TTR)
+ check.isInt(id)
+
+ -- get job info
+ local res = bean:statsJob(id)
+ check.equals(res["id"], tostring(id))
+ check.equals(res["state"], "delayed")
+
+ -- get tube info
+ local res = bean:statsTube(_TEST_TUBE)
+ check.equals(res["name"], _TEST_TUBE)
+ check.equals(res["current-jobs-delayed"], "1")
+
+ -- get system info
+ local res = bean:stats()
+ check.equals(res["current-jobs-ready"], "0")
+ check.equals(res["current-jobs-delayed"], "1")
+
+ -- list tubes
+ local tubes = bean:listTubes()
+ check.isTable(tubes)
+ check.contains(tubes, _TEST_TUBE)
+
+ -- list used tube
+ local tube = bean:listTubeUsed()
+ check.equals(tube, _TEST_TUBE)
+
+ -- list watched tubes
+ local tubes = bean:listTubesWatched()
+ check.isTable(tubes)
+ check.contains(tubes, _TEST_TUBE)
+
+ return true
+end
+
+function BeanstalkdTestCase:tubeAction()
+ local bean = self._beanstalkd
+ local errors = bean.ERRORS
+
+ -- use, watch, ignore
+ check.equals({bean:use(_TEST_TUBE)}, {_TEST_TUBE})
+
+ local count = bean:watch(_TEST_TUBE)
+ check.isInt(count)
+ check.greaterThan(count, 0)
+
+ local count2 = bean:ignore(_DEFAULT_TUBE)
+ check.isInt(count2)
+
+ return true
+end
+
+-- private
+
+_newbean = function(config)
+ local beanstalkd = Beanstalkd:new()
+ beanstalkd:connect(config.host, config.port)
+ beanstalkd:use(_TEST_TUBE)
+ beanstalkd:watch(_TEST_TUBE)
+ beanstalkd:ignore(_DEFAULT_TUBE)
+ return beanstalkd
+end
+
+_flush = function(bean)
+ bean:kick(10000)
+
+ while true do
+ local data = bean:reserve(0)
+ if not data then break end
+ bean:delete(data.id)
+ end
+end
+
+return BeanstalkdTestCase
diff --git a/apps/tests/actions/ComponentsAction.lua b/apps/tests/actions/ComponentsAction.lua
new file mode 100644
index 0000000..d6e72d3
--- /dev/null
+++ b/apps/tests/actions/ComponentsAction.lua
@@ -0,0 +1,109 @@
+--[[
+
+Copyright (c) 2015 gameboxcloud.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+]]
+
+
+local Event = cc.import("#event")
+
+local helper = cc.import(".helper")
+local tests = cc.import("#tests")
+local check = tests.Check
+
+local ComponentsTestCase = cc.class("ComponentsTestCase", tests.TestCase)
+
+function ComponentsTestCase:setup()
+end
+
+function ComponentsTestCase:teardown()
+end
+
+function ComponentsTestCase:bindingAction()
+ local Sheep = cc.class("Sheep")
+ local sheep = Sheep:new()
+
+ -- add component
+ local eventComponent = cc.addComponent(sheep, Event)
+ check.isTable(eventComponent)
+ check.isFunction(eventComponent.bind)
+
+ -- get component by class
+ local eventComponent_ = cc.getComponent(sheep, Event)
+ check.equals(tostring(eventComponent), tostring(eventComponent_))
+
+ -- get component by class name
+ local eventComponent_ = cc.getComponent(sheep, Event.__cname)
+ check.equals(tostring(eventComponent), tostring(eventComponent_))
+
+ -- bind listeners
+ local results = {}
+
+ local tag1 = eventComponent:bind("RUN", function(event)
+ results[#results + 1] = {event.name, event.step}
+ end)
+
+ local tag2 = eventComponent:bind("RUN", function(event)
+ results[#results + 1] = {event.name, event.step}
+ end) -- add second listener for event "RUN"
+
+ local tag3 = eventComponent:bind("WALK", function(event)
+ results[#results + 1] = {event.name, event.step}
+ end)
+
+ -- trigger events
+ local step1 = math.random(10000, 20000)
+ local step2 = math.random(30000, 40000)
+ local step3 = math.random(50000, 60000)
+
+ eventComponent:trigger({name = "RUN", step = step1})
+ eventComponent:trigger({name = "WALK", step = step2})
+
+ -- unbind listener
+ eventComponent:unbind(tag2)
+ eventComponent:trigger({name = "RUN", step = step3})
+
+ -- check
+ check.equals(results, {
+ {"RUN", step1},
+ {"RUN", step1},
+ {"WALK", step2},
+ {"RUN", step3},
+ })
+
+ -- remove component by class
+ cc.removeComponent(sheep, Event)
+ check.isNil(cc.getComponent(sheep, Event))
+
+ -- remove component by class name
+ cc.addComponent(sheep, Event)
+ cc.removeComponent(sheep, Event.__cname)
+ check.isNil(cc.getComponent(sheep, Event))
+
+ -- remove component by component object
+ local eventComponent = cc.addComponent(sheep, Event)
+ cc.removeComponent(sheep, eventComponent)
+ check.isNil(cc.getComponent(sheep, Event))
+
+ return true
+end
+
+return ComponentsTestCase
diff --git a/apps/tests/actions/JobsAction.lua b/apps/tests/actions/JobsAction.lua
new file mode 100644
index 0000000..cbc8315
--- /dev/null
+++ b/apps/tests/actions/JobsAction.lua
@@ -0,0 +1,132 @@
+--[[
+
+Copyright (c) 2015 gameboxcloud.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+]]
+
+local helper = cc.import(".helper")
+local tests = cc.import("#tests")
+local check = tests.Check
+
+local JobsTestCase = cc.class("JobsTestCase", tests.TestCase)
+
+local _TEST_REDIS_KEY = 'jobs.test.number'
+
+local _flush
+
+function JobsTestCase:setup()
+ local instance = self:getInstance()
+ self._jobs = instance:getJobs()
+ self._redis = instance:getRedis()
+ _flush(self._jobs, self._redis)
+end
+
+function JobsTestCase:teardown()
+ _flush(self._jobs, self._redis)
+end
+
+function JobsTestCase:addAction()
+ local number = math.random(1, 10000)
+ local data = {number = number, key = _TEST_REDIS_KEY}
+
+ local delay = 1
+ local jobid = self._jobs:add({
+ action = 'jobs.trigging',
+ data = data,
+ delay = delay,
+ })
+ check.isInt(jobid)
+ helper.sleep(delay + 1) -- waiting for job done
+
+ -- query job result from redis
+ local res = tonumber(self._redis:get(_TEST_REDIS_KEY))
+ check.equals(res, number * 2)
+
+ return true
+end
+
+function JobsTestCase:atAction()
+ local number = math.random(20000, 30000)
+ local data = {number = number, key = _TEST_REDIS_KEY}
+
+ local time = os.time() + 1
+ local jobid = self._jobs:at({
+ action = 'jobs.trigging',
+ data = data,
+ time = time,
+ })
+ check.isInt(jobid)
+ helper.sleep(2) -- waiting for job done
+
+ -- query job result from redis
+ local now = os.time()
+ local res = tonumber(self._redis:get(_TEST_REDIS_KEY))
+ check.equals(res, number * 2)
+ check.isTrue(math.abs(now - time) <= 1)
+
+ return true
+end
+
+function JobsTestCase:getAction()
+ local number = math.random(40000, 50000)
+ local data = {number = number, key = _TEST_REDIS_KEY}
+
+ local delay = 2
+ local jobid = self._jobs:add({
+ action = 'jobs.trigging',
+ data = data,
+ delay = delay,
+ })
+ check.isInt(jobid)
+
+ -- query job
+ local job = self._jobs:get(jobid)
+ check.isTable(job)
+ check.equals(job.id, jobid)
+ check.equals(job.data, data)
+
+ -- delete job
+ local res = self._jobs:delete(jobid)
+ check.isTrue(res)
+
+ return true
+end
+
+-- remove all jobs
+function JobsTestCase:_flush()
+ local states = {"ready", "delayed", "buried"}
+ for _, state in ipairs(states) do
+ while true do
+ local job = self._jobs:queryNext(state)
+ if not job then break end
+ self._jobs:remove(job.id)
+ end
+ end
+end
+
+-- private
+
+_flush = function(jobs, redis)
+ redis:del(_TEST_REDIS_KEY)
+end
+
+return JobsTestCase
+
diff --git a/apps/tests/actions/RedisAction.lua b/apps/tests/actions/RedisAction.lua
new file mode 100644
index 0000000..de09a93
--- /dev/null
+++ b/apps/tests/actions/RedisAction.lua
@@ -0,0 +1,287 @@
+--[[
+
+Copyright (c) 2015 gameboxcloud.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+]]
+
+local Redis = cc.import("#redis")
+local helper = cc.import(".helper")
+local tests = cc.import("#tests")
+local check = tests.Check
+
+local RedisTestCase = cc.class("RedisTestCase", tests.TestCase)
+
+local _TEST_DB_INDEX = 15
+local _CLEANUP_CODE = [[
+local keys = redis.call('KEYS', '*')
+for _, key in ipairs(keys) do
+ redis.call('DEL', key)
+end
+]]
+
+local _newredis, _runcmds
+
+function RedisTestCase:setup()
+ local config = self:getInstanceConfig()
+ self._redis = _newredis(config.server.redis)
+end
+
+function RedisTestCase:teardown()
+ local redis = self._redis
+ redis:select(_TEST_DB_INDEX)
+ redis:eval(_CLEANUP_CODE, 0)
+ redis:close()
+ self._redis = nil
+end
+
+function RedisTestCase:typesAction()
+ local redis = self._redis
+
+ -- check return types:
+ -- Simple Strings
+ local word = "hello world"
+ check.equals(redis:echo(word), word)
+ check.equals(redis:ping(), "PONG")
+
+ -- Errors
+ local ok, err = redis:auth("INVALID_PASSWORD")
+ check.contains(string.lower(err), "no password is set")
+
+ -- Integers
+ check.equals(redis:del("NON_EXISTS_KEY"), 0)
+ redis:del("TEST_KEY")
+ check.equals(redis:set("TEST_KEY", word), "OK")
+ check.equals(redis:del("TEST_KEY"), 1)
+
+ -- Bulk Strings
+ check.equals(redis:set("TEST_KEY", word), "OK")
+ check.equals(redis:get("TEST_KEY"), word)
+
+ -- Arrays
+ check.equals(redis:set("TEST_KEY_1", word), "OK")
+ check.equals(redis:set("TEST_KEY_2", word), "OK")
+ local keys = redis:keys("TEST_KEY_*")
+ check.isTable(keys)
+ table.sort(keys)
+ check.equals(keys, {"TEST_KEY_1", "TEST_KEY_2"})
+
+ check.equals(redis:mget("TEST_KEY", "NON_EXISTS_KEY"), {word, redis.null})
+
+ return true
+end
+
+function RedisTestCase:pipelineAction()
+ local redis = self._redis
+
+ local word = "hello world"
+ local commands = {
+ {"echo", word},
+ {"ping"},
+ {"del", "NON_EXISTS_KEY"},
+ {"set", "TEST_KEY", word},
+ {"del", "TEST_KEY"},
+ {"set", "TEST_KEY_1", word},
+ {"set", "TEST_KEY_2", word},
+ {"keys", "TEST_KEY_*"},
+ {"mget", "TEST_KEY", "TEST_KEY_1", "NON_EXISTS_KEY"},
+ {"eval", "return {redis.call('get', 'NON_EXISTS_KEY'), KEYS[1], ARGV[1]}", 1, "KEY_1", "ARG_1"},
+ {"auth", "NO_PASSWORD"}, -- get error in pipeline
+ }
+
+ local expected = {
+ word,
+ "PONG",
+ 0,
+ "OK",
+ 1,
+ "OK",
+ "OK",
+ {"TEST_KEY_1", "TEST_KEY_2"},
+ {redis.null, word, redis.null},
+ {
+ redis.null, "KEY_1", "ARG_1",
+ },
+ }
+
+ local function _checkResult(vals)
+ table.sort(vals[8])
+ local last = table.remove(vals)
+
+ check.equals(vals, expected)
+ check.isTable(last)
+ check.isFalse(last[1])
+ check.contains(last[2], "no password is set")
+ end
+
+ -- test commit pipeline
+ redis:initPipeline()
+ _runcmds(redis, commands)
+ local vals = redis:commitPipeline()
+ check.isTable(vals)
+ _checkResult(vals)
+
+ -- test cancel pipeline
+ redis:initPipeline()
+ _runcmds(redis, commands)
+ redis:cancelPipeline() -- cleanup pipeline
+
+ redis:initPipeline() -- start again
+ _runcmds(redis, commands)
+ local vals = redis:commitPipeline()
+ check.isTable(vals)
+ _checkResult(vals)
+
+ return true
+end
+
+function RedisTestCase:pubsubAction()
+ local redis = self._redis
+
+ local channel1 = "MSG_CHANNEL_1"
+ local channel2 = "MSG_CHANNEL_2"
+ local channel3 = "MSG_CHANNEL_3"
+ local channel4 = "MSG_CHANNEL_4"
+
+ -- use current instance to subscribe to channels
+ check.equals(redis:subscribe(channel1, channel2), {
+ "subscribe", channel1, 1
+ })
+ check.equals(redis:readReply(), {
+ "subscribe", channel2, 2
+ })
+
+ -- use an other instance publish message to channels
+ local config = self:getInstanceConfig()
+ local redis2 = _newredis(config.server.redis)
+ redis2:publish(channel1, "hello")
+
+ check.equals(redis:readReply(), {
+ "message", channel1, "hello"
+ })
+
+ redis:subscribe(channel3, channel4)
+ check.equals(redis:readReply(), {
+ "subscribe", channel4, 4
+ })
+
+ redis2:publish(channel2, "world")
+ check.equals(redis:readReply(), {
+ "message", channel2, "world"
+ })
+
+ for i = 1, 10 do
+ redis2:publish(channel2, "world_" .. i)
+ end
+
+ for i = 1, 10 do
+ check.equals(redis:readReply(), {
+ "message", channel2, "world_" .. i
+ })
+ end
+
+ redis2:close()
+
+ -- unsubscribe from channels
+ check.equals(redis:unsubscribe(channel1), {
+ "unsubscribe", channel1, 3
+ })
+ check.equals(redis:unsubscribe(channel2), {
+ "unsubscribe", channel2, 2
+ })
+ check.equals(redis:unsubscribe(channel3), {
+ "unsubscribe", channel3, 1
+ })
+ check.equals(redis:unsubscribe(channel4), {
+ "unsubscribe", channel4, 0
+ })
+ check.equals(redis:echo("hello"), "hello")
+
+ return true
+end
+
+function RedisTestCase:loopAction()
+ local redis = self._redis
+
+ local channel1 = "MSG_CHANNEL_1"
+ local channel2 = "MSG_CHANNEL_2"
+ local channel3 = "MSG_CHANNEL_3"
+ local channel4 = "MSG_CHANNEL_4"
+
+ -- Loop will use an other redis instance
+ local msgs = {}
+ local loop, err = redis:makeSubscribeLoop()
+ if not loop then
+ check.equals(self:getInstance():getRequestType(), "cli")
+ return true
+ end
+
+ local cmdchannel = "_TEST_CMD_CHANNEL"
+ loop:start(function(channel, msg)
+ msgs[#msgs + 1] = {channel, msg}
+ end, cmdchannel)
+
+ loop:subscribe(channel1, channel2)
+
+ -- publish message to channels
+ redis:publish(channel1, "hello1")
+ redis:publish(channel2, "hello2")
+
+ loop:unsubscribe(channel2)
+ redis:publish(channel2, "hello2") -- skip
+
+ loop:psubscribe("MSG_CHANNEL_*")
+ redis:publish(channel2, "hello2")
+ redis:publish(channel3, "hello3")
+ redis:publish(channel4, "hello4")
+
+ loop:stop() -- read all messages and stop loop
+
+ check.equals(msgs, {
+ {channel1, "hello1"},
+ {channel2, "hello2"},
+ --
+ {channel2, "hello2"},
+ {channel3, "hello3"},
+ {channel4, "hello4"},
+ })
+
+ return true
+end
+
+-- private
+
+_newredis = function(config)
+ local redis, err = helper.newredis(config)
+ check.isNil(err, err)
+ redis:select(_TEST_DB_INDEX)
+ redis:eval(_CLEANUP_CODE, 0)
+ return redis
+end
+
+_runcmds = function(redis, commands)
+ local res = {}
+ for i, args in ipairs(commands) do
+ res[i] = redis:doCommand(unpack(args))
+ end
+ return res
+end
+
+return RedisTestCase
diff --git a/apps/tests/actions/SessionAction.lua b/apps/tests/actions/SessionAction.lua
new file mode 100644
index 0000000..22ed81b
--- /dev/null
+++ b/apps/tests/actions/SessionAction.lua
@@ -0,0 +1,123 @@
+--[[
+
+Copyright (c) 2015 gameboxcloud.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+]]
+
+
+local Session = cc.import("#session")
+
+local helper = cc.import(".helper")
+local tests = cc.import("#tests")
+local check = tests.Check
+
+local SessionTestCase = cc.class("SessionTestCase", tests.TestCase)
+
+local _KEYS = table.readonly({
+ NON_EXISTS_KEY = "NON_EXISTS_KEY",
+ STRING_KEY = "STRING_KEY",
+ NUMBER_KEY = "NUMBER_KEY",
+})
+
+local _newredis
+
+function SessionTestCase:setup()
+ local config = self:getInstanceConfig()
+ self._redis = _newredis(config.server.redis)
+end
+
+function SessionTestCase:teardown()
+end
+
+function SessionTestCase:createAction()
+ local redis = self._redis
+ local session = Session:new(redis)
+ session:start()
+
+ local sid = session:getSid()
+ check.isString(sid)
+
+ check.isNil(session:get(_KEYS.NON_EXISTS_KEY))
+
+ local number = math.random(1, 100000)
+ session:set(_KEYS.NUMBER_KEY, number)
+ check.equals(session:get(_KEYS.NUMBER_KEY), number)
+
+ local word = "hello " .. tostring(number)
+ session:set(_KEYS.STRING_KEY, word)
+ check.equals(session:get(_KEYS.STRING_KEY), word)
+
+ -- save session
+ session:save()
+
+ -- create an other session use same sid
+ local session2 = Session:new(redis)
+ session2:start(sid)
+
+ check.equals(session:get(_KEYS.NUMBER_KEY), number)
+ check.equals(session:get(_KEYS.STRING_KEY), word)
+
+ -- destroy first session
+ session:destroy()
+
+ -- second session should is destroyed also
+ check.isFalse(session2:isAlive())
+
+ return true
+end
+
+function SessionTestCase:expiredAction()
+ local redis = self._redis
+ local session = Session:new(redis, {expired = 1})
+ session:start()
+
+ local number = math.random(1, 100000)
+ session:set(_KEYS.NUMBER_KEY, number)
+ check.isFalse(session:isAlive())
+ check.equals(session:get(_KEYS.NUMBER_KEY), number)
+
+ session:save()
+ check.isTrue(session:isAlive())
+
+ local expired = 2
+ session:setKeepAlive(expired)
+ check.equals(session:getExpired(), expired)
+ helper.sleep(1)
+ check.isTrue(session:isAlive())
+ check.equals(session:get(_KEYS.NUMBER_KEY), number)
+
+ helper.sleep(2)
+ check.isFalse(session:isAlive())
+
+ return true
+end
+
+-- private
+
+_newredis = function(config)
+ local redis, err = helper.newredis(config)
+ check.isNil(err, err)
+ redis:select(_TEST_DB_INDEX)
+ redis:eval(_CLEANUP_CODE, 0)
+ return redis
+end
+
+return SessionTestCase
diff --git a/apps/tests/actions/helper.lua b/apps/tests/actions/helper.lua
new file mode 100644
index 0000000..6ce86ba
--- /dev/null
+++ b/apps/tests/actions/helper.lua
@@ -0,0 +1,34 @@
+
+local Redis = cc.import("#redis")
+local Beanstalkd = cc.import("#beanstalkd")
+
+local _M = {}
+
+_M.newredis = function(config)
+ local redis = Redis:new()
+ local ok, err
+ if config.socket then
+ ok, err = redis:connect(config.socket)
+ else
+ ok, err = redis:connect(config.host, config.port)
+ end
+ if not ok then
+ return nil, err
+ end
+ return redis
+end
+
+_M.newbeanstalkd = function(config)
+ local bean = Beanstalkd:new()
+ local ok, err = bean:connect(config.host, config.port)
+ if not ok then
+ return nil, err
+ end
+ return bean
+end
+
+_M.sleep = function(n)
+ os.execute("sleep " .. tonumber(n))
+end
+
+return _M
diff --git a/apps/tests/conf/app_config.lua b/apps/tests/conf/app_config.lua
new file mode 100644
index 0000000..8257d73
--- /dev/null
+++ b/apps/tests/conf/app_config.lua
@@ -0,0 +1,6 @@
+
+local config = {
+ numOfJobWorkers = 1,
+}
+
+return config
diff --git a/apps/tests/conf/app_entry.conf b/apps/tests/conf/app_entry.conf
new file mode 100644
index 0000000..dfa5c7b
--- /dev/null
+++ b/apps/tests/conf/app_entry.conf
@@ -0,0 +1,4 @@
+
+location /tests/ {
+ content_by_lua 'nginxBootstrap:runapp("_APP_ROOT_")';
+}
diff --git a/apps/tests/jobs/JobsAction.lua b/apps/tests/jobs/JobsAction.lua
new file mode 100644
index 0000000..6598963
--- /dev/null
+++ b/apps/tests/jobs/JobsAction.lua
@@ -0,0 +1,15 @@
+
+local gbc = cc.import("#gbc")
+local JobsAction = cc.class("JobsAction", gbc.ActionBase)
+
+JobsAction.ACCEPTED_REQUEST_TYPE = "worker"
+
+function JobsAction:triggingAction(job)
+ local key = job.data.key
+ local number = job.data.number
+
+ local redis = self:getInstance():getRedis()
+ redis:set(key, number * 2)
+end
+
+return JobsAction
diff --git a/apps/tests/shells/run_tests b/apps/tests/shells/run_tests
new file mode 100755
index 0000000..00ef59b
--- /dev/null
+++ b/apps/tests/shells/run_tests
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+CUR_DIR=$(cd "$(dirname $0)" && pwd)
+ROOT_DIR=$(dirname "$CUR_DIR")
+ROOT_DIR=$(dirname "$ROOT_DIR")
+ROOT_DIR=$(dirname "$ROOT_DIR")
+source "$ROOT_DIR/bin/shell_func.sh"
+
+if [ $? -ne 0 ]; then echo "Terminating..." >&2; exit 1; fi
+
+DEBUG=1
+
+$LUA_BIN -e "ROOT_DIR='$ROOT_DIR'; DEBUG=$DEBUG; dofile('$CUR_DIR/run_tests_func.lua'); runTests('$*')"
diff --git a/apps/tests/shells/run_tests_func.lua b/apps/tests/shells/run_tests_func.lua
new file mode 100644
index 0000000..0ec4754
--- /dev/null
+++ b/apps/tests/shells/run_tests_func.lua
@@ -0,0 +1,249 @@
+--[[
+
+Copyright (c) 2015 gameboxcloud.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+]]
+
+local os_execute = os.execute
+local os_remove = os.remove
+local string_format = string.format
+local string_lower = string.lower
+local string_sub = string.sub
+
+package.path = ROOT_DIR .. "/src/?.lua;" .. ROOT_DIR .. "/apps/tests/?.lua;" .. package.path
+require("framework.init")
+cc.DEBUG = cc.DEBUG_VERBOSE
+
+local json = cc.import("#json")
+local Factory = cc.import("#gbc").Factory
+
+-- declare Tests class
+
+local Tests = cc.class("Tests")
+
+local _CURL_PATTERN = "curl -s --no-keepalive -o - '%s'"
+
+local _parseargs, _findtests
+local _testsrv, _testcli
+local _help
+
+function Tests:ctor(appConfig, appRootPath)
+ self._url = string_format("http://localhost:%s/tests/?action=%%s", tostring(appConfig.server.nginx.port))
+ self._config = appConfig
+ self._root = appRootPath
+end
+
+function Tests:run(args)
+ local opts, err = _parseargs(args)
+ if not opts then
+ _help()
+ return
+ end
+
+ if #opts.tests == 0 then
+ local casesDir = self._root .. "/actions"
+ opts.tests = _findtests(casesDir)
+ end
+
+ local pass
+ for _, casename in ipairs(opts.tests) do
+ if string_sub(casename, -6) ~= "Action" then
+ -- casename passed from command line arguments
+ casename = string.ucfirst(string.lower(casename)) .. "Action"
+ end
+
+ local ok, testCaseClass = pcall(require, "actions." .. casename)
+ if not ok then
+ -- testCaseClass is error message
+ cc.printf("ERR: not found test '%s'\n\n%s", casename, testCaseClass)
+ break
+ end
+ if type(testCaseClass) ~= "table" then
+ cc.printf("ERR: '%s' isn't module", casename)
+ break
+ end
+
+ local actionPackageName = string_lower(string_sub(casename, 1, -7))
+ local tests = {}
+ for methodName, _2 in pairs(testCaseClass) do
+ if string_sub(methodName, -6) == "Action" then
+ tests[#tests + 1] = actionPackageName .. "." .. string_lower(string_sub(methodName, 1, -7))
+ end
+ end
+
+ table.sort(tests)
+
+ print(string_format("## Test Case : %s", actionPackageName))
+
+ for _3, action in ipairs(tests) do
+ if opts.testsrv then
+ pass = self:_runtest(_testsrv, {action}, "SERVER " .. action)
+ if (not pass) and (not opts.continue) then
+ break
+ end
+ end
+
+ if opts.testcli then
+ pass = self:_runtest(_testcli, {action}, "CLI " .. action)
+ if (not pass) and (not opts.continue) then
+ break
+ end
+ end
+ end
+
+ print("")
+
+ if (not pass) and (not opts.continue) then
+ break
+ end
+
+ end
+end
+
+function Tests:_runtest(testfun, arg, action)
+ local result
+ local err
+
+ local ok, contents = xpcall(function()
+ return testfun(self, unpack(arg))
+ end, function(_err)
+ err = _err .. debug.traceback("", 4)
+ end)
+
+ if contents == true then
+ result = {ok = true}
+ elseif type(contents) == "table" then
+ result = contents
+ else
+ result = json.decode(tostring(contents))
+ if type(result) ~= "table" then
+ contents = tostring(contents)
+ contents = string.gsub(contents, "\\n", "\n")
+ contents = string.gsub(contents, "\\\"", '"')
+ result = {err = err}
+ end
+ end
+
+ if result.err then
+ print(string_format("[%s] \27[31mfailed\27[0m: %s", action, result.err))
+ elseif tostring(result.ok) == "true" or tostring(result.result) == "true" then
+ print(string_format("[%s] \27[32mok\27[0m", action))
+ return true
+ else
+ print(string_format("[%s] \27[33minvalid result\27[0m: %s", action, contents))
+ end
+end
+
+-- private
+
+_parseargs = function(args)
+ local opts = {
+ continue = false,
+ testsrv = true,
+ testcli = true,
+ tests = {}
+ }
+ for _, arg in ipairs(string.split(args, " ")) do
+ if arg == "-h" then
+ return
+ elseif arg == "-c" then
+ opts.continue = true
+ elseif arg == "-ns" then
+ opts.testsrv = false
+ elseif arg == "-nc" then
+ opts.testcli = false
+ elseif string.sub(arg, 1, 1) == "-" then
+ print("Invalid options")
+ return
+ else
+ opts.tests[#opts.tests + 1] = arg
+ end
+ end
+
+ return opts
+end
+
+_findtests = function(rootdir)
+ local cmd = string_format('ls "%s"', rootdir)
+ local h = io.popen(cmd)
+ local res = h:read("*a")
+ h:close()
+
+ local cases = {}
+ for _, file in ipairs(string.split(res, "\n")) do
+ if string.sub(file, -10) == "Action.lua" then
+ cases[#cases + 1] = string.sub(file, 1, -5)
+ end
+ end
+
+ table.sort(cases)
+
+ return cases
+end
+
+_testsrv = function(self, action)
+ local url = string_format(self._url, action)
+ local cmd = string_format(_CURL_PATTERN, url)
+ local h = io.popen(cmd)
+ local res = h:read("*a")
+ h:close()
+ return res
+end
+
+_testcli = function(self, action)
+ local config = table.copy(self._config)
+ config.app.package = "actions"
+ local cmd = Factory.create(config, "CommandLineInstance", arg)
+ return cmd:runAction(action)
+end
+
+_help = function()
+ print [[
+
+$ run_tests.sh [options] [test case name ...]
+
+options:
+-h: show help
+-c: continue when test failed
+-ns: skip server tests
+-nc: skip cli tests
+
+examples:
+
+# run JobsTestCase and RedisTestCase
+run_tests.sh jobs redis
+
+]]
+
+end
+
+-- bootstrap
+
+local appKeys = dofile(ROOT_DIR .. "/tmp/app_keys.lua")
+local globalConfig = dofile(ROOT_DIR .. "/tmp/config.lua")
+local appConfigs = Factory.makeAppConfigs(appKeys, globalConfig, package.path)
+local appRootPath = ROOT_DIR .. "/apps/tests"
+local appConfig = appConfigs[appRootPath]
+
+cc.exports.runTests = function(arg)
+ local tests = Tests:new(appConfig, appRootPath)
+ tests:run(arg)
+end
diff --git a/apps/tests/shells/show_nginx_error_log b/apps/tests/shells/show_nginx_error_log
new file mode 100755
index 0000000..bba81e9
--- /dev/null
+++ b/apps/tests/shells/show_nginx_error_log
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+CUR_DIR=$(cd "$(dirname $0)" && pwd)
+ROOT_DIR=$(dirname "$CUR_DIR")
+ROOT_DIR=$(dirname "$ROOT_DIR")
+ROOT_DIR=$(dirname "$ROOT_DIR")
+source "$ROOT_DIR/bin/shell_func.sh"
+
+if [ $? -ne 0 ] ; then echo "Terminating..." >&2; exit 1; fi
+
+multitail -csn -cS Apache -ke '^[0-9/]+ [0-9][0-9]:' -ke ' [0-9]+#[0-9]+: ' -ke ' debug\.lua:[0-9]+: _?print[a-z]+\(\):' -ke ', client: .+$' -ke '\[lua\] debug.lua:[0-9]+: dump\(\)' "$ROOT_DIR/logs/nginx-error.log"
diff --git a/apps/welcome/WebSocketConnect.lua b/apps/welcome/WebSocketConnect.lua
deleted file mode 100644
index efd21ab..0000000
--- a/apps/welcome/WebSocketConnect.lua
+++ /dev/null
@@ -1,56 +0,0 @@
-
-local WebSocketConnectBase = require("server.base.WebSocketConnectBase")
-local WebSocketConnect = class("WebSocketConnect", WebSocketConnectBase)
-
-local ConnectIdService = cc.load("connectid").service
-local OnlineService = cc.load("online").service
-
-function WebSocketConnect:ctor(config)
- printInfo("new WebSocketConnect instance")
- WebSocketConnect.super.ctor(self, config)
-end
-
-function WebSocketConnect:onUserAdd(event)
- local userdata = self.online:get(event.username)
- self:sendMessageToSelf({name = "adduser", username = event.username, tag = userdata.tag})
-end
-
-function WebSocketConnect:onUserRemove(event)
- self:sendMessageToSelf({name = "removeuser", username = event.username})
-end
-
-function WebSocketConnect:afterConnectReady()
- -- add user to online list
- local session = self:getSession()
- local username = session:get("username")
- local tag = session:get("tag")
- self.online = OnlineService:create(self)
- self.online:add(username, {tag = tag})
-
- -- register events
- self.online:addEventListener(OnlineService.USER_ADD_EVENT, handler(self, self.onUserAdd))
- self.online:addEventListener(OnlineService.USER_REMOVE_EVENT, handler(self, self.onUserRemove))
- self.online:setEventsEnabled(true)
-
- -- send all users name to client
- local all = self.online:getAll()
- self:sendMessageToSelf({name = "allusers", users = all})
-
- -- set connect tag
- local connectId = self:getConnectId()
- self.connects = ConnectIdService:create(self:getRedis())
- self.connects:setTag(connectId, tag)
-end
-
-function WebSocketConnect:beforeConnectClose()
- -- remove user from online list
- local session = self:getSession()
- local username = session:get("username")
- self.online:remove(username)
-
- -- remove connect tag
- local connectId = self:getConnectId()
- self.connects:removeTag(connectId)
-end
-
-return WebSocketConnect
diff --git a/apps/welcome/WebSocketInstance.lua b/apps/welcome/WebSocketInstance.lua
new file mode 100644
index 0000000..4e265a9
--- /dev/null
+++ b/apps/welcome/WebSocketInstance.lua
@@ -0,0 +1,87 @@
+--[[
+
+Copyright (c) 2015 gameboxcloud.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+]]
+
+local Online = cc.import("#online")
+local Session = cc.import("#session")
+
+local gbc = cc.import("#gbc")
+local WebSocketInstance = cc.class("WebSocketInstance", gbc.WebSocketInstanceBase)
+
+function WebSocketInstance:ctor(config)
+ WebSocketInstance.super.ctor(self, config)
+ self._event:bind(WebSocketInstance.EVENT.CONNECTED, cc.handler(self, self.onConnected))
+ self._event:bind(WebSocketInstance.EVENT.DISCONNECTED, cc.handler(self, self.onDisconnected))
+end
+
+function WebSocketInstance:onConnected()
+ local redis = self:getRedis()
+
+ -- load session
+ local sid = self:getConnectToken() -- token is session id
+ local session = Session:new(redis)
+ session:start(sid)
+
+ -- add user to online users list
+ local online = Online:new(self)
+ local username = session:get("username")
+ online:add(username, self:getConnectId())
+
+ -- send all usernames to current client
+ local users = online:getAll()
+ online:sendMessage(username, {name = "LIST_ALL_USERS", users = users})
+ -- subscribe online users event
+ self:subscribe(online:getChannel())
+
+ self._username = username
+ self._session = session
+ self._online = online
+end
+
+function WebSocketInstance:onDisconnected(event)
+ if event.reason ~= gbc.Constants.CLOSE_CONNECT then
+ -- connection interrupted unexpectedly, remove user from online list
+ cc.printwarn("[websocket:%s] connection interrupted unexpectedly", self:getConnectId())
+ local username = self._session:get("username")
+ self._online:remove(username)
+ end
+end
+
+function WebSocketInstance:heartbeat()
+ -- refresh session
+ self._session:setKeepAlive()
+end
+
+function WebSocketInstance:getUsername()
+ return self._username
+end
+
+function WebSocketInstance:getSession()
+ return self._session
+end
+
+function WebSocketInstance:getOnline()
+ return self._online
+end
+
+return WebSocketInstance
diff --git a/apps/welcome/actions/ChatAction.lua b/apps/welcome/actions/ChatAction.lua
index fe84d78..9e2a86c 100644
--- a/apps/welcome/actions/ChatAction.lua
+++ b/apps/welcome/actions/ChatAction.lua
@@ -1,34 +1,44 @@
-local ChatAction = class("ChatAction")
+local gbc = cc.import("#gbc")
+local ChatAction = cc.class("ChatAction", gbc.ActionBase)
-function ChatAction:ctor(connect)
- self.connect = connect
- self.connects = connect.connects
-end
+ChatAction.ACCEPTED_REQUEST_TYPE = "websocket"
function ChatAction:sendmessageAction(arg)
- local tag = arg.tag
- if not tag then
- throw("not set argument: \"tag\"")
+ local recipient = arg.recipient
+ if not recipient then
+ cc.throw("not set argument: \"recipient\"")
end
- -- get connect id by tag
- local connectId = self.connects:getIdByTag(tag)
- if not connectId then
- throw("not found connect id by tag \"%s\"", tag)
+
+ local message = arg.message
+ if not message then
+ cc.throw("not set argument: \"message\"")
end
+ -- forward message to other client
+ local instance = self:getInstance()
+ instance:getOnline():sendMessage(recipient, {
+ name = "MESSAGE",
+ sender = instance:getUsername(),
+ recipient = recipient,
+ body = message,
+ })
+end
+
+function ChatAction:sendmessagetoallAction(arg)
local message = arg.message
if not message then
- throw("not set argument: \"message\"")
+ cc.throw("not set argument: \"message\"")
end
- local session = self.connect:getSession()
- local data = {
- src = session:get("tag"),
- username = session:get("username"),
- message = message,
- }
- self.connect:sendMessageToConnect(connectId, data)
+ -- forward message to all clients
+ local instance = self:getInstance()
+ instance:getOnline():sendMessageToAll({
+ name = "MESSAGE",
+ sender = instance:getUsername(),
+ recipient = recipient,
+ body = message,
+ })
end
return ChatAction
diff --git a/apps/welcome/actions/UserAction.lua b/apps/welcome/actions/UserAction.lua
index 14ba437..f9cd38e 100644
--- a/apps/welcome/actions/UserAction.lua
+++ b/apps/welcome/actions/UserAction.lua
@@ -1,77 +1,127 @@
+--[[
-local UserAction = class("UserAction")
+Copyright (c) 2015 gameboxcloud.com
-local OnlineService = cc.load("online").service
+Permission is hereby granted, free of chargse, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
-function UserAction:ctor(connect)
- self.connect = connect
- self.online = OnlineService:create(connect)
-end
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
-function UserAction:loginAction(arg)
- local username = arg.username
- if not username then
- throw("not set argument: \"username\"")
- end
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+]]
- -- check username is exists
- if self.online:isExists(username) then
- throw("username \"%s\" is exists", username)
+local Online = cc.import("#online")
+local Session = cc.import("#session")
+
+local gbc = cc.import("#gbc")
+local UserAction = cc.class("UserAction", gbc.ActionBase)
+
+local _opensession
+
+function UserAction:signinAction(args)
+ local username = args.username
+ if not username then
+ cc.throw("not set argsument: \"username\"")
end
-- start session
- local session = self.connect:newSession()
+ local session = Session:new(self:getInstance():getRedis())
+ session:start()
session:set("username", username)
+ session:set("count", 0)
+ session:save()
- -- generating tag by session id
- local sid = session:getSid()
- local tag = ngx.md5(sid)
- session:set("tag", tag)
+ -- return result
+ return {sid = session:getSid(), count = 0}
+end
- -- set count value
- local count = 0
+function UserAction:signoutAction(args)
+ -- remove user from online list
+ local session = _opensession(self:getInstance(), args)
+ local online = Online:new(self:getInstance())
+ online:remove(session:get("username"))
+ -- delete session
+ session:destroy()
+ return {ok = "ok"}
+end
+
+function UserAction:countAction(args)
+ -- update count value in session
+ local session = _opensession(self:getInstance(), args)
+ local count = session:get("count")
+ count = count + 1
session:set("count", count)
session:save()
- -- return result
- return {sid = sid, tag = tag, count = count}
+ return {count = count}
end
-function UserAction:logoutAction(arg)
- local sid = arg.sid
+function UserAction:addjobAction(args)
+ local sid = args.sid
if not sid then
- throw("not set argument: \"sid\"")
+ cc.throw("not set argsument: \"sid\"")
end
- local session = self.connect:openSession(sid)
- if not session then
- throw("session is expired, or invalid session id")
+ local instance = self:getInstance()
+ local redis = instance:getRedis()
+ local session = Session:new(redis)
+ if not session:start(sid) then
+ cc.throw("session is expired, or invalid session id")
end
- -- close websocket connect
- self.connect:closeConnect(session:getConnectId())
- -- destroy session
- self.connect:destroySession()
- return {ok = "ok"}
+ local delay = cc.checkint(args.delay)
+ if delay <= 0 then
+ delay = 1
+ end
+ local message = args.message
+ if not message then
+ cc.throw("not set argument: \"message\"")
+ end
+
+ -- send message to job
+ local jobs = instance:getJobs()
+ local job = {
+ action = "/jobs/jobs.echo",
+ delay = delay,
+ data = {
+ username = session:get("username"),
+ message = message,
+ }
+ }
+ local ok, err = jobs:add(job)
+ if not ok then
+ return {err = err}
+ else
+ return {ok = "ok"}
+ end
end
-function UserAction:countAction(arg)
- local sid = arg.sid
+-- private
+
+_opensession = function(instance, args)
+ local sid = args.sid
if not sid then
- throw("not set argument: \"sid\"")
+ cc.throw("not set argsument: \"sid\"")
end
- local session = self.connect:openSession(sid)
- if not session then
- throw("session is expired, or invalid session id")
+ local session = Session:new(instance:getRedis())
+ if not session:start(sid) then
+ cc.throw("session is expired, or invalid session id")
end
- -- update count value
- local count = session:get("count")
- count = count + 1
- session:set("count", count)
- session:save()
- return {count = count}
+ return session
end
return UserAction
diff --git a/apps/welcome/conf/app_entry.conf b/apps/welcome/conf/app_entry.conf
new file mode 100644
index 0000000..bce0ea5
--- /dev/null
+++ b/apps/welcome/conf/app_entry.conf
@@ -0,0 +1,9 @@
+
+location / {
+ root '_APP_ROOT_/public_html';
+ index index.html;
+}
+
+location /welcome/ {
+ content_by_lua 'nginxBootstrap:runapp("_APP_ROOT_")';
+}
diff --git a/apps/welcome/jobs/JobsAction.lua b/apps/welcome/jobs/JobsAction.lua
new file mode 100644
index 0000000..3f7f8ce
--- /dev/null
+++ b/apps/welcome/jobs/JobsAction.lua
@@ -0,0 +1,21 @@
+
+local Online = cc.import("#online")
+
+local gbc = cc.import("#gbc")
+local JobsAction = cc.class("JobsAction", gbc.ActionBase)
+
+JobsAction.ACCEPTED_REQUEST_TYPE = "worker"
+
+function JobsAction:echoAction(job)
+ local username = job.data.username
+ local message = job.data.message
+
+ local online = Online:new(self:getInstance())
+ online:sendMessage(username, {
+ name = "MESSAGE",
+ sender = username,
+ body = string.format("'%s' do a job, message is '%s', delay is %d", username, message, job.delay),
+ })
+end
+
+return JobsAction
diff --git a/apps/welcome/packages/online/OnlineService.lua b/apps/welcome/packages/online/OnlineService.lua
deleted file mode 100644
index 46a2fff..0000000
--- a/apps/welcome/packages/online/OnlineService.lua
+++ /dev/null
@@ -1,96 +0,0 @@
-
-local ngx_null = ngx.null
-local table_map = table.map
-local json_encode = json.encode
-local json_decode = json.decode
-
-local OnlineService = class("OnlineService")
-
-OnlineService.USER_ADD_EVENT = "USER_ADD_EVENT"
-OnlineService.USER_REMOVE_EVENT = "USER_REMOVE_EVENT"
-
-local _ONLINE_KEY_PREFIX = "_OL_"
-local _ONLINE_SET = "_ONLINES_USERS"
-local _ONLINE_CHANNEL = "_ONLINES"
-
-local _ADD_MESSAGE = "add"
-local _REMOVE_MESSAGE = "remove"
-
-local function _key(username)
- return _ONLINE_KEY_PREFIX .. tostring(username)
-end
-
-function OnlineService:ctor(connect)
- cc.bind(self, "event")
- self.connect = connect
- self.redis = connect:getRedis()
- self.eventsEnabled = false
-end
-
-function OnlineService:setEventsEnabled(enabled)
- if self.eventsEnabled == enabled then return end
-
- self.eventsEnabled = enabled
- if enabled then
- self.connect:subscribeChannel(_ONLINE_CHANNEL, function(message)
- local message = json_decode(message)
- if message.name == _ADD_MESSAGE then
- self:dispatchEvent({name = OnlineService.USER_ADD_EVENT, username = message.username})
- elseif message.name == _REMOVE_MESSAGE then
- self:dispatchEvent({name = OnlineService.USER_REMOVE_EVENT, username = message.username})
- end
- end)
- else
- self.connect:unsubscribeChannel(_ONLINE_CHANNEL)
- end
-end
-
-function OnlineService:isExists(username)
- return tostring(self.redis:command("EXISTS", _key(username))) == "1"
-end
-
-function OnlineService:get(username)
- local res = self.redis:command("GET", _key(username))
- if not res then return {} end
- return json_decode(res)
-end
-
-function OnlineService:getAll()
- local usernames = self.redis:command("SMEMBERS", _ONLINE_SET)
- if #usernames == 0 then return {} end
-
- local keys = table_map(usernames, function(v, k)
- return _ONLINE_KEY_PREFIX .. v
- end)
- local all = self.redis:command("MGET", unpack(keys))
- local res = {}
- for i, username in ipairs(usernames) do
- if all[i] ~= ngx_null then
- local userdata = json_decode(all[i])
- res[#res + 1] = {username = username, tag = userdata.tag}
- end
- end
- return res
-end
-
-function OnlineService:getAllUsername()
- return self.redis:command("SMEMBERS", _ONLINE_SET)
-end
-
-function OnlineService:add(username, data)
- local pipe = self.redis:newPipeline()
- pipe:command("SET", _key(username), json_encode(data))
- pipe:command("SADD", _ONLINE_SET, username)
- pipe:command("PUBLISH", _ONLINE_CHANNEL, json_encode({name = _ADD_MESSAGE, username = username}))
- pipe:commit()
-end
-
-function OnlineService:remove(username)
- local pipe = self.redis:newPipeline()
- pipe:command("DEL", _key(username))
- pipe:command("SREM", _ONLINE_SET, username)
- pipe:command("PUBLISH", _ONLINE_CHANNEL, json_encode({name = _REMOVE_MESSAGE, username = username}))
- pipe:commit()
-end
-
-return OnlineService
diff --git a/apps/welcome/packages/online/init.lua b/apps/welcome/packages/online/init.lua
deleted file mode 100644
index 16a0381..0000000
--- a/apps/welcome/packages/online/init.lua
+++ /dev/null
@@ -1,6 +0,0 @@
-
-local _P = {}
-
-_P.service = import(".OnlineService")
-
-return _P
diff --git a/apps/welcome/packages/online/online.lua b/apps/welcome/packages/online/online.lua
new file mode 100644
index 0000000..1422dbd
--- /dev/null
+++ b/apps/welcome/packages/online/online.lua
@@ -0,0 +1,113 @@
+--[[
+
+Copyright (c) 2015 gameboxcloud.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+]]
+
+local string_format = string.format
+
+local json = cc.import("#json")
+local gbc = cc.import("#gbc")
+
+local Online = cc.class("Online")
+
+local _ONLINE_SET = "_ONLINE_USERS"
+local _ONLINE_CHANNEL = "_ONLINE_CHANNEL"
+local _EVENT = table.readonly({
+ ADD_USER = "ADD_USER",
+ REMOVE_USER = "REMOVE_USER",
+})
+local _CONNECT_TO_USERNAME = "_CONNECT_TO_USERNAME"
+local _USERNAME_TO_CONNECT = "_USERNAME_TO_CONNECT"
+
+function Online:ctor(instance)
+ self._instance = instance
+ self._redis = instance:getRedis()
+ self._broadcast = gbc.Broadcast:new(self._redis, instance.config.app.websocketMessageFormat)
+end
+
+function Online:getAll()
+ return self._redis:smembers(_ONLINE_SET)
+end
+
+function Online:add(username, connectId)
+ local redis = self._redis
+ redis:initPipeline()
+ -- map username <-> connect id
+ redis:hset(_CONNECT_TO_USERNAME, connectId, username)
+ redis:hset(_USERNAME_TO_CONNECT, username, connectId)
+ -- add username to set
+ redis:sadd(_ONLINE_SET, username)
+ -- send event to all clients
+ redis:publish(_ONLINE_CHANNEL, json.encode({name = _EVENT.ADD_USER, username = username}))
+ return redis:commitPipeline()
+end
+
+function Online:remove(username)
+ local redis = self._redis
+ local connectId, err = redis:hget(_USERNAME_TO_CONNECT, username)
+ if not connectId then
+ return nil, err
+ end
+ if connectId == redis.null then
+ return nil, string_format("not found username '%s'", username)
+ end
+
+ redis:initPipeline()
+ -- remove map
+ redis:hdel(_CONNECT_TO_USERNAME, connectId)
+ redis:hdel(_USERNAME_TO_CONNECT, username)
+ -- remove username from set
+ redis:srem(_ONLINE_SET, username)
+ redis:publish(_ONLINE_CHANNEL, json.encode({name = _EVENT.REMOVE_USER, username = username}))
+ local res, err = redis:commitPipeline()
+ if not res then
+ return nil, err
+ end
+
+ return self._broadcast:sendControlMessage(connectId, gbc.Constants.CLOSE_CONNECT)
+end
+
+function Online:getChannel()
+ return _ONLINE_CHANNEL
+end
+
+function Online:sendMessage(recipient, event)
+ local redis = self._redis
+ -- query connect id by recipient
+ local connectId, err = redis:hget(_USERNAME_TO_CONNECT, recipient)
+ if not connectId then
+ return nil, err
+ end
+
+ if connectId == redis.null then
+ return nil, string_format("not found recipient '%s'", recipient)
+ end
+
+ -- send message to connect id
+ return self._broadcast:sendMessage(connectId, event)
+end
+
+function Online:sendMessageToAll(event)
+ return self._broadcast:sendMessageToAll(event)
+end
+
+return Online
diff --git a/apps/welcome/public_html/css/chartist.custom.css b/apps/welcome/public_html/css/chartist.custom.css
deleted file mode 100644
index f274f0c..0000000
--- a/apps/welcome/public_html/css/chartist.custom.css
+++ /dev/null
@@ -1,13 +0,0 @@
-
-.ct-line {
- stroke: #c6f43d !important;
-}
-
-.ct-point {
- stroke: #79d105 !important;
- fill: #79d105 !important;
-}
-
-.ct-area {
- fill: #5bf04f !important;
-}
diff --git a/apps/welcome/public_html/css/chartist.min.css b/apps/welcome/public_html/css/chartist.min.css
deleted file mode 100644
index 1e0756d..0000000
--- a/apps/welcome/public_html/css/chartist.min.css
+++ /dev/null
@@ -1 +0,0 @@
-.ct-chart .ct-label,.ct-chart .ct-label.ct-horizontal{display:block;width:100%;height:100%;fill:rgba(0,0,0,.4);color:rgba(0,0,0,.4);font-size:.50rem;text-align:left}.ct-chart .ct-label.ct-vertical{display:block;width:100%;height:100%;fill:rgba(0,0,0,.4);color:rgba(0,0,0,.4);font-size:.50rem;text-align:right}.ct-chart .ct-grid{stroke:rgba(0,0,0,.2);stroke-width:1px;stroke-dasharray:2px}.ct-chart .ct-point{stroke-width:6px;stroke-linecap:round}.ct-chart .ct-line{fill:none;stroke-width:1px}.ct-chart .ct-area{stroke:none;fill-opacity:.1}.ct-chart .ct-bar{fill:none;stroke-width:10px}.ct-chart .ct-slice.ct-donut{fill:none;stroke-width:60px}.ct-chart .ct-series.ct-series-a .ct-bar,.ct-chart .ct-series.ct-series-a .ct-line,.ct-chart .ct-series.ct-series-a .ct-point,.ct-chart .ct-series.ct-series-a .ct-slice.ct-donut{stroke:#d70206}.ct-chart .ct-series.ct-series-a .ct-area,.ct-chart .ct-series.ct-series-a .ct-slice:not(.ct-donut){fill:#d70206}.ct-chart .ct-series.ct-series-b .ct-bar,.ct-chart .ct-series.ct-series-b .ct-line,.ct-chart .ct-series.ct-series-b .ct-point,.ct-chart .ct-series.ct-series-b .ct-slice.ct-donut{stroke:#f05b4f}.ct-chart .ct-series.ct-series-b .ct-area,.ct-chart .ct-series.ct-series-b .ct-slice:not(.ct-donut){fill:#f05b4f}.ct-chart .ct-series.ct-series-c .ct-bar,.ct-chart .ct-series.ct-series-c .ct-line,.ct-chart .ct-series.ct-series-c .ct-point,.ct-chart .ct-series.ct-series-c .ct-slice.ct-donut{stroke:#f4c63d}.ct-chart .ct-series.ct-series-c .ct-area,.ct-chart .ct-series.ct-series-c .ct-slice:not(.ct-donut){fill:#f4c63d}.ct-chart .ct-series.ct-series-d .ct-bar,.ct-chart .ct-series.ct-series-d .ct-line,.ct-chart .ct-series.ct-series-d .ct-point,.ct-chart .ct-series.ct-series-d .ct-slice.ct-donut{stroke:#d17905}.ct-chart .ct-series.ct-series-d .ct-area,.ct-chart .ct-series.ct-series-d .ct-slice:not(.ct-donut){fill:#d17905}.ct-chart .ct-series.ct-series-e .ct-bar,.ct-chart .ct-series.ct-series-e .ct-line,.ct-chart .ct-series.ct-series-e .ct-point,.ct-chart .ct-series.ct-series-e .ct-slice.ct-donut{stroke:#453d3f}.ct-chart .ct-series.ct-series-e .ct-area,.ct-chart .ct-series.ct-series-e .ct-slice:not(.ct-donut){fill:#453d3f}.ct-chart .ct-series.ct-series-f .ct-bar,.ct-chart .ct-series.ct-series-f .ct-line,.ct-chart .ct-series.ct-series-f .ct-point,.ct-chart .ct-series.ct-series-f .ct-slice.ct-donut{stroke:#59922b}.ct-chart .ct-series.ct-series-f .ct-area,.ct-chart .ct-series.ct-series-f .ct-slice:not(.ct-donut){fill:#59922b}.ct-chart .ct-series.ct-series-g .ct-bar,.ct-chart .ct-series.ct-series-g .ct-line,.ct-chart .ct-series.ct-series-g .ct-point,.ct-chart .ct-series.ct-series-g .ct-slice.ct-donut{stroke:#0544d3}.ct-chart .ct-series.ct-series-g .ct-area,.ct-chart .ct-series.ct-series-g .ct-slice:not(.ct-donut){fill:#0544d3}.ct-chart .ct-series.ct-series-h .ct-bar,.ct-chart .ct-series.ct-series-h .ct-line,.ct-chart .ct-series.ct-series-h .ct-point,.ct-chart .ct-series.ct-series-h .ct-slice.ct-donut{stroke:#6b0392}.ct-chart .ct-series.ct-series-h .ct-area,.ct-chart .ct-series.ct-series-h .ct-slice:not(.ct-donut){fill:#6b0392}.ct-chart .ct-series.ct-series-i .ct-bar,.ct-chart .ct-series.ct-series-i .ct-line,.ct-chart .ct-series.ct-series-i .ct-point,.ct-chart .ct-series.ct-series-i .ct-slice.ct-donut{stroke:#f05b4f}.ct-chart .ct-series.ct-series-i .ct-area,.ct-chart .ct-series.ct-series-i .ct-slice:not(.ct-donut){fill:#f05b4f}.ct-chart .ct-series.ct-series-j .ct-bar,.ct-chart .ct-series.ct-series-j .ct-line,.ct-chart .ct-series.ct-series-j .ct-point,.ct-chart .ct-series.ct-series-j .ct-slice.ct-donut{stroke:#dda458}.ct-chart .ct-series.ct-series-j .ct-area,.ct-chart .ct-series.ct-series-j .ct-slice:not(.ct-donut){fill:#dda458}.ct-chart .ct-series.ct-series-k .ct-bar,.ct-chart .ct-series.ct-series-k .ct-line,.ct-chart .ct-series.ct-series-k .ct-point,.ct-chart .ct-series.ct-series-k .ct-slice.ct-donut{stroke:#eacf7d}.ct-chart .ct-series.ct-series-k .ct-area,.ct-chart .ct-series.ct-series-k .ct-slice:not(.ct-donut){fill:#eacf7d}.ct-chart .ct-series.ct-series-l .ct-bar,.ct-chart .ct-series.ct-series-l .ct-line,.ct-chart .ct-series.ct-series-l .ct-point,.ct-chart .ct-series.ct-series-l .ct-slice.ct-donut{stroke:#86797d}.ct-chart .ct-series.ct-series-l .ct-area,.ct-chart .ct-series.ct-series-l .ct-slice:not(.ct-donut){fill:#86797d}.ct-chart .ct-series.ct-series-m .ct-bar,.ct-chart .ct-series.ct-series-m .ct-line,.ct-chart .ct-series.ct-series-m .ct-point,.ct-chart .ct-series.ct-series-m .ct-slice.ct-donut{stroke:#b2c326}.ct-chart .ct-series.ct-series-m .ct-area,.ct-chart .ct-series.ct-series-m .ct-slice:not(.ct-donut){fill:#b2c326}.ct-chart .ct-series.ct-series-n .ct-bar,.ct-chart .ct-series.ct-series-n .ct-line,.ct-chart .ct-series.ct-series-n .ct-point,.ct-chart .ct-series.ct-series-n .ct-slice.ct-donut{stroke:#6188e2}.ct-chart .ct-series.ct-series-n .ct-area,.ct-chart .ct-series.ct-series-n .ct-slice:not(.ct-donut){fill:#6188e2}.ct-chart .ct-series.ct-series-o .ct-bar,.ct-chart .ct-series.ct-series-o .ct-line,.ct-chart .ct-series.ct-series-o .ct-point,.ct-chart .ct-series.ct-series-o .ct-slice.ct-donut{stroke:#a748ca}.ct-chart .ct-series.ct-series-o .ct-area,.ct-chart .ct-series.ct-series-o .ct-slice:not(.ct-donut){fill:#a748ca}.ct-chart.ct-square{display:block;position:relative;width:100%}.ct-chart.ct-square:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:100%}.ct-chart.ct-square:after{content:"";display:table;clear:both}.ct-chart.ct-square>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-minor-second{display:block;position:relative;width:100%}.ct-chart.ct-minor-second:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:93.75%}.ct-chart.ct-minor-second:after{content:"";display:table;clear:both}.ct-chart.ct-minor-second>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-major-second{display:block;position:relative;width:100%}.ct-chart.ct-major-second:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:88.8888888889%}.ct-chart.ct-major-second:after{content:"";display:table;clear:both}.ct-chart.ct-major-second>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-minor-third{display:block;position:relative;width:100%}.ct-chart.ct-minor-third:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:83.3333333333%}.ct-chart.ct-minor-third:after{content:"";display:table;clear:both}.ct-chart.ct-minor-third>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-major-third{display:block;position:relative;width:100%}.ct-chart.ct-major-third:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:80%}.ct-chart.ct-major-third:after{content:"";display:table;clear:both}.ct-chart.ct-major-third>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-perfect-fourth{display:block;position:relative;width:100%}.ct-chart.ct-perfect-fourth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:75%}.ct-chart.ct-perfect-fourth:after{content:"";display:table;clear:both}.ct-chart.ct-perfect-fourth>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-perfect-fifth{display:block;position:relative;width:100%}.ct-chart.ct-perfect-fifth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:66.6666666667%}.ct-chart.ct-perfect-fifth:after{content:"";display:table;clear:both}.ct-chart.ct-perfect-fifth>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-minor-sixth{display:block;position:relative;width:100%}.ct-chart.ct-minor-sixth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:62.5%}.ct-chart.ct-minor-sixth:after{content:"";display:table;clear:both}.ct-chart.ct-minor-sixth>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-golden-section{display:block;position:relative;width:100%}.ct-chart.ct-golden-section:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:61.804697157%}.ct-chart.ct-golden-section:after{content:"";display:table;clear:both}.ct-chart.ct-golden-section>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-major-sixth{display:block;position:relative;width:100%}.ct-chart.ct-major-sixth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:60%}.ct-chart.ct-major-sixth:after{content:"";display:table;clear:both}.ct-chart.ct-major-sixth>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-minor-seventh{display:block;position:relative;width:100%}.ct-chart.ct-minor-seventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:56.25%}.ct-chart.ct-minor-seventh:after{content:"";display:table;clear:both}.ct-chart.ct-minor-seventh>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-major-seventh{display:block;position:relative;width:100%}.ct-chart.ct-major-seventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:53.3333333333%}.ct-chart.ct-major-seventh:after{content:"";display:table;clear:both}.ct-chart.ct-major-seventh>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-octave{display:block;position:relative;width:100%}.ct-chart.ct-octave:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:50%}.ct-chart.ct-octave:after{content:"";display:table;clear:both}.ct-chart.ct-octave>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-major-tenth{display:block;position:relative;width:100%}.ct-chart.ct-major-tenth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:40%}.ct-chart.ct-major-tenth:after{content:"";display:table;clear:both}.ct-chart.ct-major-tenth>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-major-eleventh{display:block;position:relative;width:100%}.ct-chart.ct-major-eleventh:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:37.5%}.ct-chart.ct-major-eleventh:after{content:"";display:table;clear:both}.ct-chart.ct-major-eleventh>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-major-twelfth{display:block;position:relative;width:100%}.ct-chart.ct-major-twelfth:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:33.3333333333%}.ct-chart.ct-major-twelfth:after{content:"";display:table;clear:both}.ct-chart.ct-major-twelfth>svg{display:block;position:absolute;top:0;left:0}.ct-chart.ct-double-octave{display:block;position:relative;width:100%}.ct-chart.ct-double-octave:before{display:block;float:left;content:"";width:0;height:0;padding-bottom:25%}.ct-chart.ct-double-octave:after{content:"";display:table;clear:both}.ct-chart.ct-double-octave>svg{display:block;position:absolute;top:0;left:0}
diff --git a/apps/welcome/public_html/css/custom.css b/apps/welcome/public_html/css/custom.css
index dfc9e9f..035cfdd 100644
--- a/apps/welcome/public_html/css/custom.css
+++ b/apps/welcome/public_html/css/custom.css
@@ -6,3 +6,17 @@ pre {
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
+
+article {
+ margin-top: 3em;
+ font-size: 110%;
+ line-height: 150%
+}
+
+article li {
+ margin-bottom: 1em;
+}
+
+article blockquote {
+ font-size: 85%;
+}
diff --git a/apps/welcome/public_html/css/notify.almost-flat.min.css b/apps/welcome/public_html/css/notify.almost-flat.min.css
new file mode 100644
index 0000000..5ad6fca
--- /dev/null
+++ b/apps/welcome/public_html/css/notify.almost-flat.min.css
@@ -0,0 +1,2 @@
+/*! UIkit 2.24.2 | http://www.getuikit.com | (c) 2014 YOOtheme | MIT License */
+.uk-notify{position:fixed;top:10px;left:10px;z-index:1040;box-sizing:border-box;width:350px}.uk-notify-bottom-right,.uk-notify-top-right{left:auto;right:10px}.uk-notify-bottom-center,.uk-notify-top-center{left:50%;margin-left:-175px}.uk-notify-bottom-center,.uk-notify-bottom-left,.uk-notify-bottom-right{top:auto;bottom:10px}@media (max-width:479px){.uk-notify{left:10px;right:10px;width:auto;margin:0}}.uk-notify-message{position:relative;margin-bottom:10px;padding:15px;background:#444;color:#fff;font-size:16px;line-height:22px;cursor:pointer;border:1px solid #444;border-radius:4px}.uk-notify-message>.uk-close{visibility:hidden;float:right}.uk-notify-message:hover>.uk-close{visibility:visible}.uk-notify-message-primary{background:#ebf7fd;color:#2d7091;border-color:rgba(45,112,145,.3)}.uk-notify-message-success{background:#f2fae3;color:#659f13;border-color:rgba(101,159,19,.3)}.uk-notify-message-warning{background:#fffceb;color:#e28327;border-color:rgba(226,131,39,.3)}.uk-notify-message-danger{background:#fff1f0;color:#d85030;border-color:rgba(216,80,48,.3)}
\ No newline at end of file
diff --git a/apps/welcome/public_html/tests.html b/apps/welcome/public_html/demo.html
similarity index 50%
rename from apps/welcome/public_html/tests.html
rename to apps/welcome/public_html/demo.html
index de89781..fec8261 100644
--- a/apps/welcome/public_html/tests.html
+++ b/apps/welcome/public_html/demo.html
@@ -11,34 +11,33 @@
-
-
+
-
-
+
+