├── .eslintrc
├── bin
└── nats-monitor
├── public
├── favicon.ico
├── manifest.json
└── index.html
├── src
├── img
│ ├── nats-logo.png
│ └── kuali-logo.svg
├── index.scss
├── App.scss
├── _globals.scss
├── App.test.js
├── api
│ ├── web-socket.js
│ ├── server-config.js
│ └── nats-streaming.js
├── index.js
├── NotFound.js
├── components
│ ├── footer.js
│ ├── number-dash-widget.js
│ ├── nav-item-link.js
│ ├── store-view.js
│ ├── chart-dash-widget.js
│ ├── style.scss
│ ├── home-view.js
│ ├── server-view.js
│ ├── channel-view.js
│ └── client-view.js
├── utilities
│ └── data-transformer.js
├── logo.svg
├── App.js
└── registerServiceWorker.js
├── .npmignore
├── Dockerfile
├── server
├── proxy-proxy.js
├── nats-ss.js
├── routes
│ ├── server.js
│ └── channel-message.js
├── index.js
└── events
│ └── nats-to-socketio.js
├── .gitignore
├── README.md
└── package.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "react-app"
3 | }
4 |
--------------------------------------------------------------------------------
/bin/nats-monitor:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | require('../server')
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zen/nats-streaming-console/master/public/favicon.ico
--------------------------------------------------------------------------------
/src/img/nats-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zen/nats-streaming-console/master/src/img/nats-logo.png
--------------------------------------------------------------------------------
/src/index.scss:
--------------------------------------------------------------------------------
1 | @import 'globals';
2 |
3 | body {
4 | margin: 0;
5 | padding: 0;
6 | font-family: sans-serif;
7 | }
8 |
--------------------------------------------------------------------------------
/src/App.scss:
--------------------------------------------------------------------------------
1 | .navbar {
2 |
3 | }
4 |
5 | .navbar-item {
6 | display: block;
7 | float: left;
8 | text-decoration: none;
9 | width: 200px;
10 | }
--------------------------------------------------------------------------------
/src/_globals.scss:
--------------------------------------------------------------------------------
1 | @import 'react-md/src/scss/react-md';
2 |
3 | $md-light-theme: false;
4 | $md-primary-color: $md-light-blue-500;
5 | $md-secondary-color: $md-deep-orange-a-200;
6 |
7 | @include react-md-everything;
8 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from './App'
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div')
7 | ReactDOM.render(, div)
8 | })
9 |
--------------------------------------------------------------------------------
/src/api/web-socket.js:
--------------------------------------------------------------------------------
1 | import openSocket from 'socket.io-client'
2 | const socket = openSocket('/')
3 |
4 | export const subscribeToChannel = (channel, opts, cb) => {
5 | socket.on(channel, message => cb(message))
6 | socket.emit('subscribe-to-channel', { channel, opts })
7 | }
8 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | src
2 | examples
3 | coverage
4 | docs
5 | *.jpg
6 | *.png
7 | *.log
8 | CONTRIBUTING.md
9 | ISSUE_TEMPLATE.md
10 | PULL_REQUEST_TEMPLATE.md
11 | tslint.json
12 | .circle
13 | .gitignore
14 | .vscode
15 | .publishrc
16 | .rpt2_cache
17 |
18 | jest.config.js
19 | tsconfig.json
20 | tslint.json
21 | prettier.config.js
22 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:8-alpine
2 |
3 | ENV CODE /usr/src/app
4 | WORKDIR $CODE
5 |
6 | RUN mkdir -p $CODE
7 | COPY package.json $CODE
8 | COPY yarn.lock $CODE
9 | RUN yarn
10 |
11 | ADD public $CODE/public
12 | ADD server $CODE/server
13 | ADD src $CODE/src
14 |
15 | RUN yarn build-css
16 | RUN yarn build
17 |
18 | EXPOSE 8282
19 | CMD ["node", "server"]
20 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/server/proxy-proxy.js:
--------------------------------------------------------------------------------
1 | const proxy = require('http-proxy-middleware')
2 | const { options } = require('./nats-ss')
3 |
4 | const proxies = {}
5 |
6 | exports.proxy = (req, res, next) => {
7 | if (!proxies[options.monitor]) {
8 | proxies[options.monitor] = proxy('/streaming', {
9 | target: options.monitor,
10 | ws: true,
11 | })
12 | }
13 | proxies[options.monitor](req, res, next)
14 | }
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
23 | # build artifacts
24 | src/**/*.css
25 | .vscode
26 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { render } from 'react-dom'
3 | import { BrowserRouter } from 'react-router-dom'
4 | import './index.css'
5 | import App from './App'
6 | import registerServiceWorker from './registerServiceWorker'
7 | import WebFontLoader from 'webfontloader'
8 |
9 | WebFontLoader.load({
10 | google: {
11 | families: ['Roboto:300,400,500,700', 'Material Icons'],
12 | },
13 | })
14 |
15 | render(
16 |
17 |
18 | ,
19 | document.getElementById('root'),
20 | )
21 | registerServiceWorker()
22 |
--------------------------------------------------------------------------------
/src/NotFound.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import classnames from 'classnames'
4 |
5 | import './style.css'
6 |
7 | export default class NotFound extends Component {
8 | // static propTypes = {}
9 | // static defaultProps = {}
10 | // state = {}
11 |
12 | render() {
13 | const { className, ...props } = this.props
14 | return (
15 |
16 |
17 | 404 Not Found :(
18 |
19 |
20 | )
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/nats-ss.js:
--------------------------------------------------------------------------------
1 | const { getInstance: getNerveInstance } = require('nats-nerve')
2 |
3 | const defaults = {
4 | server: process.env.STAN_URL || 'nats://localhost:4222',
5 | monitor: process.env.STAN_MONITOR_URL || 'http://localhost:8222',
6 | cluster: process.env.STAN_CLUSTER || 'test-cluster',
7 | appName: 'nats-streaming-console',
8 | }
9 |
10 | exports.options = Object.assign({}, defaults)
11 | console.log({ options: exports.options })
12 |
13 | exports.getNerveInstance = async () => {
14 | const { server, cluster, appName } = exports.options
15 | return getNerveInstance(server, cluster, appName)
16 | }
17 |
--------------------------------------------------------------------------------
/src/api/server-config.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | // export const config = {
4 | // host: 'localhost',
5 | // port: '4222',
6 | // monitoringPort: '8222'
7 | // }
8 |
9 | export async function getServerConfig() {
10 | return axios({
11 | method: 'get',
12 | url: '/api/server',
13 | headers: { 'Content-Type': 'application/json' },
14 | }).then(resp => resp.data)
15 | }
16 |
17 | export async function updateServerConfig(data) {
18 | return axios({
19 | method: 'post',
20 | url: '/api/server',
21 | headers: { 'Content-Type': 'application/json' },
22 | data,
23 | }).then(resp => resp.data)
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/footer.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import logo from '../img/kuali-logo.svg'
4 | import './style.css'
5 |
6 | export default class NumberWidget extends Component {
7 | render() {
8 | const { number, text, title } = this.props
9 | return (
10 |
19 | )
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/number-dash-widget.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Toolbar } from 'react-md'
4 |
5 | import './style.css'
6 |
7 | export default class NumberWidget extends Component {
8 | static propTypes = {
9 | number: PropTypes.number.isRequired,
10 | text: PropTypes.string,
11 | title: PropTypes.string,
12 | }
13 |
14 | render() {
15 | const { number, text, title } = this.props
16 | return (
17 |
18 |
19 |
20 | {number}
21 | {text}
22 |
23 |
24 | )
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/server/routes/server.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios')
2 | const { getNerveInstance, options } = require('../nats-ss')
3 |
4 | exports.getServerOptions = async (req, res) => {
5 | if (options.server.split('@').length > 1) {
6 | options.server = `nats://${options.server.split('@')[1]}`
7 | }
8 | res.status(200).send(options)
9 | }
10 |
11 | exports.setServerOptions = async (req, res) => {
12 | const { host, port, monitoringPort } = req.body
13 | try {
14 | const resp = await axios({
15 | method: 'get',
16 | baseURL: `http://${host}:${monitoringPort}/`,
17 | url: '/streaming/serverz',
18 | headers: { Accept: 'application/json' },
19 | proxy: false,
20 | })
21 | updateOptions(resp.data, host, port, monitoringPort)
22 | res.status(200).send({ options, data: resp.data })
23 | } catch (err) {
24 | console.log({ err })
25 | res.status(500).send({ status: 'error' })
26 | }
27 | }
28 |
29 | function updateOptions(data, host, port, monitoringPort) {
30 | ;(options.server = `nats://${host}:${port}`),
31 | (options.monitor = `http://${host}:${monitoringPort}`),
32 | (options.cluster = data.cluster_id)
33 | }
34 |
--------------------------------------------------------------------------------
/server/routes/channel-message.js:
--------------------------------------------------------------------------------
1 | const Promise = require('bluebird')
2 | const { getNerveInstance } = require('../nats-ss')
3 |
4 | exports.channelMessageList = async (req, res) => {
5 | try {
6 | const { channel } = req.params
7 | const nerve = await getNerveInstance()
8 | const opts = { startAtSequence: 10 }
9 | let count = 0
10 | const messages = []
11 | const disposer = getNerveSubscription(nerve, channel, opts, msg => {
12 | messages.push({
13 | sequence: msg.getSequence(),
14 | timestamp: msg.getTimestamp(),
15 | subject: msg.getSubject(),
16 | data: msg.getData(),
17 | })
18 | })
19 | Promise.using(disposer, async subscription => {
20 | await Promise.delay(1000)
21 | res.send(messages)
22 | })
23 | } catch (err) {
24 | console.log({ err })
25 | res.status(500).send(err)
26 | }
27 | }
28 |
29 | function getNerveSubscription(nerve, channel, opts, fn) {
30 | return Promise.resolve(nerve.subscribe(channel, opts, fn)).disposer(subscription => {
31 | return new Promise((resolve, reject) => {
32 | subscription.unsubscribe()
33 | subscription.on('unsubscribed', () => {
34 | resolve()
35 | })
36 | })
37 | })
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/nav-item-link.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Link, Route } from 'react-router-dom'
4 | import { FontIcon, ListItem } from 'react-md'
5 |
6 | /**
7 | * Due to the fact that react-router uses context and most of the components
8 | * in react-md use PureComponent, the matching won't work as expected since
9 | * the PureComponent will block the context updates. This is a simple wrapper
10 | * with Route to make sure that the active state is correctly applied after
11 | * an item has been clicked.
12 | */
13 | const NavItemLink = ({ label, to, icon, exact }) => (
14 |
15 | {({ match }) => {
16 | let leftIcon
17 | if (icon) {
18 | leftIcon = {icon}
19 | }
20 |
21 | return (
22 |
29 | )
30 | }}
31 |
32 | )
33 |
34 | NavItemLink.propTypes = {
35 | label: PropTypes.string.isRequired,
36 | to: PropTypes.string,
37 | exact: PropTypes.bool,
38 | icon: PropTypes.node,
39 | }
40 | export default NavItemLink
41 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | const _http = require('http')
2 | const path = require('path')
3 | const express = require('express')
4 | const bodyParser = require('body-parser')
5 | const socketio = require('socket.io')
6 | const { proxy } = require('./proxy-proxy')
7 | const { getServerOptions, setServerOptions } = require('./routes/server')
8 | const { channelMessageList } = require('./routes/channel-message')
9 | const { Bridge } = require('./events/nats-to-socketio')
10 | require('./nats-ss')
11 |
12 | // SETUP APPLICATION SERVER
13 | const app = express()
14 | const http = _http.Server(app)
15 | app.use(express.static(path.join(__dirname, '../build')))
16 | app.use('/streaming', proxy)
17 | app.get('/api/server', getServerOptions)
18 | app.post('/api/server', bodyParser.json(), setServerOptions)
19 | app.get('/api/channel/:channel/message', channelMessageList)
20 | app.get('*', (req, res) => {
21 | res.status(404).send('Not here.')
22 | })
23 |
24 | // SETUP WEB SOCKETS
25 | const io = socketio(http)
26 | io.on('connection', client => {
27 | const clientMessageBridge = new Bridge(client)
28 | client.on('subscribe-to-channel', data => {
29 | clientMessageBridge.subscribeToChannel(data)
30 | })
31 | client.on('unsubscribe-from-channel', data => {
32 | clientMessageBridge.unsubscribeFromChannel(data)
33 | })
34 | })
35 |
36 | // START SERVER
37 | const server = http.listen(8282, () => {
38 | var host = server.address().address
39 | var port = server.address().port
40 | console.log(`Example app listening at http://${host}:${port}`)
41 | })
42 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | React App
23 |
24 |
25 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/server/events/nats-to-socketio.js:
--------------------------------------------------------------------------------
1 | const Promise = require('bluebird')
2 | const { getNerveInstance } = require('../nats-ss')
3 |
4 | exports.Bridge = class Bridge {
5 | constructor(client) {
6 | this.client = client
7 | this.subscriptions = {}
8 | }
9 |
10 | async subscribeToChannel(data) {
11 | console.log({ data })
12 | const { channel, opts } = data
13 | console.log({ channel, opts })
14 | try {
15 | const nerve = await getNerveInstance()
16 | const subscription = nerve.subscribe(channel, opts, msg => {
17 | const message = {
18 | sequence: msg.getSequence(),
19 | timestamp: msg.getTimestamp(),
20 | subject: msg.getSubject(),
21 | data: msg.getData(),
22 | }
23 | console.log({ channel, message })
24 | this.client.emit(channel, message)
25 | })
26 | this.subscriptions[channel] = { nerve, subscription }
27 | console.log({ subscriptions: this.subscriptions })
28 | } catch (err) {
29 | console.log('MEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEME')
30 | console.log(err)
31 | console.log('MEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEMEME')
32 | }
33 | }
34 |
35 | async unsubscribeFromChannel(channel) {
36 | console.log('UNSUBSCRIBE ===============================================>')
37 | console.log({ channel })
38 | const { subscription } = this.subscriptions[channel]
39 | return new Promise((resolve, reject) => {
40 | subscription.unsubscribe()
41 | subscription.on('unsubscribed', () => {
42 | resolve()
43 | })
44 | })
45 | }
46 |
47 | async unsubscribeAll() {
48 | const promises = this.subscriptions.map((sub, channel) => {
49 | this.unsubscribeFromChannel(channel)
50 | })
51 | return Promise.all(promises)
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Nats Monitor
2 |
3 | A web console for Nats Streaming Server.
4 |
5 | This repository is forked from [nats-streaming-console](https://github.com/KualiCo/nats-streaming-console).
6 |
7 | # Install
8 |
9 | ```
10 | npm -g install nats-monitor
11 |
12 | nats-monitor
13 | ```
14 |
15 | For other installation method, please read the below.
16 |
17 | ## Dashboard
18 |
19 |
20 |
21 | ## Clients
22 |
23 |
24 |
25 | ## Channels
26 |
27 |
28 |
29 | ## Server
30 |
31 |
32 |
33 | ## Store
34 |
35 |
36 |
37 | # Configuration
38 |
39 | ```
40 | env STAN_URL=nats://127.0.0.1:4222 STAN_MONITOR_URL=http://127.0.0.1:8222 STAN_CLUSTER=my-cluster nats-monitor
41 |
42 | ```
43 |
44 | # Usage
45 |
46 | There are four ways other than npm to use Nats Monitor:
47 |
48 | ## Docker
49 |
50 | The docker image is available from dockerhub
51 | [https://hub.docker.com/r/mozgoo/nats-streaming-console](https://hub.docker.com/r/mozgoo/nats-streaming-console)
52 |
53 | ## Electron App
54 |
55 | Coming soon
56 |
57 | ## Checkout and run the Codes!
58 |
59 | If you just want to build and run it. That is pretty easy too. You will need git and nodejs.
60 |
61 | ```sh
62 | git clone https://github.com/KualiCo/nats-streaming-console.git
63 | cd nats-streaming-console
64 | yarn install
65 | yarn start
66 | ```
67 |
68 | Good luck. Let me know if something goes wrong.
69 |
--------------------------------------------------------------------------------
/src/utilities/data-transformer.js:
--------------------------------------------------------------------------------
1 | import { timeParse, ascending } from 'd3'
2 |
3 | const parseDate = timeParse('%m/%d/%Y')
4 |
5 | function flattenData(data) {
6 | let flattenedData = []
7 | let keys = []
8 | data.forEach(function(dataArray) {
9 | dataArray.forEach(function(row) {
10 | if (keys.indexOf(row.name) < 0) {
11 | keys.push(row.name)
12 | }
13 | let thisDate = row.date
14 | row.date = parseDate(row.date)
15 | let metricName = row.name
16 | row[metricName] = +row.count
17 | delete row.name
18 | delete row.count
19 | })
20 | flattenedData = flattenedData.concat(dataArray)
21 | })
22 | return {
23 | data: flattenedData,
24 | keys: keys,
25 | }
26 | }
27 |
28 | function combineData(data) {
29 | let combinedData = []
30 | data.forEach(function(row) {
31 | let thisDate = row.date
32 | let index = combinedData.findIndex(x => +x.date === +thisDate)
33 | if (index > 0) {
34 | combinedData[index] = Object.assign(combinedData[index], row)
35 | } else {
36 | combinedData.push(row)
37 | }
38 | })
39 | return combinedData
40 | }
41 |
42 | function fillZeros(data, keys) {
43 | data.forEach(function(row) {
44 | keys.forEach(function(key) {
45 | if (!row[key]) {
46 | row[key] = 0
47 | }
48 | })
49 | })
50 | return data
51 | }
52 |
53 | function sortData(data) {
54 | data.sort(function(x, y) {
55 | return ascending(x.date, y.date)
56 | })
57 | return data
58 | }
59 |
60 | function roundDate(date) {
61 | if (date && date.setHours) {
62 | date.setHours(0)
63 | date.setMinutes(0)
64 | date.setSeconds(0)
65 | date.setMilliseconds(0)
66 | return date
67 | }
68 | }
69 |
70 | function findValue(data, date, metric) {
71 | let myVal
72 | data.forEach(function(row) {
73 | if (+row.data.date == +date) {
74 | myVal = row.data[metric]
75 | }
76 | })
77 | return myVal
78 | }
79 |
80 | export { flattenData, combineData, fillZeros, sortData, roundDate, findValue }
81 |
--------------------------------------------------------------------------------
/src/components/store-view.js:
--------------------------------------------------------------------------------
1 | import { isString } from 'lodash'
2 | import React, { Component } from 'react'
3 | import PropTypes from 'prop-types'
4 | import classnames from 'classnames'
5 | import { getStores } from '../api/nats-streaming'
6 | import { DataTable, TableHeader, TableBody, TableRow, TableColumn } from 'react-md'
7 |
8 | import './style.css'
9 |
10 | export default class Stores extends Component {
11 | // static propTypes = {}
12 | // static defaultProps = {}
13 | constructor(props) {
14 | super(props)
15 |
16 | this.state = {
17 | stores: undefined,
18 | }
19 | }
20 |
21 | componentDidMount = async () => {
22 | const stores = await getStores()
23 | this.setState({ stores: Object.entries(stores) })
24 | }
25 |
26 | toTable = obj => {
27 | const array = Object.entries(obj)
28 | return (
29 |
30 |
31 |
32 | Key
33 | Value
34 |
35 |
36 |
37 | {array &&
38 | array.map(tuple => (
39 |
40 | {tuple[0]}
41 | {isString ? tuple[1] : this.toTable(tuple[1])}
42 |
43 | ))}
44 |
45 |
46 | )
47 | }
48 |
49 | render() {
50 | const { stores } = this.state
51 | return (
52 |
53 |
54 |
55 | Key
56 | Value
57 |
58 |
59 |
60 | {stores &&
61 | stores.map(tuple => (
62 |
63 | {tuple[0]}
64 | {isString(tuple[1]) ? tuple[1] : this.toTable(tuple[1])}
65 |
66 | ))}
67 |
68 |
69 | )
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/chart-dash-widget.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Toolbar } from 'react-md'
4 | import { Area, AreaChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts'
5 |
6 | import './style.css'
7 |
8 | export default class ChartWidget extends Component {
9 | static propTypes = {
10 | data: PropTypes.any.isRequired,
11 | title: PropTypes.string,
12 | }
13 |
14 | getClientDimensions = () => {
15 | const w = window
16 | const d = document
17 | const e = d.documentElement
18 | const g = d.getElementsByTagName('body')[0]
19 | const width = w.innerWidth || e.clientWidth || g.clientWidth
20 | const height = w.innerHeight || e.clientHeight || g.clientHeight
21 | return { width, height }
22 | }
23 |
24 | render() {
25 | const { data, title } = this.props
26 | const { width, height } = this.getClientDimensions()
27 | console.log({ width, height })
28 | return (
29 |
30 |
31 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
53 |
60 |
61 |
62 | )
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nats-monitor",
3 | "version": "1.0.7",
4 | "author": [
5 | "kuali",
6 | "acro5piano"
7 | ],
8 | "license": "MIT",
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/acro5piano/nats-streaming-console.git"
12 | },
13 | "bugs": {
14 | "url": "https://github.com/acro5piano/nats-streaming-console/issues"
15 | },
16 | "bin": {
17 | "nats-monitor": "./bin/nats-monitor"
18 | },
19 | "scripts": {
20 | "prepublish": "yarn build",
21 | "build": "npm-run-all -p build:*",
22 | "build:css": "node-sass-chokidar --include-path ./node_modules src/ -o src/",
23 | "watch:css": "npm run build:css -- --watch --recursive",
24 | "start": "npm-run-all -p server watch:css start:react",
25 | "start:react": "react-scripts start",
26 | "build:react": "react-scripts build",
27 | "test": "react-scripts test --env=jsdom",
28 | "eject": "react-scripts eject",
29 | "server": "node server",
30 | "lint": "eslint --fix {src,server}/**/*.{js,jsx}",
31 | "format": "prettier --write {src,server}/**/*.{js,jsx} README.md"
32 | },
33 | "proxy": "http://localhost:8282",
34 | "dependencies": {
35 | "axios": "0.17.1",
36 | "bluebird": "3.5.1",
37 | "body-parser": "1.18.2",
38 | "express": "4.16.2",
39 | "http-proxy-middleware": "0.17.4",
40 | "nats-nerve": "1.0.3",
41 | "socket.io": "2.0.4"
42 | },
43 | "devDependencies": {
44 | "classnames": "2.2.5",
45 | "husky": "^3.0.0",
46 | "lint-staged": "^9.0.1",
47 | "lodash": "4.17.4",
48 | "node-sass-chokidar": "0.0.3",
49 | "npm-run-all": "4.1.2",
50 | "prettier": "^1.18.2",
51 | "prop-types": "15.6.0",
52 | "react": "16.2.0",
53 | "react-dom": "16.2.0",
54 | "react-json-tree": "0.11.0",
55 | "react-md": "1.2.11",
56 | "react-router": "4.2.0",
57 | "react-router-dom": "4.2.2",
58 | "react-scripts": "1.1.0",
59 | "recharts": "1.0.0-beta.9",
60 | "socket.io-client": "2.0.4",
61 | "webfontloader": "1.6.28"
62 | },
63 | "husky": {
64 | "hooks": {
65 | "pre-commit": "lint-staged"
66 | }
67 | },
68 | "lint-staged": {
69 | "*.{js,ts,tsx}": [
70 | "eslint --fix",
71 | "prettier --write",
72 | "git add"
73 | ]
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/img/kuali-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/components/style.scss:
--------------------------------------------------------------------------------
1 | @import '../globals';
2 |
3 | body {
4 | margin: 0;
5 | padding: 0;
6 | font-family: sans-serif;
7 | }
8 |
9 | .view {
10 | display: flex;
11 | flex-wrap: wrap;
12 | justify-content: space-around;
13 | }
14 |
15 | .column-view {
16 | display: flex;
17 | flex-wrap: nowrap;
18 | justify-content: left;
19 | }
20 |
21 | .menu-wrapper {
22 | background-color: get-color('card', $md-light-theme);
23 | margin: 0 7px 0 0;
24 | }
25 |
26 | .menu-list {
27 | min-width: 400px;
28 | }
29 |
30 | .menu-input {
31 | padding: 0 7px;
32 | }
33 |
34 | .menu-input button {
35 | margin-top: -13px;
36 | }
37 |
38 | .dashboard-widget-one-by-one {
39 | background-color: get-color('card', $md-light-theme);
40 | display: flex;
41 | flex-basis: 20%;
42 | flex-direction: column;
43 | flex-grow: 1;
44 | min-height: 220px;
45 | min-width: 220px;
46 | margin: 4px;
47 | }
48 |
49 | .dashboard-widget-four-by-one {
50 | background-color: get-color('card', $md-light-theme);
51 | display: flex;
52 | flex-basis: 75%;
53 | flex-direction: column;
54 | flex-grow: 4;
55 | min-height: 220px;
56 | min-width: 320px;
57 | margin: 4px;
58 | }
59 |
60 | .dashboard-widget-content {
61 | align-items: center;
62 | display: flex;
63 | flex-direction: column;
64 | flex-grow: 5;
65 | justify-content: center;
66 | padding: 15px;
67 | }
68 |
69 | .dashboard-widget-content .number {
70 | color: white;
71 | display: block;
72 | font-size: 4em;
73 | font-weight: bold;
74 | }
75 |
76 | .dashboard-widget-content .text {
77 | color: #eee;
78 | font-size: 1.5em;
79 | text-align: center;
80 | text-transform: uppercase;
81 | }
82 |
83 | .dashboard-footer {
84 | flex-basis: 100%;
85 | display: flex;
86 | justify-content: center;
87 | margin-top: 3em;
88 | }
89 |
90 | .detail-dialog {
91 | width: 60%;
92 | }
93 |
94 | @media (min-width: 415px) and (max-width: 1024px) {
95 | .detail-dialog {
96 | width: 80%;
97 | }
98 |
99 | .menu-wrapper {
100 | width: 400px;
101 | }
102 |
103 | .menu-list {
104 | min-width: 400px;
105 | }
106 | }
107 |
108 | @media (min-width: 376px) and (max-width: 414px) {
109 | .detail-dialog {
110 | width: 80%;
111 | }
112 |
113 | .menu-wrapper {
114 | width: 352px;
115 | }
116 |
117 | .menu-list {
118 | min-width: 352px;
119 | }
120 | }
121 |
122 | @media (min-width: 361px) and (max-width: 375px) {
123 | .detail-dialog {
124 | width: 80%;
125 | }
126 |
127 | .menu-wrapper {
128 | width: 312px;
129 | }
130 |
131 | .menu-list {
132 | min-width: 312px;
133 | }
134 | }
135 |
136 | @media (max-width: 360px) {
137 | .detail-dialog {
138 | width: 80%;
139 | }
140 |
141 | .menu-wrapper {
142 | width: 300px;
143 | }
144 |
145 | .menu-list {
146 | min-width: 300px;
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/components/home-view.js:
--------------------------------------------------------------------------------
1 | import { each, get, isArray, last, size } from 'lodash'
2 | import React, { Component } from 'react'
3 | import PropTypes from 'prop-types'
4 | import classnames from 'classnames'
5 | import NumberWidget from './number-dash-widget'
6 | import ChartWidget from './chart-dash-widget'
7 | import { getChannels, getClients, getMessages } from '../api/nats-streaming'
8 | import Footer from './footer'
9 |
10 | import './style.css'
11 |
12 | export default class Clients extends Component {
13 | state = {
14 | timeSeries: [],
15 | }
16 |
17 | async componentDidMount() {
18 | await this.getMonitorData()
19 | this.refresher = setInterval(() => this.getMonitorData(), 5000)
20 | }
21 |
22 | componentWillUnmount() {
23 | clearInterval(this.refresher)
24 | }
25 |
26 | getMonitorData = async () => {
27 | try {
28 | const [channelSummary, clientSummary] = await Promise.all([getChannels(), getClients()])
29 | const stats = {}
30 | const channels = channelSummary.channels
31 | const clients = clientSummary.clients
32 | if (channels && isArray(channels)) {
33 | stats.channels = channels.length
34 | stats.messages = channels.reduce((count, channel) => (count += channel.msgs), 0)
35 | }
36 | if (clients && isArray(clients)) {
37 | stats.clients = clients.length
38 | stats.subscriptions = clients.reduce((count, client) => {
39 | count += client.subscriptions ? size(client.subscriptions) : 0
40 | return count
41 | }, 0)
42 | }
43 | this.setState({ ...stats, timeSeries: this.getTimeSeriesData(stats) })
44 | } catch (err) {
45 | console.log({ name: err.name, message: err.message, fileName: err.fileName })
46 | window.location.href = '/server'
47 | }
48 | }
49 |
50 | getTimeSeriesData = stats => {
51 | const date = new Date()
52 | const { timeSeries: ts } = this.state
53 | const tsClone = ts.slice(ts.length >= 60 ? 1 : 0)
54 | const lastValues = last(ts)
55 | tsClone.push(
56 | Object.assign(
57 | {
58 | name: date.toLocaleTimeString(),
59 | newMsgs: lastValues ? stats.messages - lastValues.messages : 0,
60 | },
61 | stats,
62 | ),
63 | )
64 | return tsClone
65 | }
66 |
67 | render() {
68 | const { clients, channels, messages, subscriptions, timeSeries } = this.state
69 | return (
70 |
71 | {clients !== undefined && (
72 |
73 | )}
74 | {channels !== undefined && (
75 |
76 | )}
77 | {subscriptions !== undefined && (
78 |
79 | )}
80 | {messages !== undefined && (
81 |
82 | )}
83 |
84 |
85 |
86 | )
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import { upperFirst } from 'lodash/string'
2 | import React, { Component } from 'react'
3 | import PropTypes from 'prop-types'
4 | import { NavigationDrawer } from 'react-md'
5 | import { withRouter } from 'react-router'
6 | import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'
7 | import logo from './logo.svg'
8 | import NavItemLink from './components/nav-item-link'
9 | import Home from './components/home-view'
10 | import Clients from './components/client-view'
11 | import Channels from './components/channel-view'
12 | import Server from './components/server-view'
13 | import Store from './components/store-view'
14 |
15 | import './App.css'
16 |
17 | const navItems = [
18 | {
19 | label: 'Home',
20 | to: '/',
21 | exact: true,
22 | icon: 'home',
23 | },
24 | {
25 | label: 'Clients',
26 | to: '/clients',
27 | icon: 'perm_identity',
28 | },
29 | {
30 | label: 'Channels',
31 | to: '/channels',
32 | icon: 'inbox',
33 | },
34 | {
35 | label: 'Server',
36 | to: '/server',
37 | icon: 'dns',
38 | },
39 | {
40 | label: 'Store',
41 | to: '/store',
42 | icon: 'store',
43 | },
44 | ]
45 |
46 | const styles = {
47 | content: { minHeight: 'auto' },
48 | }
49 |
50 | class App extends Component {
51 | static propTypes = {
52 | location: PropTypes.object.isRequired,
53 | }
54 |
55 | constructor(props) {
56 | super(props)
57 | this.state = { toolbarTitle: this.getCurrentTitle(props) }
58 | }
59 |
60 | componentWillReceiveProps(nextProps) {
61 | this.setState({ toolbarTitle: this.getCurrentTitle(nextProps) })
62 | }
63 |
64 | getCurrentTitle = props => {
65 | const { pathname } = props.location
66 | const lastSection = pathname.substring(pathname.lastIndexOf('/') + 1)
67 | return lastSection === ''
68 | ? 'Nats Streaming Console'
69 | : `Nats Streaming Console: ${this.toTitle(lastSection)}`
70 | }
71 |
72 | toTitle = str => {
73 | return str.split(/-|[A-Z]+/).reduce((s, split) => {
74 | const capititalized = split.match(/svg$/) ? 'SVG' : upperFirst(split)
75 | return `${s ? `${s} ` : ''}${capititalized}`
76 | }, '')
77 | }
78 |
79 | render() {
80 | const { toolbarTitle } = this.state
81 | const { location } = this.props
82 | return (
83 | (
89 |
90 | ))}
91 | contentId="app-content"
92 | contentStyle={styles.content}
93 | contentClassName="md-grid"
94 | >
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | )
104 | }
105 | }
106 |
107 | export default withRouter(App)
108 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/),
17 | )
18 |
19 | export default function register() {
20 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
21 | // The URL constructor is available in all browsers that support SW.
22 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location)
23 | if (publicUrl.origin !== window.location.origin) {
24 | // Our service worker won't work if PUBLIC_URL is on a different origin
25 | // from what our page is served on. This might happen if a CDN is used to
26 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
27 | return
28 | }
29 |
30 | window.addEventListener('load', () => {
31 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
32 |
33 | if (!isLocalhost) {
34 | // Is not local host. Just register service worker
35 | registerValidSW(swUrl)
36 | } else {
37 | // This is running on localhost. Lets check if a service worker still exists or not.
38 | checkValidServiceWorker(swUrl)
39 | }
40 | })
41 | }
42 | }
43 |
44 | function registerValidSW(swUrl) {
45 | navigator.serviceWorker
46 | .register(swUrl)
47 | .then(registration => {
48 | registration.onupdatefound = () => {
49 | const installingWorker = registration.installing
50 | installingWorker.onstatechange = () => {
51 | if (installingWorker.state === 'installed') {
52 | if (navigator.serviceWorker.controller) {
53 | // At this point, the old content will have been purged and
54 | // the fresh content will have been added to the cache.
55 | // It's the perfect time to display a "New content is
56 | // available; please refresh." message in your web app.
57 | console.log('New content is available; please refresh.')
58 | } else {
59 | // At this point, everything has been precached.
60 | // It's the perfect time to display a
61 | // "Content is cached for offline use." message.
62 | console.log('Content is cached for offline use.')
63 | }
64 | }
65 | }
66 | }
67 | })
68 | .catch(error => {
69 | console.error('Error during service worker registration:', error)
70 | })
71 | }
72 |
73 | function checkValidServiceWorker(swUrl) {
74 | // Check if the service worker can be found. If it can't reload the page.
75 | fetch(swUrl)
76 | .then(response => {
77 | // Ensure service worker exists, and that we really are getting a JS file.
78 | if (
79 | response.status === 404 ||
80 | response.headers.get('content-type').indexOf('javascript') === -1
81 | ) {
82 | // No service worker found. Probably a different app. Reload the page.
83 | navigator.serviceWorker.ready.then(registration => {
84 | registration.unregister().then(() => {
85 | window.location.reload()
86 | })
87 | })
88 | } else {
89 | // Service worker found. Proceed as normal.
90 | registerValidSW(swUrl)
91 | }
92 | })
93 | .catch(() => {
94 | console.log('No internet connection found. App is running in offline mode.')
95 | })
96 | }
97 |
98 | export function unregister() {
99 | if ('serviceWorker' in navigator) {
100 | navigator.serviceWorker.ready.then(registration => {
101 | registration.unregister()
102 | })
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/components/server-view.js:
--------------------------------------------------------------------------------
1 | import { assignIn, pick } from 'lodash'
2 | import React, { Component } from 'react'
3 | import PropTypes from 'prop-types'
4 | import classnames from 'classnames'
5 | import { getServers } from '../api/nats-streaming'
6 | import { getServerConfig, updateServerConfig } from '../api/server-config'
7 | import {
8 | Button,
9 | Card,
10 | CardActions,
11 | CardTitle,
12 | CardText,
13 | DataTable,
14 | Media,
15 | MediaOverlay,
16 | Snackbar,
17 | TableHeader,
18 | TableBody,
19 | TableRow,
20 | TableColumn,
21 | TextField,
22 | Toolbar,
23 | } from 'react-md'
24 | import logo from '../img/nats-logo.png'
25 | import './style.css'
26 | import SnackbarContainer from 'react-md/lib/Snackbars/SnackbarContainer'
27 |
28 | export default class Servers extends Component {
29 | constructor(props) {
30 | super(props)
31 |
32 | this.state = {
33 | host: 'localhost',
34 | port: '4222',
35 | monitoringPort: '8222',
36 | connected: false,
37 | config: undefined,
38 | servers: undefined,
39 | }
40 | }
41 |
42 | componentDidMount = async () => {
43 | try {
44 | const [config, servers] = await Promise.all([getServerConfig(), getServers()])
45 | console.log({ config, servers })
46 | this.setState({ connected: true, config, servers })
47 | } catch (err) {
48 | if (err.message === 'Request failed with status code 504') {
49 | this.setState({ connected: false })
50 | }
51 | }
52 | }
53 |
54 | configure = () => {
55 | this.setState({ connected: false })
56 | }
57 |
58 | changeHost = value => {
59 | this.setState({ host: value })
60 | }
61 |
62 | changePort = value => {
63 | this.setState({ port: value })
64 | }
65 |
66 | changeMonitoringPort = value => {
67 | this.setState({ monitoringPort: value })
68 | }
69 |
70 | submit = async () => {
71 | const data = pick(this.state, ['host', 'port', 'monitoringPort'])
72 | try {
73 | const { options: config, data: servers } = await updateServerConfig(data)
74 | console.log({ config, servers })
75 | this.setState({ connected: true, config, servers })
76 | } catch (err) {
77 | this.setState({
78 | error: `Could not connect. Check that your Nats Streaming Server is configured to allow monitoring at http://${data.host}:${data.monitoringPort}`,
79 | })
80 | }
81 | }
82 |
83 | clearErrors = () => {
84 | this.setState({ error: undefined })
85 | }
86 |
87 | render() {
88 | const { connected } = this.state
89 | return connected ? this.renderServerInfo() : this.renderUpdate()
90 | }
91 |
92 | renderServerInfo() {
93 | const { configure } = this
94 | const { config, servers } = this.state
95 |
96 | const configEntries = Object.entries(config)
97 | const serversEntries = Object.entries(servers)
98 |
99 | return (
100 |
101 |
104 | {configEntries && configEntries.length && (
105 |
106 |
107 |
108 | Key
109 | Value
110 |
111 |
112 |
113 | {configEntries.map(tuple => (
114 |
115 | {tuple[0]}
116 | {tuple[1]}
117 |
118 | ))}
119 |
120 |
121 | )}
122 | {serversEntries && serversEntries.length && (
123 |
124 |
125 |
126 | Key
127 | Value
128 |
129 |
130 |
131 | {serversEntries.map(tuple => (
132 |
133 | {tuple[0]}
134 | {tuple[1]}
135 |
136 | ))}
137 |
138 |
139 | )}
140 |
141 | )
142 | }
143 |
144 | renderUpdate() {
145 | const { error, host, port, monitoringPort } = this.state
146 | const { changeHost, changePort, changeMonitoringPort, clearErrors, submit } = this
147 | return (
148 |
149 |
150 |
151 |
152 |
153 |
154 |
161 |
168 |
175 |
176 |
177 |
180 |
181 |
182 | {error && (
183 |
189 | )}
190 |
191 | )
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/src/api/nats-streaming.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | // const SERVERS = {
4 | // "cluster_id": "test-cluster",
5 | // "server_id": "cwZa1M7yf8hdEfdEIrLPtG",
6 | // "version": "0.5.0",
7 | // "go": "go1.7.6",
8 | // "state": "STANDALONE",
9 | // "now": "2017-10-13T22:23:11.0329339Z",
10 | // "start_time": "2017-10-13T20:37:28.3764861Z",
11 | // "uptime": "1h45m42s",
12 | // "clients": 2,
13 | // "subscriptions": 6,
14 | // "channels": 6,
15 | // "total_msgs": 771,
16 | // "total_bytes": 105806
17 | // }
18 |
19 | // const STORES = {
20 | // "cluster_id": "test-cluster",
21 | // "server_id": "cwZa1M7yf8hdEfdEIrLPtG",
22 | // "now": "2017-10-13T21:12:13.9924207Z",
23 | // "type": "FILE",
24 | // "limits": {
25 | // "max_channels": 100,
26 | // "max_msgs": 1000000,
27 | // "max_bytes": 1024000000,
28 | // "max_age": 0,
29 | // "max_subscriptions": 1000
30 | // },
31 | // "total_msgs": 771,
32 | // "total_bytes": 105806
33 | // }
34 |
35 | // const CHANNELS = {
36 | // "cluster_id": "test-cluster",
37 | // "server_id": "cwZa1M7yf8hdEfdEIrLPtG",
38 | // "now": "2017-10-13T22:23:47.4196729Z",
39 | // "offset": 0,
40 | // "limit": 1024,
41 | // "count": 6,
42 | // "total": 6,
43 | // "channels": [
44 | // {
45 | // "name": "cor",
46 | // "msgs": 0,
47 | // "bytes": 0,
48 | // "first_seq": 0,
49 | // "last_seq": 0
50 | // },
51 | // {
52 | // "name": "hackweek",
53 | // "msgs": 0,
54 | // "bytes": 0,
55 | // "first_seq": 0,
56 | // "last_seq": 0
57 | // },
58 | // {
59 | // "name": "hackweek.test.subject.one",
60 | // "msgs": 420,
61 | // "bytes": 57413,
62 | // "first_seq": 1,
63 | // "last_seq": 420,
64 | // "subscriptions": [
65 | // {
66 | // "inbox": "_INBOX.GR2GOUWO1Q3HAF1UTZYTCG",
67 | // "ack_inbox": "_INBOX.cwZa1M7yf8hdEfdEIrLQ2Y",
68 | // "durable_name": "test-durable-name",
69 | // "is_durable": true,
70 | // "max_inflight": 1,
71 | // "ack_wait": 10,
72 | // "last_sent": 416,
73 | // "pending_count": 1,
74 | // "is_stalled": true
75 | // }
76 | // ]
77 | // },
78 | // {
79 | // "name": "hackweek.test.subject.three",
80 | // "msgs": 140,
81 | // "bytes": 19613,
82 | // "first_seq": 1,
83 | // "last_seq": 140,
84 | // "subscriptions": [
85 | // {
86 | // "inbox": "_INBOX.GR2GOUWO1Q3HAF1UTZYTIC",
87 | // "ack_inbox": "_INBOX.cwZa1M7yf8hdEfdEIrLQ5e",
88 | // "durable_name": "test-durable-name",
89 | // "is_durable": true,
90 | // "max_inflight": 1,
91 | // "ack_wait": 10,
92 | // "last_sent": 139,
93 | // "pending_count": 1,
94 | // "is_stalled": true
95 | // }
96 | // ]
97 | // },
98 | // {
99 | // "name": "hackweek.test.subject.two",
100 | // "msgs": 211,
101 | // "bytes": 28780,
102 | // "first_seq": 1,
103 | // "last_seq": 211,
104 | // "subscriptions": [
105 | // {
106 | // "inbox": "_INBOX.GR2GOUWO1Q3HAF1UTZYTFE",
107 | // "ack_inbox": "_INBOX.cwZa1M7yf8hdEfdEIrLQ46",
108 | // "durable_name": "test-durable-name",
109 | // "is_durable": true,
110 | // "max_inflight": 1,
111 | // "ack_wait": 10,
112 | // "last_sent": 209,
113 | // "pending_count": 1,
114 | // "is_stalled": true
115 | // }
116 | // ]
117 | // },
118 | // {
119 | // "name": "toElasticsearch",
120 | // "msgs": 0,
121 | // "bytes": 0,
122 | // "first_seq": 0,
123 | // "last_seq": 0
124 | // }
125 | // ]
126 | // }
127 |
128 | // const CLIENTS =
129 | // {
130 | // "cluster_id": "test-cluster",
131 | // "server_id": "cwZa1M7yf8hdEfdEIrLPtG",
132 | // "now": "2017-10-13T22:25:02.896463Z",
133 | // "offset": 0,
134 | // "limit": 1024,
135 | // "count": 4,
136 | // "total": 4,
137 | // "clients": [
138 | // {
139 | // "id": "cor-main-default_9af7de4b6740",
140 | // "hb_inbox": "_INBOX.6S17LBVY36GXN499WPVDS6"
141 | // },
142 | // {
143 | // "id": "cor-workflows-development_349574cbccba",
144 | // "hb_inbox": "_INBOX.26QSTN9JZQOXBZAXOXHOA5"
145 | // },
146 | // {
147 | // "id": "test-ordered-subscriber-2",
148 | // "hb_inbox": "_INBOX.V9O12JF9BCV1KXVUC2VPEK",
149 | // "subscriptions": {
150 | // "hackweek.test.subject.one": [
151 | // {
152 | // "inbox": "_INBOX.V9O12JF9BCV1KXVUC2VPSW",
153 | // "ack_inbox": "_INBOX.cwZa1M7yf8hdEfdEIrLaQg",
154 | // "durable_name": "test-durable-name",
155 | // "is_durable": true,
156 | // "max_inflight": 1,
157 | // "ack_wait": 10,
158 | // "last_sent": 437,
159 | // "pending_count": 0,
160 | // "is_stalled": false
161 | // }
162 | // ],
163 | // "hackweek.test.subject.three": [
164 | // {
165 | // "inbox": "_INBOX.V9O12JF9BCV1KXVUC2VQLK",
166 | // "ack_inbox": "_INBOX.cwZa1M7yf8hdEfdEIrLaTm",
167 | // "durable_name": "test-durable-name",
168 | // "is_durable": true,
169 | // "max_inflight": 1,
170 | // "ack_wait": 10,
171 | // "last_sent": 145,
172 | // "pending_count": 0,
173 | // "is_stalled": false
174 | // }
175 | // ],
176 | // "hackweek.test.subject.two": [
177 | // {
178 | // "inbox": "_INBOX.V9O12JF9BCV1KXVUC2VQ78",
179 | // "ack_inbox": "_INBOX.cwZa1M7yf8hdEfdEIrLaSE",
180 | // "durable_name": "test-durable-name",
181 | // "is_durable": true,
182 | // "max_inflight": 1,
183 | // "ack_wait": 10,
184 | // "last_sent": 219,
185 | // "pending_count": 0,
186 | // "is_stalled": false
187 | // }
188 | // ]
189 | // }
190 | // },
191 | // {
192 | // "id": "test-publisher",
193 | // "hb_inbox": "_INBOX.XC4NIJC4C68NIN6JX4DJY8"
194 | // }
195 | // ]
196 | // }
197 |
198 | export function getServers() {
199 | // return Promise.resolve(SERVERS)
200 | return axios({
201 | method: 'get',
202 | url: '/streaming/serverz',
203 | headers: { 'Content-Type': 'application/json' },
204 | }).then(resp => resp.data)
205 | }
206 |
207 | export function getStores() {
208 | // return Promise.resolve(STORES)
209 | return axios({
210 | method: 'get',
211 | url: '/streaming/storez',
212 | headers: { 'Content-Type': 'application/json' },
213 | }).then(resp => resp.data)
214 | }
215 |
216 | export function getClients() {
217 | // return Promise.resolve(CLIENTS)
218 | return axios({
219 | method: 'get',
220 | url: '/streaming/clientsz',
221 | params: {
222 | subs: 1,
223 | },
224 | headers: { 'Content-Type': 'application/json' },
225 | }).then(resp => resp.data)
226 | }
227 |
228 | export function getChannels() {
229 | // return Promise.resolve(CHANNELS)
230 | return axios({
231 | method: 'get',
232 | url: '/streaming/channelsz',
233 | params: {
234 | subs: 1,
235 | },
236 | headers: { 'Content-Type': 'application/json' },
237 | }).then(resp => resp.data)
238 | }
239 |
240 | export function getMessages(channel) {
241 | return axios({
242 | method: 'get',
243 | url: `/api/channel/${channel}/message`,
244 | headers: { 'Content-Type': 'application/json' },
245 | }).then(resp => resp.data)
246 | }
247 |
--------------------------------------------------------------------------------
/src/components/channel-view.js:
--------------------------------------------------------------------------------
1 | import { find, keyBy, map, reduce, size } from 'lodash'
2 | import React, { Component } from 'react'
3 | import PropTypes from 'prop-types'
4 | import classnames from 'classnames'
5 | import { getChannels, getClients, getMessages } from '../api/nats-streaming'
6 | import { subscribeToChannel } from '../api/web-socket'
7 | import JSONTree from 'react-json-tree'
8 | import {
9 | Avatar,
10 | Button,
11 | DataTable,
12 | DialogContainer,
13 | Divider,
14 | FontIcon,
15 | List,
16 | ListItem,
17 | TableBody,
18 | TableColumn,
19 | TableHeader,
20 | TableRow,
21 | TextField,
22 | Toolbar,
23 | Subheader,
24 | } from 'react-md'
25 |
26 | import './style.css'
27 |
28 | export default class ChannelView extends Component {
29 | // static propTypes = {}
30 | // static defaultProps = {}
31 | // state = {}
32 |
33 | constructor(props) {
34 | super(props)
35 |
36 | this.state = {
37 | channelSummary: undefined,
38 | channelMap: undefined,
39 | channelFilter: undefined,
40 | clientSummary: undefined,
41 | clientMap: undefined,
42 | clientFilter: undefined,
43 | detail: undefined,
44 | loading: true,
45 | focusedChannel: undefined,
46 | width: '0',
47 | height: '0',
48 | }
49 | }
50 |
51 | componentDidMount() {
52 | this.getMonitorData()
53 | this.updateWindowDimensions()
54 | window.addEventListener('resize', this.updateWindowDimensions)
55 | }
56 |
57 | componentWillUnmount() {
58 | window.removeEventListener('resize', this.updateWindowDimensions)
59 | }
60 |
61 | updateWindowDimensions = () => {
62 | this.setState({ width: window.innerWidth, height: window.innerHeight })
63 | }
64 |
65 | getMonitorData = async () => {
66 | this.setState({ loading: true })
67 | const [channelSummary, clientSummary] = await Promise.all([getChannels(), getClients()])
68 | const channelMap = keyBy(channelSummary.channels, 'name')
69 | const clientMap = keyBy(clientSummary.clients, 'id')
70 | this.setState({
71 | channelSummary,
72 | channelMap,
73 | clientSummary,
74 | clientMap,
75 | loading: false,
76 | focusedChannel: undefined,
77 | focusedChannelMessages: undefined,
78 | })
79 | }
80 |
81 | focusChannel = async channel => {
82 | console.log({ channel })
83 | const { name, last_seq } = channel
84 | this.setState({
85 | focusedChannel: channel,
86 | focusedChannelMessages: undefined,
87 | })
88 | const opts = last_seq
89 | ? { startAtSequence: Math.max(last_seq - 30, 0) }
90 | : { startAtTimeDelta: 300000 }
91 | const messages = []
92 | console.log({ name, opts, messages })
93 | subscribeToChannel(name, opts, msg => {
94 | console.log({ msg })
95 | if (messages.length === 30) messages.pop()
96 | messages.unshift(msg)
97 | this.setState({ focusedChannelMessages: messages })
98 | })
99 | }
100 |
101 | render() {
102 | const {
103 | channelSummary,
104 | channelMap,
105 | channelFilter,
106 | clientSummary,
107 | clientMap,
108 | clientFilter,
109 | detail,
110 | detailFormat,
111 | focusedChannel,
112 | loading,
113 | height,
114 | width,
115 | } = this.state
116 |
117 | console.log('render Home.js')
118 |
119 | if (loading) {
120 | return (
121 |
122 |
Loading...
123 |
124 | )
125 | }
126 | return (
127 |
128 |
129 |
130 |
131 | search}
136 | onChange={value => this.setState({ channelFilter: value })}
137 | />
138 |
139 |
{this.renderChannels()}
140 |
141 | {focusedChannel && (
142 |
143 |
144 | {this.renderMessages()}
145 |
146 | )}
147 | {detail && detailFormat === 'json' ? this.renderJsonDialog() : this.renderDatatableDialog()}
148 |
149 | )
150 | }
151 |
152 | renderChannels = () => {
153 | const { channelMap, channelFilter, focusedChannel } = this.state
154 | let filtered = channelMap
155 | if (channelFilter) {
156 | filtered = reduce(
157 | channelMap,
158 | (map, channel, name) => {
159 | if (name.includes(channelFilter)) {
160 | map[name] = channel
161 | }
162 | return map
163 | },
164 | {},
165 | )
166 | }
167 | return map(filtered, (channel, name) => {
168 | const expander = channel.msgs ? chevron_right : undefined
169 |
170 | return (
171 |
176 | Messages: {channel.msgs}
177 |
178 | Bytes: {channel.bytes}
179 |
180 | }
181 | secondaryTextStyle={{ fontSize: '.8em', color: '#bbb' }}
182 | threeLines
183 | leftIcon={
184 | {
186 | e.preventDefault()
187 | e.stopPropagation()
188 | this.setState({ detail: channel, detailFormat: 'datatable' })
189 | }}
190 | >
191 | info
192 |
193 | }
194 | rightIcon={expander}
195 | active={focusedChannel ? name === focusedChannel.name : false}
196 | onClick={() => this.focusChannel(channel)}
197 | />
198 | )
199 | })
200 | }
201 |
202 | renderMessages = () => {
203 | const { focusedChannelMessages } = this.state
204 | if (!focusedChannelMessages) {
205 | return
206 | }
207 | return map(focusedChannelMessages, msg => {
208 | const { sequence, timestamp, subject, data } = msg
209 | return (
210 |
214 |
222 | {sequence}
223 |
224 |
231 | {new Date(timestamp).toLocaleString()}
232 |
233 |
234 | }
235 | onClick={() => {
236 | this.setState({
237 | detail: JSON.parse(data),
238 | detailFormat: 'json',
239 | })
240 | }}
241 | />
242 | )
243 | })
244 | }
245 |
246 | renderJsonDialog = () => {
247 | const { detail } = this.state
248 | const actions = [
249 | {
250 | children: 'ok',
251 | onClick: () => this.setState({ detail: null, detailFormat: null }),
252 | primary: true,
253 | },
254 | ]
255 | return (
256 | this.setState({ detail: null })}
262 | actions={actions}
263 | dialogClassName="detail-dialog"
264 | >
265 | level <= 1}
270 | />
271 |
272 | )
273 | }
274 |
275 | renderDatatableDialog = () => {
276 | const { detail } = this.state
277 | const details = detail ? Object.entries(detail) : []
278 | const actions = [
279 | {
280 | children: 'ok',
281 | onClick: () => this.setState({ detail: null }),
282 | primary: true,
283 | },
284 | ]
285 | return (
286 | this.setState({ detail: null })}
292 | actions={actions}
293 | dialogClassName="detail-dialog"
294 | >
295 |
296 |
297 |
298 | Key
299 | Value
300 |
301 |
302 |
303 | {details.map(tuple => (
304 |
305 | {tuple[0]}
306 | {JSON.stringify(tuple[1])}
307 |
308 | ))}
309 |
310 |
311 |
312 | )
313 | }
314 | }
315 |
--------------------------------------------------------------------------------
/src/components/client-view.js:
--------------------------------------------------------------------------------
1 | import { find, keyBy, map, reduce, size } from 'lodash'
2 | import React, { Component } from 'react'
3 | import PropTypes from 'prop-types'
4 | import classnames from 'classnames'
5 | import { getChannels, getClients, getMessages } from '../api/nats-streaming'
6 | import { subscribeToChannel } from '../api/web-socket'
7 | import JSONTree from 'react-json-tree'
8 | import {
9 | Avatar,
10 | Button,
11 | DataTable,
12 | DialogContainer,
13 | Divider,
14 | FontIcon,
15 | List,
16 | ListItem,
17 | TableBody,
18 | TableColumn,
19 | TableHeader,
20 | TableRow,
21 | TextField,
22 | Toolbar,
23 | Subheader,
24 | } from 'react-md'
25 |
26 | import './style.css'
27 |
28 | export default class ClientView extends Component {
29 | // static propTypes = {}
30 | // static defaultProps = {}
31 | // state = {}
32 |
33 | constructor(props) {
34 | super(props)
35 |
36 | this.state = {
37 | channelSummary: undefined,
38 | channelMap: undefined,
39 | channelFilter: undefined,
40 | clientSummary: undefined,
41 | clientMap: undefined,
42 | clientFilter: undefined,
43 | detail: undefined,
44 | loading: true,
45 | focusedClient: undefined,
46 | focusedSubscription: undefined,
47 | width: '0',
48 | height: '0',
49 | }
50 | }
51 |
52 | componentDidMount() {
53 | this.getMonitorData()
54 | this.updateWindowDimensions()
55 | window.addEventListener('resize', this.updateWindowDimensions)
56 | }
57 |
58 | componentWillUnmount() {
59 | window.removeEventListener('resize', this.updateWindowDimensions)
60 | }
61 |
62 | updateWindowDimensions = () => {
63 | this.setState({ width: window.innerWidth, height: window.innerHeight })
64 | }
65 |
66 | getMonitorData = async () => {
67 | this.setState({ loading: true })
68 | const [channelSummary, clientSummary] = await Promise.all([getChannels(), getClients()])
69 | const channelMap = keyBy(channelSummary.channels, 'name')
70 | const clientMap = keyBy(clientSummary.clients, 'id')
71 | this.setState({
72 | channelSummary,
73 | channelMap,
74 | clientSummary,
75 | clientMap,
76 | loading: false,
77 | focusedClient: this.state.focusedClient
78 | ? this.state.focusedClient
79 | : find(clientMap, () => true),
80 | focusedSubscription: undefined,
81 | })
82 | }
83 |
84 | focusClient = client => {
85 | this.setState({ focusedClient: client, focusedSubscription: undefined })
86 | }
87 |
88 | focusSubscription = async subscription => {
89 | const { _channel, last_sent } = subscription
90 | this.setState({
91 | focusedSubscription: subscription,
92 | focusedSubscriptionMessages: undefined,
93 | })
94 | const opts = last_sent
95 | ? { startAtSequence: Math.max(last_sent - 30, 0) }
96 | : { startAtTimeDelta: 300000 }
97 | const messages = []
98 | subscribeToChannel(subscription._channel, opts, msg => {
99 | if (messages.length === 30) messages.pop()
100 | messages.unshift(msg)
101 | this.setState({ focusedSubscriptionMessages: messages })
102 | })
103 | }
104 |
105 | render() {
106 | const {
107 | channelSummary,
108 | channelMap,
109 | channelFilter,
110 | clientSummary,
111 | clientMap,
112 | clientFilter,
113 | detail,
114 | detailFormat,
115 | focusedClient,
116 | focusedSubscription,
117 | focusedSubscriptionMessages,
118 | loading,
119 | height,
120 | width,
121 | } = this.state
122 |
123 | console.log('render Home.js')
124 |
125 | if (loading) {
126 | return (
127 |
128 |
Loading...
129 |
130 | )
131 | }
132 | return (
133 |
134 |
135 |
136 |
137 | search}
142 | onChange={value => this.setState({ clientFilter: value })}
143 | />
144 |
145 |
{this.renderClients()}
146 |
147 | {focusedClient && focusedClient.subscriptions && (
148 |
149 |
150 |
151 | search}
156 | onChange={value => this.setState({ channelFilter: value })}
157 | />
158 |
159 |
{this.renderSubscriptions()}
160 |
161 | )}
162 | {focusedSubscription && (
163 |
164 |
165 | {this.renderMessages()}
166 |
167 | )}
168 | {detail && detailFormat === 'json' ? this.renderJsonDialog() : this.renderDatatableDialog()}
169 |
170 | )
171 | }
172 |
173 | renderClients = () => {
174 | const { clientMap, clientFilter, focusedClient } = this.state
175 | let filtered = clientMap
176 | if (clientFilter) {
177 | filtered = reduce(
178 | clientMap,
179 | (map, client, name) => {
180 | if (name.includes(clientFilter)) {
181 | map[name] = client
182 | }
183 | return map
184 | },
185 | {},
186 | )
187 | }
188 | return map(filtered, (client, name) => {
189 | const expander = client.subscriptions ? chevron_right : undefined
190 |
191 | return (
192 | Subscriptions: {size(client.subscriptions)}}
196 | secondaryTextStyle={{ fontSize: '.8em', color: '#bbb' }}
197 | leftIcon={
198 | {
200 | e.preventDefault()
201 | e.stopPropagation()
202 | this.setState({ detail: client, detailFormat: 'datatable' })
203 | }}
204 | >
205 | info
206 |
207 | }
208 | rightIcon={expander}
209 | active={name === focusedClient.id}
210 | onClick={() => this.focusClient(client)}
211 | />
212 | )
213 | })
214 | }
215 |
216 | renderSubscriptions = () => {
217 | const { channelFilter, focusedClient, focusedSubscription } = this.state
218 | let filtered = focusedClient.subscriptions
219 | if (channelFilter) {
220 | filtered = reduce(
221 | focusedClient.subscriptions,
222 | (map, client, name) => {
223 | if (name.includes(channelFilter)) {
224 | map[name] = client
225 | }
226 | return map
227 | },
228 | {},
229 | )
230 | }
231 | return map(filtered, (sub, name) => {
232 | const subscription = sub[0]
233 | const { queue_name, last_sent } = subscription
234 | const [_client, _durableName] = subscription.queue_name
235 | ? subscription.queue_name.split(':')
236 | : [subscription.client_id, '-not durable-']
237 | console.log({ subscription, x: subscription.queue_name, _client, _durableName })
238 | Object.assign(subscription, { _channel: name, _client, _durableName })
239 |
240 | const expander = last_sent ? chevron_right : undefined
241 |
242 | return (
243 |
248 | Durable Name: {_durableName}
249 |
250 | Message Count: {last_sent}
251 |
252 | }
253 | secondaryTextStyle={{ fontSize: '.8em', color: '#bbb' }}
254 | threeLines
255 | leftIcon={
256 | {
258 | e.preventDefault()
259 | e.stopPropagation()
260 | this.setState({
261 | detail: subscription,
262 | detailFormat: 'datatable',
263 | })
264 | }}
265 | >
266 | info
267 |
268 | }
269 | rightIcon={expander}
270 | active={focusedSubscription && name === focusedSubscription._channel}
271 | onClick={last_sent ? () => this.focusSubscription(subscription) : () => {}}
272 | />
273 | )
274 | })
275 | }
276 |
277 | renderMessages = () => {
278 | const { focusedSubscription, focusedSubscriptionMessages } = this.state
279 | if (!focusedSubscriptionMessages) {
280 | return
281 | }
282 | return map(focusedSubscriptionMessages, msg => {
283 | const { sequence, timestamp, subject, data } = msg
284 | return (
285 |
289 |
297 | {sequence}
298 |
299 |
306 | {new Date(timestamp).toLocaleString()}
307 |
308 |
309 | }
310 | onClick={() => {
311 | this.setState({
312 | detail: JSON.parse(data),
313 | detailFormat: 'json',
314 | })
315 | }}
316 | />
317 | )
318 | })
319 | }
320 |
321 | renderJsonDialog = () => {
322 | const { detail } = this.state
323 | const actions = [
324 | {
325 | children: 'ok',
326 | onClick: () => this.setState({ detail: null, detailFormat: null }),
327 | primary: true,
328 | },
329 | ]
330 | return (
331 | this.setState({ detail: null })}
337 | actions={actions}
338 | dialogClassName="detail-dialog"
339 | >
340 | level <= 1}
345 | />
346 |
347 | )
348 | }
349 |
350 | renderDatatableDialog = () => {
351 | const { detail } = this.state
352 | const details = detail ? Object.entries(detail) : []
353 | const actions = [
354 | {
355 | children: 'ok',
356 | onClick: () => this.setState({ detail: null }),
357 | primary: true,
358 | },
359 | ]
360 | return (
361 | this.setState({ detail: null })}
367 | actions={actions}
368 | dialogClassName="detail-dialog"
369 | >
370 |
371 |
372 |
373 | Key
374 | Value
375 |
376 |
377 |
378 | {details.map(tuple => (
379 |
380 | {tuple[0]}
381 | {JSON.stringify(tuple[1])}
382 |
383 | ))}
384 |
385 |
386 |
387 | )
388 | }
389 | }
390 |
--------------------------------------------------------------------------------