├── .gitignore ├── README.md ├── app.js ├── config.js ├── controllers ├── cmd.js ├── cmd_page.js ├── home.js ├── index.js ├── stat.js └── stat_page.js ├── god.js ├── gulpfile.js ├── init.js ├── models └── db.js ├── monitor ├── monitor.js └── sentinel.js ├── package.json ├── public ├── favicon.png ├── main.css ├── main.js └── redis-logo.png ├── routes.js ├── screenshot ├── rsm-cmd.png ├── rsm-main-1.png ├── rsm-main-2.png ├── rsm-stat-1.png ├── rsm-stat-2.png └── rsm-stat-3.png ├── utils ├── cmdRespParser.js ├── logger.js ├── resp.js ├── staticServ.js ├── template.js └── time.js └── views ├── cmd.jade ├── footer.jade ├── header.jade ├── home.jade ├── layout.jade └── stat.jade /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | 3 | public/dist/ 4 | public/vendor/ 5 | .idea/ 6 | node_modules/ 7 | rsm.db 8 | npm-debug.log 9 | .vscode/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redis Sentinel UI 2 | 3 | ## 部署运行: 4 | 5 | 1. `git clone https://github.com/youngsterxyf/redis-sentinel-ui.git` 6 | 2. `cd redis-sentinel-ui && npm install` 7 | 3. `gulp default` 8 | 4. 数据库初始化: `node init.js` 9 | 5. 启动后台redis监控数据收集进程: `node god.js` 10 | 6. 启用web应用进程: `node app.js` 11 | 12 | ## 截图 13 | 14 | ![rsm-main-1](https://raw.github.com/youngsterxyf/redis-sentinel-ui/master/screenshot/rsm-main-1.png) 15 | ![rsm-main-2](https://raw.github.com/youngsterxyf/redis-sentinel-ui/master/screenshot/rsm-main-2.png) 16 | ![rsm-stat-1](https://raw.github.com/youngsterxyf/redis-sentinel-ui/master/screenshot/rsm-stat-1.png) 17 | ![rsm-stat-2](https://raw.github.com/youngsterxyf/redis-sentinel-ui/master/screenshot/rsm-stat-2.png) 18 | ![rsm-stat-3](https://raw.github.com/youngsterxyf/redis-sentinel-ui/master/screenshot/rsm-stat-3.png) 19 | ![rsm-cmd](https://raw.github.com/youngsterxyf/redis-sentinel-ui/master/screenshot/rsm-cmd.png) 20 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | // store global var 6 | global.RootDir = __dirname; 7 | 8 | let cluster = require('cluster'); 9 | let numCPUs = require('os').cpus().length; 10 | let http = require('http'); 11 | 12 | let config = require('./config'); 13 | let routes = require('./routes'); 14 | let Logger = require('./utils/logger'); 15 | 16 | // 配置检查 17 | if (!config.sentinels.length) { 18 | Logger.error('请配置sentinel服务器'); 19 | } 20 | 21 | if (cluster.isMaster) { 22 | // Fork workers. 23 | Logger.info('主进程id: %d', process.pid); 24 | for (let i = 0; i < numCPUs; i++) { 25 | cluster.fork(); 26 | } 27 | 28 | cluster.on('exit', function(worker, code, signal) { 29 | Logger.info('worker ' + worker.process.pid + ' died; code: ' + code + ', signal: ' + signal); 30 | cluster.fork(); 31 | }); 32 | } else { 33 | // Workers can share any TCP connection 34 | // In this case it is an HTTP server 35 | http.createServer(routes).listen(config.port); 36 | Logger.info('进程id:', process.pid, '监听端口:', config.port); 37 | } 38 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: config 3 | * @author: gejiawen 4 | * @date: 15/12/4 17:09 5 | * @description: 6 | * 7 | * configuration 8 | * 9 | */ 10 | 11 | 'use strict'; 12 | 13 | const PORT = 8080; 14 | const STORAGE_FILE_PATH = './rsm.db'; 15 | 16 | let configuration = { 17 | debug: false, 18 | log_level: 'debug', 19 | port: PORT, 20 | static_prefix: '/public/', 21 | sentinels: [ 22 | { 23 | host: '127.0.0.1', 24 | port: '26379' 25 | }, { 26 | host: '127.0.0.1', 27 | port: '26389' 28 | }, { 29 | host: '127.0.0.1', 30 | port: '26399' 31 | } 32 | ], 33 | master_name: 'mymaster', 34 | auth: null, 35 | storage_file: STORAGE_FILE_PATH, 36 | sentinel_status_interval: 30000, 37 | cluster_info_interval: 120000, 38 | server_stat: 5000 39 | }; 40 | 41 | 42 | /** 43 | * Module Exports 44 | */ 45 | 46 | module.exports = configuration; 47 | -------------------------------------------------------------------------------- /controllers/cmd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: cmd 3 | * @author: youngsterxyf, gejiawen 4 | * @date: 15/12/5 18:00 5 | * @description: cmd 6 | */ 7 | 8 | 'use strict'; 9 | 10 | let Redis = require('ioredis'); 11 | let ValidRedisCMDs = require('ioredis/commands'); 12 | 13 | let cmdRespParser = require('../utils/cmdRespParser'); 14 | let config = require('../config'); 15 | 16 | // 检测 命令 是否有效 17 | function isValidCommand(cmd) { 18 | cmd = cmd.toLowerCase(); 19 | return !!(cmd in ValidRedisCMDs); 20 | } 21 | 22 | /** 23 | * 24 | * @param req 25 | * @param res 26 | * @private 27 | */ 28 | 29 | function cmd(req, res) { 30 | /** 31 | * 请求参数: 32 | * 1. cmd: 大写的Redis命令 33 | * 2. params: 命令参数, 多个参数以空格分隔 34 | */ 35 | let cmd = req.body.cmd.toUpperCase(); 36 | if (!cmd || !isValidCommand(cmd)) { 37 | res.toResponse('参数cmd不合法!', 400); 38 | return; 39 | } 40 | let params = req.body.params; 41 | if (params === undefined) { 42 | res.toResponse('缺少必要的请求参数!', 400); 43 | return; 44 | } 45 | params = params.split(' '); 46 | 47 | let RedisServer = new Redis({ 48 | sentinels: config.sentinels, 49 | name: config.master_name, 50 | password: config.auth 51 | }); 52 | 53 | RedisServer[cmd.toLocaleLowerCase()].apply(RedisServer, params).then(function(result) { 54 | if (cmd === 'INFO') { 55 | result = cmdRespParser.infoRespParser(result.split('\r\n')); 56 | } 57 | RedisServer.disconnect(); 58 | 59 | res.toResponse(JSON.stringify(result, null, " ")); 60 | }, function(err) { 61 | res.toResponse(JSON.stringify(err, null, " ")); 62 | }); 63 | } 64 | 65 | /** 66 | * Module Exports 67 | */ 68 | module.exports = cmd; 69 | -------------------------------------------------------------------------------- /controllers/cmd_page.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let ValidRedisCMDs = require('ioredis/commands'); 4 | 5 | let Template = require('../utils/template'); 6 | 7 | function _cmd_page(req, res) { 8 | res.write(Template.render('views/cmd.jade', { 9 | cmdList: Object.getOwnPropertyNames(ValidRedisCMDs) 10 | })); 11 | res.end(); 12 | } 13 | 14 | module.exports = _cmd_page; 15 | -------------------------------------------------------------------------------- /controllers/home.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: home 3 | * @author: youngsterxyf, gejiawen 4 | * @date: 15/12/5 18:00 5 | * @description: home 6 | */ 7 | 8 | 'use strict'; 9 | 10 | let DB = require('../models/db'); 11 | let Template = require('../utils/template'); 12 | let Time = require('../utils/time'); 13 | let Logger = require('../utils/logger'); 14 | 15 | /** 16 | * `/home`控制器 17 | * @param req 18 | * @param res 19 | * @private 20 | */ 21 | function _home(req, res) { 22 | DB.getClusterInfo(function(err, result) { 23 | if (err) { 24 | Logger.error(err); 25 | 26 | res.statusCode = 500; 27 | res.write('系统异常,请联系管理员'); 28 | res.end(); 29 | return; 30 | } 31 | 32 | if (!result) { 33 | Logger.info('Has no cluster info'); 34 | 35 | result = {master: '{}', slaves: '{}', sentinels: '{}'}; 36 | } 37 | 38 | let clusterInfo = { 39 | master: JSON.parse(result.master), 40 | slaves: JSON.parse(result.slaves), 41 | sentinels: JSON.parse(result.sentinels) 42 | }; 43 | 44 | let allSentinel = []; 45 | let redisSentinels = clusterInfo.sentinels; 46 | 47 | if (redisSentinels) { 48 | Object.getOwnPropertyNames(redisSentinels).forEach(ele => { 49 | let thisSentinel = redisSentinels[ele]; 50 | allSentinel.push({ 51 | address: ele, 52 | version: thisSentinel.redis_version, 53 | process_id: thisSentinel.process_id, 54 | pending_cmds: thisSentinel["pending-commands"], 55 | uptime: Time.formatUpTime(thisSentinel.uptime_in_seconds) 56 | }); 57 | }); 58 | } 59 | 60 | let allRedis = []; 61 | 62 | let rawRedisServers = {}; 63 | let redisMaster = clusterInfo.master; 64 | if (redisMaster && Object.getOwnPropertyNames(redisMaster).length) { 65 | rawRedisServers[redisMaster.ip + ':' + redisMaster.port] = redisMaster; 66 | } 67 | let redisSlaves = clusterInfo.slaves; 68 | if (redisSlaves) { 69 | rawRedisServers = Object.assign(rawRedisServers, redisSlaves); 70 | } 71 | 72 | Object.getOwnPropertyNames(rawRedisServers).forEach(function(ele) { 73 | let thisRedisServer = rawRedisServers[ele]; 74 | 75 | let hitRate = 0; 76 | let keySpaceHits = parseInt(thisRedisServer.keyspace_hits); 77 | let keySpaceMisses = parseInt(thisRedisServer.keyspace_misses); 78 | let keySpaceHitMisses = keySpaceHits + keySpaceMisses; 79 | if (keySpaceHitMisses > 0) { 80 | hitRate = (keySpaceHits / keySpaceHitMisses).toFixed(3); 81 | } 82 | 83 | allRedis.push({ 84 | address: ele, 85 | role: thisRedisServer.role, 86 | version: thisRedisServer.redis_version, 87 | process_id: thisRedisServer.process_id, 88 | used_memory: thisRedisServer.used_memory_human, 89 | pending_cmds: thisRedisServer["pending-commands"], 90 | uptime: Time.formatUpTime(thisRedisServer.uptime_in_seconds), 91 | used_memory_peak: thisRedisServer.used_memory_peak_human, 92 | total_commands_processed: thisRedisServer.total_commands_processed, 93 | rejected_connections: thisRedisServer.rejected_connections, 94 | mem_fragmentation_ratio: thisRedisServer.mem_fragmentation_ratio, 95 | total_connections_received: thisRedisServer.total_connections_received, 96 | instantaneous_ops_per_sec: thisRedisServer.instantaneous_ops_per_sec, 97 | keyspace_hits: keySpaceHits, 98 | keyspace_misses: keySpaceMisses, 99 | hit_rate: hitRate, 100 | mem_allocator: thisRedisServer.mem_allocator, 101 | used_cpu_sys: thisRedisServer.used_cpu_sys, 102 | used_cpu_user: thisRedisServer.used_cpu_user, 103 | used_cpu_sys_children: thisRedisServer.used_cpu_sys_children, 104 | used_cpu_user_children: thisRedisServer.used_cpu_user_children 105 | }); 106 | }); 107 | 108 | let data = { 109 | sentinels: allSentinel, 110 | redises: allRedis 111 | }; 112 | 113 | res.write(Template.render('views/home.jade', data)); 114 | res.end(); 115 | }); 116 | } 117 | 118 | /** 119 | * Module Exports 120 | */ 121 | module.exports = _home; 122 | -------------------------------------------------------------------------------- /controllers/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: index 3 | * @author: gejiawen 4 | * @date: 15/12/5 16:52 5 | * @description: index 6 | */ 7 | 8 | 'use strict'; 9 | 10 | exports.Home = require('./home'); 11 | exports.Cmd = require('./cmd'); 12 | exports.Stat = require('./stat'); 13 | exports.Cmd_page = require('./cmd_page'); 14 | exports.Stat_page = require('./stat_page'); 15 | -------------------------------------------------------------------------------- /controllers/stat.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by xiayf on 15/12/7. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | let DB = require('../models/db'); 8 | 9 | let StatMapper = { 10 | 'connected_client': DB.getRangeConnectedClient, 11 | 'used_memory': DB.getRangeUsedMemory, 12 | 'cmd_ps': DB.getRangeCMDPS 13 | }; 14 | 15 | let graphTypeMapper = { 16 | 'connected_client': { 17 | type: 'spline', 18 | yAxis: { 19 | allowDecimals: false 20 | }, 21 | value_type: 'int' 22 | }, 23 | 'used_memory': { 24 | type: 'area', 25 | value_type: 'float' 26 | }, 27 | 'cmd_ps': { 28 | type: 'spline', 29 | yAxis: { 30 | allowDecimals: false 31 | }, 32 | value_type: 'int' 33 | } 34 | }; 35 | 36 | let reduceAlgoMapper = { 37 | default: null, 38 | by_ave: _byAverage, 39 | by_max: _byMax 40 | }; 41 | 42 | let DATA_POINT_THRESHOLD = 1000; 43 | 44 | function _checkStatName(statName) { 45 | return !!(statName in StatMapper); 46 | } 47 | 48 | function _checkReduceWay(reduceWay) { 49 | return !!(reduceWay in reduceAlgoMapper); 50 | } 51 | 52 | function _diffTime(begin, end) { 53 | let beginTimestamp = Date.parse(begin); 54 | let endTimestamp = Date.parse(end); 55 | return (endTimestamp - beginTimestamp) / 1000; 56 | } 57 | 58 | function _checkStatTime(begin, end) { 59 | // 时间戳格式: "xxxx-xx-xx xx:xx:xx" 60 | let regexPattern = /\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}/; 61 | let timeDiff = _diffTime(begin, end); 62 | 63 | if (begin.match(regexPattern) === null || end.match(regexPattern) === null || timeDiff <= 0) { 64 | return false; 65 | } 66 | return true; 67 | } 68 | 69 | function _checkStatTimeRange(begin, end, reduceWay) { 70 | let timeDiff = _diffTime(begin, end); 71 | 72 | if (reduceWay === 'default') { 73 | // 考虑性能, 限制只能获取3天范围的数据 74 | return timeDiff <= 3 * 24 * 3600; 75 | } else { 76 | return timeDiff <= 30 * 24 * 3600; 77 | } 78 | } 79 | 80 | function _findReduceFactor(length) { 81 | if (length < DATA_POINT_THRESHOLD * 2) { 82 | return 0; 83 | } 84 | return Math.floor(length / DATA_POINT_THRESHOLD); 85 | } 86 | 87 | /** 88 | * 最大值化处理 89 | * @param rangeDataSet 90 | * @param beginIndex 91 | * @param reduceFactor 92 | * @param valueType 93 | * @returns {*[]} 94 | * @private 95 | */ 96 | function _byMax(rangeDataSet, beginIndex, reduceFactor, valueType) { 97 | let rangeMax = null; 98 | let upLimit = beginIndex + reduceFactor; 99 | for(let index = beginIndex; index < upLimit; index++) { 100 | let thisDataPoint = rangeDataSet[index]; 101 | if (rangeMax === null) { 102 | rangeMax = thisDataPoint; 103 | continue; 104 | } 105 | if (thisDataPoint.value > rangeMax.value) { 106 | rangeMax = thisDataPoint; 107 | } 108 | } 109 | return [rangeMax.created_time, rangeMax.value]; 110 | } 111 | 112 | /** 113 | * 均值化处理 114 | * @param rangeDataSet 115 | * @param beginIndex 116 | * @param reduceFactor 117 | * @param valueType 118 | * @returns {*[]} 119 | * @private 120 | */ 121 | function _byAverage(rangeDataSet, beginIndex, reduceFactor, valueType) { 122 | let valueSum = 0; 123 | let upLimit = beginIndex + reduceFactor; 124 | for(let index = beginIndex; index < upLimit; index++) { 125 | valueSum += rangeDataSet[index].value; 126 | } 127 | let aveValue = null; 128 | if (valueType === 'int') { 129 | aveValue = Math.ceil(valueSum/reduceFactor); 130 | } else { 131 | aveValue = parseFloat((valueSum / reduceFactor).toFixed(3)); 132 | } 133 | return [rangeDataSet[beginIndex].created_time, aveValue]; 134 | } 135 | 136 | /** 137 | * 按选定算法对数据进行处理 138 | * @param dataSet 139 | * @param algorithm 140 | * @param valueType 141 | * @returns {Array} 142 | * @private 143 | */ 144 | function _reduceDataSet(dataSet, algorithm, valueType) { 145 | let dataSetLength = dataSet.length; 146 | let reduceFactor = _findReduceFactor(dataSetLength); 147 | if (reduceFactor === 0) { 148 | return _justFormatDataSet(dataSet); 149 | } 150 | let reducedDataSet = []; 151 | let lastIndex = dataSetLength - (dataSetLength % reduceFactor); 152 | for(let index = 0; index < lastIndex; index = index+reduceFactor) { 153 | reducedDataSet.push(algorithm(dataSet, index, reduceFactor, valueType)); 154 | } 155 | // 156 | let hasIteratedLength = reducedDataSet.length * reduceFactor; 157 | if (hasIteratedLength < dataSetLength) { 158 | for(let otherIndex = hasIteratedLength; otherIndex < dataSetLength; otherIndex++) { 159 | reducedDataSet.push([dataSet[otherIndex].created_time, dataSet[otherIndex].value]); 160 | } 161 | } 162 | return reducedDataSet; 163 | } 164 | 165 | /** 166 | * 对数据集进行格式转换 167 | * @param dataSet 168 | * @returns {Array} 169 | * @private 170 | */ 171 | function _justFormatDataSet(dataSet) { 172 | let formatedDataSet = []; 173 | dataSet.forEach(data => { 174 | formatedDataSet.push([data.created_time, data.value]); 175 | }); 176 | return formatedDataSet; 177 | } 178 | 179 | function _stat(req, res) { 180 | /** 181 | * 请求参数: 182 | * - name: 指标名称 183 | * - server: 目标服务器 184 | * - begin_time: 开始时间 185 | * - end_time: 截止时间 186 | * - reduce_way: 数据处理方式 187 | */ 188 | let statName = req.body.name; 189 | let targetServers = req.body.servers; 190 | let statBeginTime = req.body.begin_time; 191 | let statEndTime = req.body.end_time; 192 | let reduceWay = req.body.reduce_way; 193 | 194 | if (statName === undefined || targetServers === undefined 195 | || statBeginTime === undefined || statEndTime === undefined) { 196 | res.toResponse('缺少必要的请求参数!', 400); 197 | return; 198 | } 199 | if (_checkStatName(statName) === false) { 200 | res.toResponse('参数name不合法!', 400); 201 | return; 202 | } 203 | if (_checkStatTime(statBeginTime, statEndTime) === false) { 204 | res.toResponse('参数statBeginTime或statEndTime不合法!', 400); 205 | return; 206 | } 207 | if (_checkReduceWay(reduceWay) === false) { 208 | res.toResponse('数据聚合方式不合法!', 400); 209 | return; 210 | } 211 | if ( _checkStatTimeRange(statBeginTime, statEndTime, reduceWay) === false) { 212 | res.toResponse('时间范围不合法!', 400); 213 | return; 214 | } 215 | targetServers = targetServers.split(','); 216 | StatMapper[statName](targetServers, statBeginTime, statEndTime, function (err, result) { 217 | if (err) { 218 | res.toResponse(err.message, 500); 219 | return; 220 | } 221 | if (!result) { 222 | res.toResponse(JSON.stringify([])); 223 | return; 224 | } 225 | 226 | let targetSeriesData = {}; 227 | 228 | result.forEach(record => { 229 | if (!(record.server in targetSeriesData)) { 230 | targetSeriesData[record.server] = []; 231 | } 232 | targetSeriesData[record.server].push({created_time: Date.parse(record.created_time), value: record.value}); 233 | }); 234 | let respData = { 235 | xAxis: graphTypeMapper[statName].xAxis ? graphTypeMapper[statName].xAxis : null, 236 | yAxis: graphTypeMapper[statName].yAxis ? graphTypeMapper[statName].yAxis : null, 237 | series: [] 238 | }; 239 | Object.getOwnPropertyNames(targetSeriesData).forEach(server => { 240 | let mySeriesData = null; 241 | if (reduceWay === 'default') { 242 | mySeriesData = _justFormatDataSet(targetSeriesData[server]); 243 | } else { 244 | mySeriesData = _reduceDataSet(targetSeriesData[server], reduceAlgoMapper[reduceWay], 245 | graphTypeMapper[statName].value_type); 246 | } 247 | 248 | respData.series.push({ 249 | name: server, 250 | type: graphTypeMapper[statName].type, 251 | marker: { 252 | enabled: false 253 | }, 254 | lineWidth: 1.5, 255 | data: mySeriesData 256 | }); 257 | }); 258 | 259 | res.toResponse(JSON.stringify(respData)); 260 | }); 261 | } 262 | 263 | module.exports = _stat; 264 | -------------------------------------------------------------------------------- /controllers/stat_page.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let Template = require('../utils/template'); 4 | let DB = require('../models/db'); 5 | let Logger = require('../utils/logger'); 6 | 7 | let serverIndexs = { 8 | cmd_ps: '每秒处理命令数', 9 | connected_client: '客户端连接数', 10 | used_memory: '内存使用量' 11 | }; 12 | 13 | let reduceWays = { 14 | default: '不聚合(时间范围大时慎重使用)', 15 | by_max: '最大值', 16 | by_ave: '均值' 17 | }; 18 | 19 | function _stat_page(req, res) { 20 | DB.getClusterInfo(function (err, result) { 21 | if (err) { 22 | Logger.error(err); 23 | 24 | res.statusCode = 500; 25 | res.write('系统异常,请联系管理员'); 26 | res.end(); 27 | return; 28 | } 29 | 30 | let redisServers = []; 31 | let redisMaster = JSON.parse(result.master); 32 | let redisSlaves = JSON.parse(result.slaves); 33 | 34 | redisServers.push(redisMaster.ip + ':' + redisMaster.port); 35 | Object.getOwnPropertyNames(redisSlaves).forEach(slave => { 36 | redisServers.push(slave); 37 | }); 38 | 39 | res.write(Template.render('views/stat.jade', { 40 | servers: redisServers, 41 | indexs: serverIndexs, 42 | ways: reduceWays 43 | })); 44 | res.end(); 45 | }); 46 | } 47 | 48 | module.exports = _stat_page; 49 | -------------------------------------------------------------------------------- /god.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | let child_process = require('child_process'); 6 | 7 | let monitor = child_process.fork('./monitor/monitor.js'); 8 | 9 | let exitCallback = function (code, signal) { 10 | console.log('monitor.js, code: ' + code + ', signal: ' + signal); 11 | if (signal === 'SIGTERM') { 12 | return; 13 | } 14 | monitor = child_process.fork('./monitor/monitor.js'); 15 | monitor.on('exit', exitCallback); 16 | }; 17 | 18 | monitor.on('exit', exitCallback); 19 | 20 | process.on('SIGTERM', function () { 21 | console.log('god.js, SIGTERM...'); 22 | monitor.kill(); 23 | }); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by xiayf on 15/12/4. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | const gulp = require('gulp'); 8 | const browserify = require('gulp-browserify'); 9 | 10 | gulp.task('browserify', () => { 11 | // browserify编译 12 | gulp.src('./public/main.js') 13 | .pipe(browserify()) 14 | .pipe(gulp.dest('./public/dist')); 15 | }); 16 | 17 | gulp.task('clone', () => { 18 | gulp.src([ 19 | './node_modules/bootstrap/dist/css/**/*.*', 20 | './node_modules/bootstrap/dist/fonts/**/*.*' 21 | ], {base: './node_modules/bootstrap/dist'}) 22 | .pipe(gulp.dest('./public/vendor/bootstrap')); 23 | 24 | gulp.src([ 25 | './node_modules/cn-bootstrap-datetimepicker/build/css/bootstrap-datetimepicker.min.css' 26 | ], {base: './node_modules/cn-bootstrap-datetimepicker/build/css'}) 27 | .pipe(gulp.dest('./public/vendor')); 28 | }); 29 | 30 | gulp.task('default', ['browserify', 'clone']); 31 | -------------------------------------------------------------------------------- /init.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Created by xiayf on 15/12/24. 4 | */ 5 | 6 | 'use strict'; 7 | 8 | let config = require('./config'); 9 | let sqlite3 = require('sqlite3').verbose(); 10 | 11 | let db = new sqlite3.Database(config.storage_file); 12 | 13 | /** 14 | * 初始化sqlite数据表 15 | */ 16 | let create_sentinels_sql = ` 17 | CREATE TABLE IF NOT EXISTS sentinels ( 18 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 19 | sentinel TEXT NOT NULL UNIQUE, 20 | status TEXT NOT NULL DEFAULT 'OFF' 21 | ); 22 | `; 23 | db.serialize(function() { 24 | db.run(create_sentinels_sql); 25 | db.run('DELETE FROM sentinels'); 26 | }); 27 | 28 | let create_clusterinfo_sql = ` 29 | CREATE TABLE IF NOT EXISTS cluster_info ( 30 | master_name TEXT NOT NULL UNIQUE, 31 | master TEXT NOT NULL DEFAULT '{}', 32 | slaves TEXT NOT NULL DEFAULT '{}', 33 | sentinels TEXT NOT NULL DEFAULT '{}' 34 | ) 35 | `; 36 | db.serialize(function() { 37 | db.run(create_clusterinfo_sql); 38 | db.run('DELETE FROM cluster_info'); 39 | db.run('INSERT INTO `cluster_info` (`master_name`) VALUES (?)', config.master_name); 40 | }); 41 | 42 | let create_connected_client = ` 43 | CREATE TABLE IF NOT EXISTS connected_client ( 44 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 45 | server TEXT NOT NULL, 46 | client_num INTEGER NOT NULL, 47 | created_time NOT NULL DEFAULT (datetime('now','localtime')) 48 | ); 49 | `; 50 | db.run(create_connected_client); 51 | // 手动添加个索引吧 52 | // CREATE INDEX connected_client_server_idx ON connected_client (server); 53 | 54 | let create_used_memory = ` 55 | CREATE TABLE IF NOT EXISTS used_memory ( 56 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 57 | server TEXT NOT NULL, 58 | used_memory REAL NOT NULL, 59 | created_time NOT NULL DEFAULT (datetime('now','localtime')) 60 | ); 61 | `; 62 | db.run(create_used_memory); 63 | // 手动添加个索引吧 64 | // CREATE INDEX used_memory_server_idx ON used_memory (server); 65 | 66 | let create_cmd_per_second = ` 67 | CREATE TABLE IF NOT EXISTS cmd_ps ( 68 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 69 | server TEXT NOT NULL, 70 | cmd_ps INTEGER NOT NULL, 71 | created_time NOT NULL DEFAULT (datetime('now','localtime')) 72 | ) 73 | `; 74 | db.run(create_cmd_per_second); 75 | // 手动添加索引 76 | // CREATE INDEX cmd_ps_server_idx ON cmd_ps (server); 77 | 78 | db.close(); -------------------------------------------------------------------------------- /models/db.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: db 3 | * @author: youngsterxyf, gejiawen 4 | * @date: 15/12/5 16:56 5 | * @description: 6 | * 7 | * 连接sqlite数据的model层 8 | * 9 | */ 10 | 11 | 'use strict'; 12 | 13 | let config = require('../config'); 14 | let sqlite3 = require('sqlite3').verbose(); 15 | 16 | // 只读模式 17 | let db = new sqlite3.Database(config.storage_file, sqlite3.OPEN_READONLY); 18 | 19 | /** 20 | * 从数据库中获取某个sentinel的状态 21 | * 22 | * @param sentinel_addr 23 | * @param callback 24 | * @private 25 | */ 26 | function _getSentinelPreviousStatus(sentinel_addr, callback) { 27 | db.get('SELECT status FROM `sentinels` WHERE sentinel=?', 28 | sentinel_addr, 29 | callback 30 | ); 31 | } 32 | 33 | /** 34 | * 从数据库中随便获取一个可用sentinel的地址 35 | * 36 | * @param callback 37 | * @private 38 | */ 39 | function _getActiveSentinel(callback) { 40 | db.get('SELECT sentinel FROM `sentinels` WHERE status="ON"', 41 | callback 42 | ); 43 | } 44 | 45 | function _getClusterInfo(callback) { 46 | let masterName = config.master_name; 47 | db.get('SELECT master, slaves, sentinels FROM `cluster_info` WHERE master_name=?', 48 | masterName, callback 49 | ); 50 | } 51 | 52 | function _getRangeConnectedClient(servers, beginTime, endTime, callback) { 53 | let serverCount = servers.length; 54 | 55 | let sql = 'SELECT client_num AS value, server, created_time FROM `connected_client` WHERE server IN (?'; 56 | while (serverCount > 1) { 57 | sql = sql + ', ?'; 58 | serverCount = serverCount - 1; 59 | } 60 | sql += ') AND created_time>=? AND created_time<=? ORDER BY created_time'; 61 | 62 | let stmtParams = servers; 63 | stmtParams.unshift(sql); 64 | stmtParams.push(beginTime); 65 | stmtParams.push(endTime); 66 | stmtParams.push(callback); 67 | 68 | db.all.apply(db, stmtParams); 69 | } 70 | 71 | function _getRangeUsedMemory(servers, beginTime, endTime, callback) { 72 | let serverCount = servers.length; 73 | 74 | let sql = 'SELECT used_memory AS value, server, created_time FROM `used_memory` WHERE server IN (?'; 75 | while (serverCount > 1) { 76 | sql = sql + ', ?'; 77 | serverCount = serverCount - 1; 78 | } 79 | sql += ') AND created_time>=? AND created_time<=? ORDER BY created_time'; 80 | 81 | let stmtParams = servers; 82 | stmtParams.unshift(sql); 83 | stmtParams.push(beginTime); 84 | stmtParams.push(endTime); 85 | stmtParams.push(callback); 86 | 87 | db.all.apply(db, stmtParams); 88 | } 89 | 90 | function _getRangeCMDPS(servers, beginTime, endTime, callback) { 91 | let serverCount = servers.length; 92 | 93 | let sql = 'SELECT cmd_ps AS value, server, created_time FROM `cmd_ps` WHERE server IN (?'; 94 | while (serverCount > 1) { 95 | sql = sql + ', ?'; 96 | serverCount = serverCount - 1; 97 | } 98 | sql += ') AND created_time>=? AND created_time<=? ORDER BY created_time'; 99 | 100 | let stmtParams = servers; 101 | stmtParams.unshift(sql); 102 | stmtParams.push(beginTime); 103 | stmtParams.push(endTime); 104 | stmtParams.push(callback); 105 | 106 | db.all.apply(db, stmtParams); 107 | } 108 | 109 | /** 110 | * Module Exports 111 | */ 112 | exports.getPrev = _getSentinelPreviousStatus; 113 | exports.getActive = _getActiveSentinel; 114 | exports.getClusterInfo = _getClusterInfo; 115 | exports.getRangeConnectedClient = _getRangeConnectedClient; 116 | exports.getRangeUsedMemory = _getRangeUsedMemory; 117 | exports.getRangeCMDPS = _getRangeCMDPS; 118 | -------------------------------------------------------------------------------- /monitor/monitor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by xiayf on 15/12/24. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | let RedisSentinel = require('./sentinel'); 8 | let config = require('../config'); 9 | 10 | // 先获取一下 11 | setTimeout(RedisSentinel.update_sentinel_status, 3000); 12 | 13 | // 先执行一下,但得先等RedisSentinel.update_sentinel_status()执行完才能执行 14 | setTimeout(RedisSentinel.fetch_cluster_status, 5000); 15 | 16 | setInterval(RedisSentinel.update_sentinel_status, config.sentinel_status_interval); 17 | setInterval(RedisSentinel.fetch_cluster_status, config.cluster_info_interval); 18 | setInterval(RedisSentinel.collect_server_info, config.server_stat); -------------------------------------------------------------------------------- /monitor/sentinel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: sentinel 3 | * @author: youngsterxyf, gejiawen 4 | * @date: 15/12/5 17:13 5 | * @description: 6 | * 7 | * redis sentinel model layout 8 | */ 9 | 10 | 'use strict'; 11 | 12 | let Redis = require('ioredis'); 13 | let StdUtil = require('util'); 14 | let sqlite3 = require('sqlite3').verbose(); 15 | let config = require('../config'); 16 | let cmdRespParser = require('../utils/cmdRespParser'); 17 | let Logger = require('../utils/logger'); 18 | 19 | // 模式默认:OPEN_READWRITE | OPEN_CREATE 20 | let db = new sqlite3.Database(config.storage_file); 21 | 22 | /** 23 | * 更新数据库中sentinel的状态 24 | * 25 | * @param sentinel_addr 26 | * @param status 27 | * @param callback 28 | * @private 29 | */ 30 | let updateSentinelStatus = function (sentinel_addr, status, callback) { 31 | db.run('REPLACE INTO `sentinels` (`sentinel`, `status`) VALUES (?, ?)', 32 | sentinel_addr, 33 | status, 34 | callback 35 | ); 36 | }; 37 | 38 | let saveClusterPart = function (partData, partName) { 39 | partData = JSON.stringify(partData); 40 | let masterName = config.master_name; 41 | let sql = StdUtil.format('UPDATE `cluster_info` SET `%s`=? WHERE `master_name`=?', partName); 42 | db.run(sql, partData, masterName); 43 | }; 44 | 45 | let addNewConnectedClient = function (server, clientNum) { 46 | db.run('INSERT INTO `connected_client` (`server`, `client_num`) VALUES (?, ?)', 47 | server, clientNum); 48 | }; 49 | 50 | let addNewUsedMemory = function (server, usedMemory) { 51 | db.run('INSERT INTO `used_memory` (`server`, `used_memory`) VALUES (?, ?)', 52 | server, usedMemory); 53 | }; 54 | 55 | let addNewCMDPS = function (server, cmd_ps) { 56 | db.run('INSERT INTO `cmd_ps` (`server`, `cmd_ps`) VALUES (?, ?)', server, cmd_ps); 57 | }; 58 | 59 | 60 | 61 | // 1M = 1024 * 1024; 62 | const oneM = 1048576; 63 | 64 | // 存储Sentinel状态 65 | let AllSentinelStatus = {}; 66 | // 存储Sentinel的连接对象 67 | let RedisSentinels = []; 68 | // 存储Redis的连接对象 69 | let RedisServers = []; 70 | // Sentinel集群信息 71 | let ClusterInfo = { 72 | master: null, 73 | slaves: null, 74 | sentinels: null 75 | }; 76 | 77 | config.sentinels.forEach(val => { 78 | RedisSentinels.push(new Redis({ 79 | host: val.host, 80 | port: val.port 81 | })); 82 | }); 83 | 84 | /** 85 | * 解析sentinel命令的结果 86 | * 87 | * @param result 88 | * @returns {{}} 89 | */ 90 | function _parseSentinelSingle(result) { 91 | let mapper = {}; 92 | 93 | for (let start = 0, end = result.length - 1; start < end; start += 2) { 94 | mapper[result[start]] = result[start + 1]; 95 | } 96 | 97 | return mapper; 98 | } 99 | 100 | /** 101 | * 102 | * @param result 103 | * @returns {{}} 104 | */ 105 | function _parseSentinelMulti(result) { 106 | let multiMapper = {}; 107 | 108 | for (let start = 0, end = result.length; start < end; start++) { 109 | let parsedResult = _parseSentinelSingle(result[start]); 110 | let serverAddr = parsedResult.ip + ':' + parsedResult.port; 111 | multiMapper[serverAddr] = parsedResult; 112 | } 113 | 114 | return multiMapper; 115 | } 116 | 117 | /** 118 | * 119 | * @param first 120 | * @param second 121 | * @returns {*} 122 | */ 123 | function _mergeObject(first, second) { 124 | first = first || {}; 125 | Object.getOwnPropertyNames(second).forEach(val => { 126 | first[val] = second[val]; 127 | }); 128 | 129 | return first; 130 | } 131 | 132 | /** 133 | * 134 | * @param host 135 | * @param port 136 | * @param group 137 | */ 138 | function _connAndInfo(host, port, group) { 139 | let redisServer = new Redis({ 140 | host: host, 141 | port: port, 142 | password: group === 'sentinels' ? null : config.auth 143 | }); 144 | 145 | redisServer.info().then(resp => { 146 | let parsedResp = cmdRespParser.infoRespParser(resp.split('\r\n')); 147 | let addr = host + ':' + port; 148 | 149 | if (group === 'master') { 150 | ClusterInfo[group] = _mergeObject(ClusterInfo[group], parsedResp); 151 | } else { 152 | ClusterInfo[group][addr] = _mergeObject(ClusterInfo[group][addr], parsedResp); 153 | } 154 | 155 | // 同步到数据库 156 | saveClusterPart(ClusterInfo[group], group); 157 | // 158 | redisServer.disconnect(); 159 | }); 160 | 161 | if (group !== 'sentinels') { 162 | let serverAddr = host + ':' + port; 163 | if (RedisServers.indexOf(serverAddr) === -1) { 164 | RedisServers.push(serverAddr); 165 | } 166 | } 167 | } 168 | 169 | /** 170 | * 获取集群的信息(包含当前主Redis的信息, 所有从Redis的信息, 以及所有Sentinel的信息) 171 | * 172 | * @private 173 | */ 174 | function _fetchClusterInfo() { 175 | let activeSentinel = null; 176 | 177 | let sentinelAddrs = Object.getOwnPropertyNames(AllSentinelStatus), 178 | sentinelNum = sentinelAddrs.length, 179 | sentinelIndex = 0; 180 | while (sentinelIndex < sentinelNum) { 181 | if (AllSentinelStatus[sentinelAddrs[sentinelIndex]] === 'ON') { 182 | activeSentinel = sentinelAddrs[sentinelIndex]; 183 | break; 184 | } 185 | sentinelIndex++; 186 | } 187 | if (activeSentinel === null) { 188 | Logger.error('Now has no active sentinel'); 189 | return; 190 | } 191 | 192 | let sentinelInfo = activeSentinel.split(':'), 193 | sentinelInstance = new Redis({ 194 | host: sentinelInfo[0], 195 | port: sentinelInfo[1] 196 | }), 197 | countSign = 3; 198 | 199 | sentinelInstance.sentinel('master', config.master_name, (err, result) => { 200 | if (err) { 201 | Logger.error(err); 202 | return; 203 | } 204 | ClusterInfo.master = _parseSentinelSingle(result); 205 | 206 | /** 207 | * 创建到主Redis的连接,并查询其基本信息 208 | */ 209 | _connAndInfo(ClusterInfo.master.ip, ClusterInfo.master.port, 'master'); 210 | // 211 | if (countSign === 1) { 212 | sentinelInstance.disconnect(); 213 | } else { 214 | countSign -= 1; 215 | } 216 | }); 217 | 218 | sentinelInstance.sentinel('slaves', config.master_name, (err, result) => { 219 | if (err) { 220 | Logger.error(err); 221 | return; 222 | } 223 | ClusterInfo.slaves = _parseSentinelMulti(result); 224 | // 创建到从Redis的连接并查询其信息 225 | Object.getOwnPropertyNames(ClusterInfo.slaves).forEach(val => { 226 | let slave = ClusterInfo.slaves[val]; 227 | _connAndInfo(slave.ip, slave.port, 'slaves'); 228 | }); 229 | // 230 | if (countSign === 1) { 231 | sentinelInstance.disconnect(); 232 | } else { 233 | countSign -= 1; 234 | } 235 | }); 236 | 237 | sentinelInstance.sentinel('sentinels', config.master_name, (err, result) => { 238 | if (err) { 239 | console.error(err); 240 | return; 241 | } 242 | 243 | let parsedResultNoMe = _parseSentinelMulti(result); 244 | 245 | // 246 | let otherSentinelAddrs = Object.getOwnPropertyNames(parsedResultNoMe); 247 | if (otherSentinelAddrs.length) { 248 | let selectedAnotherSentinel = otherSentinelAddrs[0].split(':'), 249 | anotherSentinelInstance = new Redis({ 250 | host: selectedAnotherSentinel[0], 251 | port: selectedAnotherSentinel[1] 252 | }); 253 | 254 | anotherSentinelInstance.sentinel('sentinels', config.master_name, (err, result) => { 255 | if (err) { 256 | console.error(err); 257 | return; 258 | } 259 | 260 | ClusterInfo.sentinels = _mergeObject(parsedResultNoMe, _parseSentinelMulti(result)); 261 | Object.getOwnPropertyNames(ClusterInfo.sentinels).forEach(val => { 262 | let sentinel = ClusterInfo.sentinels[val]; 263 | _connAndInfo(sentinel.ip, sentinel.port, 'sentinels'); 264 | }); 265 | // 266 | anotherSentinelInstance.disconnect(); 267 | }); 268 | } else { 269 | ClusterInfo.sentinels = parsedResultNoMe; 270 | Object.getOwnPropertyNames(ClusterInfo.sentinels).forEach(val => { 271 | let sentinel = ClusterInfo.sentinels[val]; 272 | _connAndInfo(sentinel.ip, sentinel.port, 'sentinels'); 273 | }); 274 | } 275 | // 276 | if (countSign === 1) { 277 | sentinelInstance.disconnect(); 278 | } else { 279 | countSign -= 1; 280 | } 281 | }); 282 | } 283 | 284 | // 检查所有sentinel是否可连, 并更新数据库中的状态 285 | function _updateSentinelStatus() { 286 | RedisSentinels.forEach(val => { 287 | val.ping().then(function (result) { 288 | let sentinelInfo = val.options; 289 | let sentinelAddress = sentinelInfo.host + ':' + sentinelInfo.port; 290 | let sentinelStatus = result === 'PONG' ? 'ON' : 'OFF'; 291 | 292 | if ((sentinelAddress in AllSentinelStatus) 293 | && (sentinelStatus !== AllSentinelStatus[sentinelAddress]) 294 | && sentinelStatus === 'OFF') { 295 | // TODO: 发送告警 296 | } 297 | 298 | AllSentinelStatus[sentinelAddress] = sentinelStatus; 299 | updateSentinelStatus(sentinelAddress, sentinelStatus); 300 | }); 301 | }); 302 | } 303 | 304 | /** 305 | * 306 | * @private 307 | */ 308 | function _collectServerInfo() { 309 | RedisServers.forEach(addr => { 310 | let ipPort = addr.split(':'), 311 | newRedisConn = new Redis({ 312 | host: ipPort[0], 313 | port: ipPort[1], 314 | password: config.auth 315 | }); 316 | 317 | newRedisConn.info().then(resp => { 318 | let parsedResp = cmdRespParser.infoRespParser(resp.split('\r\n')); 319 | addNewConnectedClient(addr, parsedResp['connected_clients']); 320 | addNewUsedMemory(addr, (parsedResp['used_memory'] / oneM).toFixed(3)); 321 | addNewCMDPS(addr, parsedResp['instantaneous_ops_per_sec']); 322 | // 323 | newRedisConn.disconnect(); 324 | }); 325 | }); 326 | } 327 | 328 | /** 329 | * Module Exports 330 | */ 331 | module.exports = { 332 | fetch_cluster_status: _fetchClusterInfo, 333 | update_sentinel_status: _updateSentinelStatus, 334 | collect_server_info: _collectServerInfo 335 | }; 336 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redis-sentinel-ui", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node ./app", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "youngsterxyf", 11 | "contributors": [ 12 | "gejiawen <806717031@qq.com>" 13 | ], 14 | "license": "ISC", 15 | "dependencies": { 16 | "bootstrap": "^3.3.6", 17 | "cn-bootstrap-datetimepicker": "^4.15.36", 18 | "co-body": "^4.0.0", 19 | "debug": "^2.2.0", 20 | "highcharts": "^4.2.0", 21 | "ioredis": "^1.11.1", 22 | "jade": "^1.11.0", 23 | "jquery": ">=1.8.3 <2.2.0", 24 | "log": "^1.4.0", 25 | "mime-types": "^2.1.8", 26 | "sqlite3": "^3.1.1" 27 | }, 28 | "devDependencies": { 29 | "browserify": "^12.0.1", 30 | "gulp": "^3.9.0", 31 | "gulp-browserify": "^0.5.1" 32 | }, 33 | "engines": { 34 | "node": ">=5.1.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitelife/redis-sentinel-ui/f182b2d6becf8f54b23650bc6a8bf50f6908ff93/public/favicon.png -------------------------------------------------------------------------------- /public/main.css: -------------------------------------------------------------------------------- 1 | .navbar { 2 | margin-top: 20px; 3 | } 4 | 5 | .navbar-header { 6 | margin-top: 5px; 7 | color: grey; 8 | font-size: 20px; 9 | } 10 | 11 | .navbar-header > img { 12 | display: inline-block; 13 | } 14 | 15 | .navbar-header > span { 16 | margin-left: 10px; 17 | } 18 | 19 | h3 { 20 | text-align: center; 21 | } 22 | 23 | .form-horizontal > button { 24 | float: right; 25 | } 26 | 27 | .error-tip, .loading-tip { 28 | margin-top: 20px; 29 | } 30 | -------------------------------------------------------------------------------- /public/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by xiayf on 15/12/4. 3 | */ 4 | let $ = jQuery = require('jquery'); 5 | let _ = require('bootstrap'); 6 | let __ = require('cn-bootstrap-datetimepicker'); 7 | let Highcharts = require('highcharts'); 8 | Highcharts.setOptions({ global: { useUTC: false } }); 9 | 10 | let statTitleMapper = { 11 | 'connected_client': '客户端连接数(个)', 12 | 'used_memory': '内存使用量(MB)', 13 | 'cmd_ps': '每秒处理命令数(个)' 14 | }; 15 | 16 | function genErrorAlert(xhr) { 17 | return ''; 20 | } 21 | 22 | $(function () { 23 | $('#begin_datetime').datetimepicker({ 24 | format: 'YYYY-MM-DD HH:mm:ss', 25 | defaultDate: new Date(Date.now() - (60 * 60 * 12 * 1000)), 26 | sideBySide: true 27 | }); 28 | $('#end_datetime').datetimepicker({ 29 | format: 'YYYY-MM-DD HH:mm:ss', 30 | defaultDate: new Date(), 31 | sideBySide: true 32 | }); 33 | 34 | $('#submit_cmd').on('click', function ($e) { 35 | $e.preventDefault(); 36 | 37 | let $cmdOutput = $('#cmd_output'); 38 | $cmdOutput.empty(); 39 | 40 | let cmd = $('input[name="cmd"]').val(), 41 | cmdParts = cmd.split(' '), 42 | params = ''; 43 | cmd = cmdParts[0]; 44 | if (cmdParts.length > 1) { 45 | params = cmdParts.slice(1).join(' '); 46 | } 47 | 48 | // 加loading效果 49 | let loadingPart = '
' + 50 | '
' + 51 | '正在执行命令,请耐心等待...' + 52 | '
'; 53 | $cmdOutput.append(loadingPart); 54 | 55 | let $loadingPart = $('#loading_part'); 56 | 57 | let req = $.ajax({ 58 | "method": "POST", 59 | "url": "/cmd", 60 | "data": { 61 | "cmd": cmd, 62 | "params": params 63 | }, 64 | dataType: "text" 65 | }); 66 | req.done(function (resp) { 67 | $loadingPart.remove(); 68 | 69 | resp = '
' + resp + '
'; 70 | $cmdOutput.html(resp); 71 | }); 72 | req.fail(function(xhr) { 73 | $loadingPart.remove(); 74 | $cmdOutput.append(genErrorAlert(xhr)); 75 | }); 76 | }); 77 | 78 | $('#show_stat').on('click', function ($e) { 79 | $e.preventDefault(); 80 | 81 | let selectedServer = []; 82 | $('.server input').each(function () { 83 | if ($(this).prop('checked')) { 84 | selectedServer.push($(this).val()); 85 | } 86 | }); 87 | 88 | let selectedIndex = []; 89 | $('.index input').each(function () { 90 | if ($(this).prop('checked')) { 91 | selectedIndex.push($(this).val()); 92 | } 93 | }); 94 | 95 | let reduceWay = $('input[name="reduce_way"]:checked').val(); 96 | 97 | let beginDateTime = $('input[name="begin-datetime"]').val(); 98 | let endDateTime = $('input[name="end-datetime"]').val(); 99 | 100 | let $statGraphPart = $('.stat-graph-part'); 101 | $statGraphPart.empty(); 102 | 103 | if (selectedServer.length === 0 || selectedIndex.length === 0 || !beginDateTime || !endDateTime) { 104 | $statGraphPart.append(''); 105 | return; 106 | } 107 | 108 | // 一个指标一张图 109 | selectedIndex.forEach(function (ele) { 110 | // 加loading效果 111 | let loadingPartID = 'loading_' + ele, 112 | loadingPart = '
' + 113 | '
' + 114 | '正在加载数据,请耐心等待...' + 115 | '
'; 116 | 117 | $statGraphPart.append(loadingPart); 118 | 119 | let req = $.ajax({ 120 | method: 'POST', 121 | url: "/stat", 122 | data: { 123 | name: ele, 124 | servers: selectedServer.join(','), 125 | begin_time: beginDateTime, 126 | end_time: endDateTime, 127 | reduce_way: reduceWay 128 | }, 129 | dataType: 'json' 130 | }); 131 | req.done(function (resp) { 132 | // 133 | $('#' + loadingPartID).remove(); 134 | 135 | let containerID = 'container_' + ele; 136 | $('.stat-graph-part').append('
'); 137 | 138 | $('#' + containerID).highcharts({ 139 | credits: { 140 | enabled: false 141 | }, 142 | title: { 143 | text: statTitleMapper[ele] 144 | }, 145 | xAxis: resp.xAxis ? resp.xAxis : {type: 'datetime'}, 146 | yAxis: resp.yAxis ? resp.yAxis : null, 147 | series: resp.series 148 | }); 149 | }); 150 | req.fail(function(xhr) { 151 | $('#' + loadingPartID).remove(); 152 | $('.stat-graph-part').append(genErrorAlert(xhr)); 153 | }); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /public/redis-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitelife/redis-sentinel-ui/f182b2d6becf8f54b23650bc6a8bf50f6908ff93/public/redis-logo.png -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: routes.js 3 | * @author: youngsterxyf, gejiawen 4 | * @date: 15/12/4 17:19 5 | * @description: routes.js 6 | */ 7 | 8 | 'use strict'; 9 | 10 | let fs = require('fs'); 11 | let urlParser = require('url'); 12 | //const qs = require('querystring'); 13 | 14 | let Parser = require('co-body'); 15 | 16 | let controllers = require('./controllers'); 17 | let StaticServ = require('./utils/staticServ'); 18 | let RespUtil = require('./utils/resp'); 19 | let Logger = require('./utils/logger'); 20 | 21 | // Web路由 22 | let routes = { 23 | '/': { 24 | 'verb': ['GET'], 25 | 'action': controllers.Home 26 | }, 27 | '/cmd_page': { 28 | 'verb': ['GET'], 29 | 'action': controllers.Cmd_page 30 | }, 31 | '/cmd': { 32 | 'verb': ['POST'], 33 | 'action': controllers.Cmd 34 | }, 35 | '/stat_page': { 36 | 'verb': ['GET'], 37 | 'action': controllers.Stat_page 38 | }, 39 | '/stat': { 40 | 'verb': ['POST'], 41 | 'action': controllers.Stat 42 | } 43 | }; 44 | 45 | function* _parseReqBody(req) { 46 | let type = 'form'; 47 | let body; 48 | 49 | let headers = req.headers; 50 | if ('content-type' in headers) { 51 | if (headers['content-type'] === 'application/json') { 52 | type = 'json'; 53 | } 54 | } 55 | if (type === 'json') { 56 | body = yield Parser.json(req); 57 | } else { 58 | body = yield Parser.form(req); 59 | } 60 | return body; 61 | } 62 | 63 | function _router(req, res) { 64 | let urlParts = urlParser.parse(req.url, true); 65 | let pathname = urlParts.pathname; 66 | 67 | Logger.info('%s %s', req.method, pathname); 68 | 69 | // 输出请求路径及方法 70 | // console.log(pathname, req.method); 71 | 72 | // 绑定一些方法 73 | res.toResponse = RespUtil.toRespone; 74 | 75 | // 匹配路由表 76 | if ((pathname in routes) && (routes[pathname].verb.indexOf(req.method) != -1)) { 77 | // 统一解析保存URL查询字符串的请求参数 78 | // req.query = qs.parse(urlParts.query); 79 | req.query = urlParts.query; 80 | 81 | if (req.method === 'POST' || req.method === 'PUT') { 82 | // 统一解析并保存请求体数据 83 | _parseReqBody(req).next().value.then(function(parsedBody) { 84 | req.body = parsedBody; 85 | // console.log(req.body); 86 | routes[pathname].action(req, res); 87 | }); 88 | } else { 89 | req.body = {}; 90 | // 执行对应的处理方法 91 | routes[pathname].action(req, res); 92 | } 93 | return; 94 | } 95 | 96 | StaticServ(pathname, function(err, result) { 97 | if (err) { 98 | Logger.error(err); 99 | 100 | res.toResponse(err.msg, err.code); 101 | return; 102 | } 103 | if (result.cached) { 104 | let data = result.data; 105 | 106 | let statusCode = 200; 107 | // 写响应头, 以及可能相应304 108 | let ifNoneMatch = req.headers['if-none-match']; 109 | if (ifNoneMatch && ifNoneMatch == data.md5) { 110 | statusCode = 304; 111 | } 112 | 113 | res.statusCode = statusCode; 114 | res.setHeader('Cache-Control', data.cacheControl || 'public, max-age=' + data.maxAge); 115 | res.setHeader('ETag', data.md5); 116 | res.setHeader('Expires', (new Date(Date.now() + data.maxAge * 1000)).toUTCString()); 117 | 118 | if (statusCode === 200) { 119 | res.write(data.buffer); 120 | } 121 | } else { 122 | res.write(result.data); 123 | } 124 | res.end(); 125 | }); 126 | } 127 | 128 | 129 | /** 130 | * Module Exports. 131 | */ 132 | module.exports = _router; 133 | -------------------------------------------------------------------------------- /screenshot/rsm-cmd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitelife/redis-sentinel-ui/f182b2d6becf8f54b23650bc6a8bf50f6908ff93/screenshot/rsm-cmd.png -------------------------------------------------------------------------------- /screenshot/rsm-main-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitelife/redis-sentinel-ui/f182b2d6becf8f54b23650bc6a8bf50f6908ff93/screenshot/rsm-main-1.png -------------------------------------------------------------------------------- /screenshot/rsm-main-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitelife/redis-sentinel-ui/f182b2d6becf8f54b23650bc6a8bf50f6908ff93/screenshot/rsm-main-2.png -------------------------------------------------------------------------------- /screenshot/rsm-stat-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitelife/redis-sentinel-ui/f182b2d6becf8f54b23650bc6a8bf50f6908ff93/screenshot/rsm-stat-1.png -------------------------------------------------------------------------------- /screenshot/rsm-stat-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitelife/redis-sentinel-ui/f182b2d6becf8f54b23650bc6a8bf50f6908ff93/screenshot/rsm-stat-2.png -------------------------------------------------------------------------------- /screenshot/rsm-stat-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitelife/redis-sentinel-ui/f182b2d6becf8f54b23650bc6a8bf50f6908ff93/screenshot/rsm-stat-3.png -------------------------------------------------------------------------------- /utils/cmdRespParser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by xiayf on 15/12/20. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | function _infoRespParser(resp) { 8 | let mapper = {}; 9 | 10 | resp.forEach(val => { 11 | if (val.indexOf(':') === -1) { 12 | return; 13 | } 14 | 15 | let pairs = val.split(':'); 16 | mapper[pairs[0]] = pairs[1]; 17 | }); 18 | 19 | return mapper; 20 | } 21 | 22 | exports.infoRespParser = _infoRespParser; -------------------------------------------------------------------------------- /utils/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let Log = require('log'); 4 | 5 | let config = require('../config'); 6 | 7 | let _logger = new Log(config.log_level ? config.log_level : 'info'); 8 | 9 | module.exports = _logger; -------------------------------------------------------------------------------- /utils/resp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by xiayf on 15/12/6. 3 | */ 4 | 5 | 'use strict'; 6 | 7 | function _resp(content, code) { 8 | if (code === undefined) { 9 | code = 200; 10 | } 11 | this.statusCode = code; 12 | if (code >= 400) { 13 | // 解决响应体乱码问题 14 | this.setHeader('Content-Type', 'text/plain; charset=utf-8'); 15 | } 16 | this.write(content); 17 | this.end(); 18 | } 19 | 20 | exports.toRespone = _resp; -------------------------------------------------------------------------------- /utils/staticServ.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: static.js 3 | * @author: youngsterxyf, gejiawen 4 | * @date: 15/12/5 20:35 5 | * @description: static.js 6 | */ 7 | 8 | /** 9 | * 服务器端静态文件缓存实现参考了koa-static-cache 10 | */ 11 | 12 | 'use strict'; 13 | 14 | let crypto = require('crypto'); 15 | let fs = require('fs'); 16 | let path = require('path'); 17 | let mime = require('mime-types'); 18 | let debug = require('debug')('staticServ'); 19 | let config = require('../config'); 20 | let Logger = require('./logger'); 21 | 22 | let staticFileObjs = Object.create(null); 23 | let staticDir = path.join(global.RootDir, 'public'); 24 | 25 | let options = { 26 | buffer: !config.debug, 27 | maxAge: config.debug ? 0 : 60 * 60 * 24 * 7 28 | }; 29 | 30 | staticCache(staticDir, options, staticFileObjs); 31 | 32 | /** 33 | * @param dir 34 | * @param options 35 | * @param files 36 | * @private 37 | */ 38 | function staticCache(dir, options, files) { 39 | options = options || {}; 40 | options.prefix = (options.prefix || '').replace(/\/$/, '') + path.sep; 41 | files = files || options.files || Object.create(null); 42 | dir = dir || options.dir || process.cwd(); 43 | 44 | // option.filter 45 | let fileFilter = function () { return true }; 46 | if (Array.isArray(options.filter)) fileFilter = function (file) { return ~options.filter.indexOf(file) }; 47 | if (typeof options.filter === 'function') fileFilter = options.filter; 48 | 49 | readDirRecursive(dir).filter(fileFilter).forEach(function (name) { 50 | loadFile(name, dir, options, files) 51 | }); 52 | } 53 | 54 | /** 55 | * @param root 56 | * @param filter 57 | * @param files 58 | * @param prefix 59 | * @returns {*|Array} 60 | * @private 61 | */ 62 | function readDirRecursive(root, filter, files, prefix) { 63 | prefix = prefix || ''; 64 | files = files || []; 65 | filter = filter || noDotFiles; 66 | 67 | let dir = path.join(root, prefix); 68 | if (!fs.existsSync(dir)) return files; 69 | if (fs.statSync(dir).isDirectory()) 70 | fs.readdirSync(dir) 71 | .filter(filter) 72 | .forEach(function (name) { 73 | readDirRecursive(root, filter, files, path.join(prefix, name)) 74 | }); 75 | else { 76 | files.push(prefix); 77 | } 78 | 79 | return files; 80 | } 81 | 82 | /** 83 | * @param x 84 | * @returns {boolean} 85 | * @private 86 | */ 87 | function noDotFiles(x) { 88 | return x[0] !== '.' 89 | } 90 | 91 | /** 92 | * @param pathname 93 | * @param dir 94 | * @param options 95 | * @param files 96 | * @returns {{}} 97 | * @private 98 | */ 99 | function loadFile(pathname, dir, options, files) { 100 | // 引用, 修改了obj,即修改了files[pathname] 101 | let obj = files[pathname] = files[pathname] ? files[pathname] : {}; 102 | let filename = obj.path = path.join(dir, pathname); 103 | let stats = fs.statSync(filename); 104 | let buffer = fs.readFileSync(filename); 105 | 106 | obj.cacheControl = options.cacheControl; 107 | obj.maxAge = obj.maxAge ? obj.maxAge : options.maxAge || 0; 108 | obj.type = obj.mime = mime.lookup(pathname) || 'application/octet-stream'; 109 | obj.mtime = stats.mtime.toUTCString(); 110 | obj.length = stats.size; 111 | obj.md5 = crypto.createHash('md5').update(buffer).digest('base64'); 112 | 113 | if (options.buffer) 114 | obj.buffer = buffer; 115 | 116 | buffer = null; 117 | return obj 118 | } 119 | 120 | function safeDecodeURIComponent(text) { 121 | try { 122 | return decodeURIComponent(text); 123 | } catch (e) { 124 | return text; 125 | } 126 | } 127 | 128 | /** 129 | * 130 | * @param pathname 131 | * @param callback 132 | * @private 133 | */ 134 | function staticService(pathname, callback) { 135 | if (pathname.indexOf(config.static_prefix) !== 0) { 136 | callback({code: 404, msg: '不存在目标文件'}); 137 | return; 138 | } 139 | 140 | // 去掉'/public/' 141 | pathname = pathname.slice(config.static_prefix.length); 142 | let filename = safeDecodeURIComponent(path.normalize(pathname)); 143 | 144 | let filePath = path.join(staticDir, pathname); 145 | let fileStat; 146 | try { 147 | fileStat = fs.statSync(filePath); 148 | } catch (err) { 149 | Logger.error(err); 150 | 151 | callback({code: 500, msg: '系统异常'}); 152 | return; 153 | } 154 | 155 | if (!fileStat){ 156 | callback({code:500, msg:'fileStat does not exist'}); 157 | return; 158 | } 159 | 160 | if (config.debug) { 161 | if (fileStat.isFile()) { 162 | fs.readFile(filePath, function(err, data) { 163 | callback(err ? {code: 500, msg: err.message} : null, {cached: false, data: data}); 164 | }); 165 | } else { 166 | callback({code: 404, msg: '不存在目标文件'}); 167 | } 168 | return; 169 | } 170 | 171 | let file = staticFileObjs[filename]; 172 | if (!file) { 173 | if (path.basename(filename)[0] === '.') { 174 | callback({code: 403, msg: '没有权限'}); 175 | return; 176 | } 177 | 178 | if (!fileStat.isFile()) { 179 | callback({code: 404, msg: '不存在目标文件'}); 180 | return; 181 | } 182 | 183 | file = loadFile(filename, staticDir, options, staticFileObjs) 184 | } else { 185 | // 检查自缓存以来文件是否变更过 186 | if (fileStat.mtime > new Date(file.mtime)) { 187 | file = loadFile(filename, staticDir, options, staticFileObjs); 188 | } 189 | } 190 | 191 | callback(null, {cached: true, data: file}); 192 | } 193 | 194 | /** 195 | * Module Exports 196 | */ 197 | module.exports = staticService; 198 | -------------------------------------------------------------------------------- /utils/template.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: template 3 | * @author: gejiawen 4 | * @date: 15/12/5 18:02 5 | * @description: template 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const path = require('path'); 11 | const jade = require('jade'); 12 | 13 | let templateCache = {}; 14 | 15 | /** 16 | * 17 | * @param relativePath 18 | * @param data 19 | * @returns {*} 20 | * @private 21 | */ 22 | function _render(relativePath, data) { 23 | // templateCache = {}; 24 | let filePath = path.join(global.RootDir, relativePath); 25 | 26 | if (!(filePath in templateCache)) { 27 | templateCache[filePath] = jade.compileFile(filePath, { 28 | pretty: true 29 | }); 30 | } 31 | 32 | return templateCache[filePath](data); 33 | } 34 | 35 | /** 36 | * Module Exports 37 | */ 38 | exports.render = _render; 39 | -------------------------------------------------------------------------------- /utils/time.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file: time 3 | * @author: gejiawen 4 | * @date: 15/12/5 18:13 5 | * @description: time 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const StdUtil = require('util'); 11 | 12 | // const moment = require('moment'); 13 | 14 | function _formatUpTime(seconds) { 15 | let day = Math.floor(seconds / 86400); 16 | let hour = Math.floor(seconds % 86400 / 3600); 17 | let minute = Math.floor(seconds % 3600 / 60); 18 | let second = seconds % 60; 19 | 20 | return StdUtil.format('%d天%d时%d分%d秒', day, hour, minute, second); 21 | } 22 | 23 | /** 24 | * 25 | * @param timestamp 26 | * @returns {*} 27 | * @private 28 | */ 29 | /* 30 | function _formatUpTime(timestamp) { 31 | return moment(timestamp).format('DD天HH时mm分ss秒'); 32 | } 33 | */ 34 | 35 | /** 36 | * Module Exports 37 | */ 38 | exports.formatUpTime = _formatUpTime; 39 | -------------------------------------------------------------------------------- /views/cmd.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h3 执行命令 5 | div(class="container-fluid") 6 | form(class="form-horizontal") 7 | div(class="form-group") 8 | input(type="text", class="form-control", placeholder="输入命令,参数以空格相隔", name="cmd", list="cmd_list") 9 | datalist(id="cmd_list") 10 | each cmd in cmdList 11 | option(value="#{cmd}") 12 | button(type="submit", class=["btn", "btn-primary"], id="submit_cmd") 执行 13 | 14 | div(class="clearfix") 15 | 16 | h3 输出 17 | div(id="cmd_output") 18 | -------------------------------------------------------------------------------- /views/footer.jade: -------------------------------------------------------------------------------- 1 | //- 2 | @file: footer 3 | @author: gejiawen 4 | @date: 15/12/5 19:15 5 | @description: footer 6 | 7 | -------------------------------------------------------------------------------- /views/header.jade: -------------------------------------------------------------------------------- 1 | //- 2 | @file: header 3 | @author: gejiawen 4 | @date: 15/12/5 19:15 5 | @description: header 6 | 7 | nav(class=["navbar", "navbar-inverse"]) 8 | div(class="container-fluid") 9 | div(class="navbar-header") 10 | img(alt="logo", src="/public/redis-logo.png", width="50px") 11 | span RSM 12 | ul(class=["nav", "navbar-nav"]) 13 | li 14 | a(href="/") 概况 15 | li 16 | a(href="/stat_page") 监控数据图 17 | li 18 | a(href="/cmd_page") 执行命令 19 | -------------------------------------------------------------------------------- /views/home.jade: -------------------------------------------------------------------------------- 1 | //- 2 | @file: home 3 | @author: youngsterxyf,gejiawen 4 | @date: 15/12/5 19:15 5 | @description: home 6 | 7 | extends layout 8 | 9 | block content 10 | h3 Sentinel服务器 11 | each sentinel in sentinels 12 | div(class=['panel', 'panel-info']) 13 | div(class="panel-heading") #{sentinel.address} 14 | div(class="panel-body") 15 | div(class="container-fluid") 16 | div(class="row") 17 | div(class="col-md-6") 18 | label 版本: 19 | span #{sentinel.version} 20 | div(class="col-md-6") 21 | label 进程ID: 22 | span #{sentinel.process_id} 23 | div(class="row") 24 | div(class="col-md-6") 25 | label 运行时长: 26 | span #{sentinel.uptime} 27 | div(class="col-md-6") 28 | label 等待执行命令数: 29 | span #{sentinel.pending_cmds} 30 | 31 | h3 Redis服务器 32 | each redis in redises 33 | - var isMaster = redis.role === 'master' 34 | div(class=['panel', 'panel-success']) 35 | div(class="panel-heading") 36 | span #{redis.address} 37 | label(class=isMaster ? "label label-primary" : "label label-info") #{redis.role} 38 | div(class="panel-body") 39 | div(class="container-fluid") 40 | div(class="row") 41 | div(class="col-md-6") 42 | label 版本: 43 | span #{redis.version} 44 | div(class="col-md-6") 45 | label 进程ID: 46 | span #{redis.process_id} 47 | div(class="row") 48 | div(class="col-md-6") 49 | label 运行时长: 50 | span #{redis.uptime} 51 | div(class="col-md-6") 52 | label 内存占用: 53 | span #{redis.used_memory} 54 | div(class="row") 55 | div(class="col-md-6") 56 | label 内存分配器: 57 | span #{redis.mem_allocator} 58 | div(class="col-md-6") 59 | label 内存碎片比: 60 | span #{redis.mem_fragmentation_ratio} 61 | div(class="row") 62 | div(class="col-md-6") 63 | label 进程系统态CPU时间: 64 | span #{redis.used_cpu_sys}秒 65 | div(class="col-md-6") 66 | label 进程用户态CPU时间: 67 | span #{redis.used_cpu_user}秒 68 | div(class="row") 69 | div(class="col-md-6") 70 | label 后台子进程系统态CPU时间: 71 | span #{redis.used_cpu_sys_children}秒 72 | div(class="col-md-6") 73 | label 后台子进程用户态CPU时间: 74 | span #{redis.used_cpu_user_children}秒 75 | div(class="row") 76 | div(class="col-md-6") 77 | label 等待执行命令数: 78 | span #{redis.pending_cmds} 79 | div(class="col-md-6") 80 | label 内存占用峰值: 81 | span #{redis.used_memory_peak} 82 | div(class="row") 83 | div(class="col-md-6") 84 | label 已接连接总数: 85 | span #{redis.total_connections_received} 86 | div(class="col-md-6") 87 | label 拒绝的连接数: 88 | span #{redis.rejected_connections} 89 | div(class="row") 90 | div(class="col-md-6") 91 | label 已处理命令数: 92 | span #{redis.total_commands_processed} 93 | div(class="col-md-6") 94 | label 每秒处理命令数: 95 | span #{redis.instantaneous_ops_per_sec} 96 | div(class="row") 97 | div(class="col-md-6") 98 | label 查询命中数: 99 | span #{redis.keyspace_hits} 100 | div(class="col-md-6") 101 | label 查询未命中数: 102 | span #{redis.keyspace_misses} 103 | div(class="row") 104 | div(class="col-md-6") 105 | label 查询命中率: 106 | span #{redis.hit_rate} -------------------------------------------------------------------------------- /views/layout.jade: -------------------------------------------------------------------------------- 1 | //- 2 | @file: layout 3 | @author: youngsterxyf, gejiawen 4 | @date: 15/12/5 19:14 5 | @description: layout 6 | 7 | - var date = new Date() 8 | - title = 'Redis Sentinel Manager' 9 | 10 | doctype html 11 | 12 | html 13 | head 14 | meta(charset='utf-8') 15 | title #{title} 16 | meta(name="viewport", content="width=1000, initial-scale=1.0, maximum-scale=1.0") 17 | link(rel='shortcut icon', href='/public/favicon.png') 18 | link(rel='stylesheet', href='/public/vendor/bootstrap/css/bootstrap.min.css') 19 | link(rel='stylesheet', href='/public/vendor/bootstrap/css/bootstrap-theme.min.css') 20 | link(rel='stylesheet', href='/public/vendor/bootstrap-datetimepicker.min.css') 21 | link(rel='stylesheet', href='/public/main.css') 22 | block head_css 23 | block head_js 24 | body 25 | div(class="container") 26 | include header 27 | block content 28 | include footer 29 | script(src='/public/dist/main.js') -------------------------------------------------------------------------------- /views/stat.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h4 选择服务器: 5 | each server in servers 6 | div(class=["checkbox", "server"]) 7 | label 8 | input(type="checkbox", value="#{server}" checked=true) 9 | span #{server} 10 | h4 选择指标: 11 | each v, k in indexs 12 | div(class=["checkbox", "index"]) 13 | label 14 | - var isCMDPS = k === 'cmd_ps' 15 | input(type="checkbox", value="#{k}" checked=isCMDPS) 16 | span #{v} 17 | h4 选择聚合方式: 18 | each v, k in ways 19 | div(class="radio") 20 | label 21 | - var isDefault = k === 'default' 22 | input(type="radio", name="reduce_way", value="#{k}" checked=isDefault) 23 | span #{v} 24 | h4 选择时间范围: 25 | div(class="form-inline") 26 | div(class=["input-group", "date"], id="begin_datetime") 27 | input(type="text", class="form-control", name="begin-datetime") 28 | span(class="input-group-addon") 29 | span(class=["glyphicon", "glyphicon-calendar"]) 30 | div(class=["input-group", "date"], id="end_datetime") 31 | input(type="text", class="form-control", name="end-datetime") 32 | span(class="input-group-addon") 33 | span(class=["glyphicon", "glyphicon-calendar"]) 34 | button(type="submit", class=["btn", "btn-primary"], id="show_stat") 提交 35 | 36 | div(class="stat-graph-part") 37 | --------------------------------------------------------------------------------