├── src ├── front-end │ ├── .npmignore │ ├── .gitignore │ ├── index.scss │ ├── index.html │ ├── components │ │ └── Dropdown.js │ └── index.js ├── errorHandler.js ├── utilities │ ├── getLogger.js │ └── fileFinder.js ├── router │ ├── app.js │ └── api.js └── index.js ├── .gitignore ├── .babelrc ├── test.js ├── LICENSE ├── package.json └── README.md /src/front-end/.npmignore: -------------------------------------------------------------------------------- 1 | .cache -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | logs -------------------------------------------------------------------------------- /src/front-end/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .cache -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "stage-1", 4 | "react" 5 | ], 6 | "plugins": [ 7 | "transform-class-properties" 8 | ] 9 | } -------------------------------------------------------------------------------- /src/front-end/index.scss: -------------------------------------------------------------------------------- 1 | @import './../../node_modules/bootstrap/scss/bootstrap.scss'; 2 | 3 | .input-group { 4 | margin: 10px 0px; 5 | } 6 | -------------------------------------------------------------------------------- /src/errorHandler.js: -------------------------------------------------------------------------------- 1 | module.exports = (error, req, res, next) => { 2 | console.log(error); 3 | res.status(500).send('Something broke!'); 4 | }; 5 | -------------------------------------------------------------------------------- /src/utilities/getLogger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | 3 | module.exports = filePath => 4 | new winston.transports.File({ filename: filePath }); 5 | -------------------------------------------------------------------------------- /src/router/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const router = express.Router(); 4 | 5 | router.use('/', express.static(path.join(__dirname, '../front-end/dist'))); 6 | 7 | module.exports = router; 8 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const winstonServer = require('./src/index'); 3 | 4 | winstonServer({ 5 | path: path.join(__dirname, '/logs'), 6 | logFiles: '/**/*.log', 7 | port: 8000, 8 | orderBy: 'modifiedTime' 9 | }); 10 | -------------------------------------------------------------------------------- /src/front-end/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Winston Dashboard 5 | 6 | 7 | 8 | 9 |
app
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | 4 | module.exports = config => { 5 | if (config.path == null) { 6 | throw new Error('Please provide a path property to the logs'); 7 | } 8 | 9 | if (config.logFiles == null) { 10 | throw new Error( 11 | 'Please provide a logFiles property to look for the logs. remember this is a Glob.' 12 | ); 13 | } 14 | const port = config.port || 8000; 15 | 16 | app.use('/api', require('./router/api')(config)); 17 | app.use('/', require('./router/app')); 18 | 19 | app.use(require('./errorHandler')); 20 | 21 | app.listen(port, () => console.log(`listening to port ${port}`)); 22 | }; 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ricardo Spear 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "winston-dashboard", 3 | "version": "1.0.12", 4 | "description": "React Based Log Browser", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "dev": 8 | "concurrently \"nodemon test.js\" \"cd src/front-end && parcel index.html --public-url /\"", 9 | "build": "cd src/front-end && parcel build index.html --public-url /", 10 | "publish": "npm run build", 11 | "test": "./" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "bluebird": "^3.5.1", 17 | "bootstrap": "^4.0.0", 18 | "browserslist": "^3.2.0", 19 | "express": "^4.16.3", 20 | "fs-extra": "^5.0.0", 21 | "glob": "^7.1.2", 22 | "react": "^16.2.0", 23 | "react-dom": "^16.2.0", 24 | "reactstrap": "^5.0.0-beta.2", 25 | "rxjs": "^5.5.7", 26 | "winston": "^2.4.1" 27 | }, 28 | "devDependencies": { 29 | "babel-core": "^6.26.0", 30 | "babel-plugin-transform-class-properties": "^6.24.1", 31 | "babel-preset-env": "^1.6.1", 32 | "babel-preset-react": "^6.24.1", 33 | "babel-preset-stage-1": "^6.24.1", 34 | "moment": "^2.21.0", 35 | "node-sass": "^4.7.2" 36 | }, 37 | "peerDependencies": { 38 | "winston": "^2.4.1" 39 | }, 40 | "files": ["src/**/*.js", "src/**/*.css", "src/**/*.html"] 41 | } 42 | -------------------------------------------------------------------------------- /src/utilities/fileFinder.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const Rx = require('rxjs'); 3 | const fs = require('fs-extra'); 4 | const util = require('util'); 5 | const glob = util.promisify(require('glob')); 6 | 7 | const sort = (files, sortBy) => 8 | new Promise(resolve => { 9 | const promises = files.map(file => fs.stat(file)); 10 | Promise.all(promises) 11 | .then(stats => 12 | stats.map((stat, index) => ({ 13 | value: stat[sortBy], 14 | file: files[index] 15 | })) 16 | ) 17 | .then(files => { 18 | resolve(files.sort(file => file.value).map(file => file.file)); 19 | }); 20 | }); 21 | 22 | module.exports = config => { 23 | const files$ = new Rx.BehaviorSubject([]); 24 | const getSourceFiles = () => 25 | glob(config.logFiles, { 26 | root: config.path 27 | }) 28 | .then(results => { 29 | switch (config.orderBy) { 30 | case 'creationTime': 31 | return sort(results, 'ctimeMs'); 32 | case 'modifiedTime': 33 | return sort(results, 'mtimeMs'); 34 | default: 35 | return results; 36 | } 37 | }) 38 | .then(results => { 39 | files$.next(results); 40 | }) 41 | .catch(console.error); 42 | 43 | getSourceFiles(); 44 | setInterval(getSourceFiles, 60 * 1000); 45 | 46 | return files$; 47 | }; 48 | -------------------------------------------------------------------------------- /src/router/api.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const moment = require('moment'); 3 | const express = require('express'); 4 | const fileFinder = require('../utilities/fileFinder'); 5 | const getLogger = require('../utilities/getLogger'); 6 | 7 | module.exports = config => { 8 | const router = express.Router(); 9 | let sources = {}; 10 | let sourceNames; 11 | const validSource = source => source != null && sources[source] != null; 12 | 13 | fileFinder(config).subscribe(files => { 14 | sourceNames = files.map(filePath => path.relative(config.path, filePath)); 15 | sources = {}; 16 | sourceNames.forEach(sourceName => { 17 | sources[sourceName] = getLogger(path.join(config.path, sourceName)); 18 | }); 19 | }); 20 | 21 | router.use('/sources', (req, res, next) => { 22 | res.send(sourceNames); 23 | }); 24 | 25 | router.use('/query', (req, res, next) => { 26 | const { source, query } = req.query; 27 | 28 | if (!validSource(source)) { 29 | return res.send('did not work'); 30 | } 31 | 32 | const options = JSON.parse(query); 33 | options.from = moment().subtract(100, 'years'); 34 | options.until = moment().add(1, 'days'); 35 | options.order = 'asc'; 36 | 37 | sources[source].query(options, (error, results) => { 38 | if (error) { 39 | return res.send('did not work'); 40 | } 41 | 42 | res.send(results); 43 | }); 44 | }); 45 | 46 | return router; 47 | }; 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # winston-dashboard 2 | 3 | React Based Log Browser for WinstonJs 4 | 5 | ![alt text](https://raw.githubusercontent.com/spearmootz/winston-dashboard/gh-pages/winston-dashboard.png) 6 | 7 | ## Instalation 8 | 9 | `npm install --save winston-dashboard` 10 | Minimum requirement: Node 8 11 | 12 | ## Usage 13 | 14 | ``` 15 | const winstonServer = require('winston-dashboard'); 16 | const path = require('path'); 17 | 18 | // Instantiate the server 19 | winstonServer({ 20 | path: path.join(__dirname, '/logs'), //Root path of the logs (used to not show the full path on the log selector) 21 | logFiles: '/**/*.log', //Glob to search for logs, make sure you start with a '/' 22 | port: 8000, // Optional custom port, defaults to 8000, 23 | orderBy: 'creationTime' // 'creationTime' | 'modifiedTime', if none is provided then it will sort by alphabetical order 24 | }); 25 | ``` 26 | 27 | ## How it works 28 | 29 | It uses options.path and options.logFiles to look for all logs. 30 | Each one of these logs is instantiated as a Transport. 31 | 32 | Server provides query api for these transports. 33 | 34 | 35 | ## What you can do 36 | 37 | * Picks up new log files every minute 38 | * Filter logs with an input. useful when you have a lot of log files, for example daily files. 39 | * Filter logs with an input 40 | * Filter by maximum level (if Info is selected verbose and silly wont show). 41 | * Select amount of rows to show. 42 | * Paginate 43 | 44 | ## What it cant do 45 | 46 | * It cannot sort even thought he Api provides a way to do so because the order 'desc' has a bug and does not paginate. 47 | 48 | * Cannot filter by time. if you are really interested in this and just let me know and i can make it work. I had it at some point but while investingating the transport query function i removed this functionality. 49 | 50 | ## Roadmap 51 | 52 | * Add a refresh button 53 | -------------------------------------------------------------------------------- /src/front-end/components/Dropdown.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { 3 | Dropdown, 4 | DropdownToggle, 5 | DropdownMenu, 6 | DropdownItem, 7 | Input 8 | } from 'reactstrap'; 9 | 10 | export default class CustomDropdown extends Component { 11 | state = { open: false, textFilter: '' }; 12 | 13 | static defaultProps = { 14 | options: [], 15 | onChange: () => null, 16 | textFilter: false 17 | }; 18 | 19 | toggle = () => { 20 | this.setState( 21 | prevState => ({ 22 | textFilter: '', 23 | open: !prevState.open 24 | }), 25 | () => { 26 | if (this.state.open && this.props.textFilter) { 27 | this.input.focus(); 28 | } 29 | } 30 | ); 31 | }; 32 | 33 | get options() { 34 | const { textFilter } = this.state; 35 | if (this.props.textFilter === false || this.state.textFilter == '') { 36 | return this.props.options; 37 | } 38 | const searchValue = textFilter.toLowerCase(); 39 | return this.props.options.filter(option => 40 | option.toLowerCase().includes(searchValue) 41 | ); 42 | } 43 | 44 | onTextChange = event => { 45 | const { value } = event.target; 46 | 47 | this.setState(() => ({ 48 | textFilter: value 49 | })); 50 | }; 51 | 52 | render() { 53 | const { open } = this.state; 54 | const label = this.props.value || this.props.label; 55 | 56 | return ( 57 | 62 | {label} 63 | 64 | {this.props.textFilter && ( 65 | (this.input = input)} 67 | placeholder="Log Search Filter" 68 | onChange={this.onTextChange} 69 | value={this.state.textFilter} 70 | /> 71 | )} 72 | {this.options.map((value, index) => ( 73 | this.props.onChange(value)} 76 | > 77 | {value} 78 | 79 | ))} 80 | 81 | 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/front-end/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { render } from 'react-dom'; 3 | import moment from 'moment'; 4 | import Dropdown from './components/Dropdown'; 5 | import './index.scss'; 6 | import { 7 | Container, 8 | Row, 9 | Col, 10 | Input, 11 | InputGroup, 12 | InputGroupAddon, 13 | InputGroupText, 14 | Pagination, 15 | PaginationItem, 16 | PaginationLink, 17 | Table 18 | } from 'reactstrap'; 19 | 20 | const levels = { 21 | silly: { class: 'text-success', label: 'Silly', value: 5 }, 22 | debug: { class: 'text-muted', label: 'Debug', value: 4 }, 23 | verbose: { class: 'text-primary', label: 'Verbose', value: 3 }, 24 | info: { class: 'text-info', label: 'Info', value: 2 }, 25 | warn: { class: 'text-warning', label: 'Warn', value: 1 }, 26 | error: { class: 'text-danger', label: 'Error', value: 0 } 27 | }; 28 | 29 | const levelNames = Object.keys(levels); 30 | 31 | const rowOptions = [20, 50, 100, 250, 500, 1000]; 32 | 33 | class App extends Component { 34 | state = { 35 | rows: 100, 36 | page: 1, 37 | source: null, 38 | sources: [], 39 | logs: [], 40 | levelFilter: 'silly', 41 | inputFilter: '' 42 | }; 43 | 44 | componentWillMount() { 45 | this.getSources(); 46 | setInterval(this.getSources, 60 * 1000); 47 | } 48 | 49 | getSources = () => { 50 | fetch('/api/sources') 51 | .then(response => response.json()) 52 | .then(sources => 53 | this.setState(prevState => { 54 | const { source } = this.state; 55 | const selectNewSource = source == null; 56 | if (selectNewSource) { 57 | setTimeout(this.runQuery, 200); 58 | } 59 | return { sources, source: selectNewSource ? sources[0] : source }; 60 | }) 61 | ); 62 | }; 63 | 64 | updateState = state => { 65 | this.setState(() => state, this.runQuery); 66 | }; 67 | 68 | runQuery = () => { 69 | const { source, page, rows } = this.state; 70 | 71 | const query = { 72 | rows, 73 | start: (page - 1) * rows 74 | }; 75 | 76 | fetch(`/api/query?source=${source}&query=${JSON.stringify(query)}`) 77 | .then(response => response.json()) 78 | .then(logs => { 79 | this.setState(() => ({ 80 | logs: logs.map(log => ({ 81 | ...log, 82 | levelRank: levels[log.level].level 83 | })) 84 | })); 85 | }); 86 | }; 87 | 88 | selectLevelFilter = level => { 89 | this.setState(() => ({ 90 | levelFilter: level.toLowerCase() 91 | })); 92 | }; 93 | 94 | get sourceSelector() { 95 | return ( 96 | 97 | 98 | Select Log 99 | 100 | this.updateState({ source, page: 1 })} 102 | value={this.state.source} 103 | options={this.state.sources} 104 | label="Select Log File" 105 | textFilter 106 | /> 107 | 108 | ); 109 | } 110 | 111 | get rowsSelector() { 112 | return ( 113 | 114 | 115 | Rows 116 | 117 | this.updateState({ rows, page: 1 })} 119 | value={this.state.rows} 120 | options={rowOptions} 121 | label="Select Log File" 122 | /> 123 | 124 | ); 125 | } 126 | 127 | get levelSelector() { 128 | return ( 129 | 130 | 131 | Maximum Level 132 | 133 | levels[level].label)} 137 | label="Select Minimum Filter Level" 138 | /> 139 | 140 | ); 141 | } 142 | 143 | getLevel = level => { 144 | return levels[level] ? levels[level] : {}; 145 | }; 146 | 147 | get logs() { 148 | const { levelFilter, inputFilter } = this.state; 149 | const levelFilterValue = this.getLevel(levelFilter).value; 150 | const textFilter = inputFilter.toLowerCase(); 151 | const logs = 152 | levelFilter === 'silly' 153 | ? this.state.logs 154 | : this.state.logs.filter( 155 | ({ level }) => this.getLevel(level).value <= levelFilterValue 156 | ); 157 | 158 | return inputFilter == '' 159 | ? logs 160 | : logs.filter(log => log.message.toLowerCase().includes(textFilter)); 161 | } 162 | 163 | get table() { 164 | return ( 165 |
166 |
167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | {this.logs.map(({ level, message, timestamp }) => ( 177 | 178 | 185 | 186 | 187 | 188 | ))} 189 | 190 |
LevelMessageTimestamp
183 | {level} 184 | {message}{moment(timestamp).format('LLLL')}
191 |
192 | {this.pagination} 193 |
194 | ); 195 | } 196 | 197 | previousPage = () => { 198 | this.setState( 199 | prevState => ({ 200 | logs: [], 201 | page: prevState.page - 1 202 | }), 203 | this.runQuery 204 | ); 205 | }; 206 | 207 | nextPage = () => { 208 | this.setState( 209 | prevState => ({ 210 | logs: [], 211 | page: prevState.page + 1 212 | }), 213 | this.runQuery 214 | ); 215 | }; 216 | 217 | changeInputFilter = event => { 218 | const { value } = event.target; 219 | this.setState(prevState => ({ 220 | inputFilter: value 221 | })); 222 | }; 223 | 224 | get pagination() { 225 | return ( 226 | 227 | 228 | 229 | {this.state.page > 1 && ( 230 | 231 | 232 | 233 | )} 234 | 235 | Page {this.state.page} 236 | 237 | {this.state.logs.length >= this.state.rows && ( 238 | 239 | 240 | 241 | )} 242 | 243 | 244 | 245 | ); 246 | } 247 | 248 | get inputFilter() { 249 | return ( 250 | 251 | 252 | Text Search 253 | 254 | 259 | 260 | ); 261 | } 262 | 263 | render() { 264 | return ( 265 | 266 | 267 | 268 |

Winston Dashboard

269 | 270 |
271 | 272 | 273 | {this.sourceSelector} 274 | {this.inputFilter} 275 | 276 | 277 | {this.levelSelector} 278 | {this.rowsSelector} 279 | 280 | 281 | 282 | {this.table} 283 | 284 |
285 | ); 286 | } 287 | } 288 | 289 | render(, document.getElementById('app')); 290 | --------------------------------------------------------------------------------