├── .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 |  15 |  16 |  17 |  18 |  19 |  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 '
' + xhr.responseText + '
' 19 | + '' + 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('