├── 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 | 
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 | | Level |
171 | Message |
172 | Timestamp |
173 |
174 |
175 |
176 | {this.logs.map(({ level, message, timestamp }) => (
177 |
178 | |
183 | {level}
184 | |
185 | {message} |
186 | {moment(timestamp).format('LLLL')} |
187 |
188 | ))}
189 |
190 |
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 |
--------------------------------------------------------------------------------