Move to nivo from nx and add WS support
This commit is contained in:
parent
60208b29db
commit
f3f500b344
8 changed files with 11695 additions and 109 deletions
11501
priv/frontend/package-lock.json
generated
Normal file
11501
priv/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -3,13 +3,8 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vx/axis": "^0.0.165",
|
"@nivo/line": "^0.42.1",
|
||||||
"@vx/curve": "^0.0.165",
|
"ms": "^2.1.1",
|
||||||
"@vx/gradient": "^0.0.165",
|
|
||||||
"@vx/group": "^0.0.165",
|
|
||||||
"@vx/scale": "^0.0.165",
|
|
||||||
"@vx/shape": "^0.0.165",
|
|
||||||
"d3-array": "^1.2.1",
|
|
||||||
"react": "^16.4.0",
|
"react": "^16.4.0",
|
||||||
"react-dom": "^16.4.0",
|
"react-dom": "^16.4.0",
|
||||||
"react-scripts": "1.1.4"
|
"react-scripts": "1.1.4"
|
||||||
|
|
|
@ -3,45 +3,145 @@ import React, { Component } from 'react';
|
||||||
import Service from './Service.js';
|
import Service from './Service.js';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const ENDPOINT = 'https://elstatus.stayathomeserver.club/api/status'
|
import OP from './ws/op.js';
|
||||||
|
|
||||||
|
const log = (...args) => {
|
||||||
|
console.log(
|
||||||
|
'%c[elstat]%c',
|
||||||
|
'color: purple; font-weight: bold',
|
||||||
|
'color: inherit; font-weight: inherit',
|
||||||
|
...args,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOMAIN = 'https://elstatus.stayathomeserver.club';
|
||||||
// const ENDPOINT = 'http://localhost:8069/api/status'
|
// const ENDPOINT = 'http://localhost:8069/api/status'
|
||||||
|
|
||||||
export default class App extends Component {
|
export default class App extends Component {
|
||||||
|
websocket = null;
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
metrics: null,
|
metrics: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
_send(payload) {
|
||||||
fetch(ENDPOINT)
|
this.websocket.send(JSON.stringify(payload));
|
||||||
.then((resp) => resp.json())
|
|
||||||
.then((data) => {
|
|
||||||
this.setState({ metrics: data, loading: false });
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.setState({ error: error.toString() });
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_subscribe() {
|
||||||
|
const channels = Object.keys(this.state.metrics.graph).map((channel) => `latency:${channel}`);
|
||||||
|
log('subscribing to channels:', channels);
|
||||||
|
|
||||||
|
this._send({
|
||||||
|
op: OP.SUBSCRIBE,
|
||||||
|
channels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleMessage(packet) {
|
||||||
|
const { op, c: channel, d: data } = packet;
|
||||||
|
|
||||||
|
if (op !== OP.DATA) {
|
||||||
|
log('ignoring boring packet:', packet);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, name] = channel.split(':');
|
||||||
|
|
||||||
|
const { metrics } = this.state;
|
||||||
|
const graph = metrics.graph[name].slice(1);
|
||||||
|
const newGraph = [...graph, data];
|
||||||
|
|
||||||
|
log('adding data:', data);
|
||||||
|
|
||||||
|
const clone = Object.assign({}, this.state.metrics);
|
||||||
|
clone.graph[name] = newGraph;
|
||||||
|
|
||||||
|
log('current graph:', graph, 'new graph:', newGraph);
|
||||||
|
log('current state', this.state, 'new state:', { metrics: clone });
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
metrics: clone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectWs() {
|
||||||
|
log('connecting to ws');
|
||||||
|
|
||||||
|
const endpoint = (`${DOMAIN}/api/streaming`).replace('https', 'wss');
|
||||||
|
this.websocket = new WebSocket(endpoint);
|
||||||
|
|
||||||
|
this.websocket.onopen = () => {
|
||||||
|
log('ws opened');
|
||||||
|
this._subscribe();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onmessage = (message) => {
|
||||||
|
const { data } = message;
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
log('ws recv:', parsed);
|
||||||
|
|
||||||
|
this._handleMessage(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onerror = (error) => {
|
||||||
|
log('ws error:', error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async componentDidMount() {
|
||||||
|
await this._loadMetrics();
|
||||||
|
this._connectWs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadMetrics() {
|
||||||
|
log('loading metrics');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${DOMAIN}/api/status`);
|
||||||
|
const json = await resp.json();
|
||||||
|
|
||||||
|
this.setState({ metrics: json, loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: err.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const metrics = !this.state.metrics ? null : (
|
const metrics = !this.state.metrics ? null : (
|
||||||
<div className="services">
|
<div className="services">
|
||||||
{Object.entries(this.state.metrics.status)
|
{Object.entries(this.state.metrics.status)
|
||||||
.map(([name, info]) =>
|
.map(([name, info]) => (
|
||||||
<Service name={name} key={name}
|
<Service
|
||||||
|
name={name}
|
||||||
|
key={name}
|
||||||
graph={this.state.metrics.graph[name]}
|
graph={this.state.metrics.graph[name]}
|
||||||
{...info}
|
{...info}
|
||||||
/>)
|
/>
|
||||||
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
<h1>elstatus</h1>
|
<h1>
|
||||||
{this.state.loading ? <div>Loading metrics...</div> : null}
|
elstatus
|
||||||
{this.state.error ? <div>Error: {this.state.error}</div> : null}
|
</h1>
|
||||||
|
{this.state.loading ? (
|
||||||
|
<div>
|
||||||
|
Loading metrics...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{this.state.error ? (
|
||||||
|
<div>
|
||||||
|
Error:
|
||||||
|
{this.state.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{metrics}
|
{metrics}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
.graph {
|
.graph-container {
|
||||||
margin-top: 1em;
|
width: 100%;
|
||||||
|
height: 10rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,94 +1,68 @@
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
import ms from 'ms';
|
||||||
|
import { ResponsiveLine } from '@nivo/line';
|
||||||
|
|
||||||
import './Graph.css';
|
import './Graph.css';
|
||||||
|
|
||||||
import { curveNatural } from '@vx/curve';
|
|
||||||
import { GradientOrangeRed } from '@vx/gradient';
|
|
||||||
import { AxisLeft, AxisBottom } from '@vx/axis';
|
|
||||||
import { Group } from '@vx/group';
|
|
||||||
import { AreaClosed } from '@vx/shape';
|
|
||||||
import { scaleTime, scaleLinear } from '@vx/scale';
|
|
||||||
import { extent, max } from 'd3-array';
|
|
||||||
|
|
||||||
const x = ([timestamp,]) => new Date(timestamp);
|
|
||||||
const y = ([, latency]) => latency;
|
|
||||||
|
|
||||||
const margin = {
|
|
||||||
top: 0,
|
|
||||||
bottom: 50,
|
|
||||||
left: 50,
|
|
||||||
right: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tick = {
|
|
||||||
textAnchor: 'middle',
|
|
||||||
fontSize: 8,
|
|
||||||
fontFamily: 'system-ui, sans-serif',
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftTick = {
|
|
||||||
...tick,
|
|
||||||
dx: '-0.25em',
|
|
||||||
dy: '0.25em',
|
|
||||||
textAnchor: 'end',
|
|
||||||
};
|
|
||||||
|
|
||||||
const label = {
|
|
||||||
...tick,
|
|
||||||
fontSize: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class Graph extends Component {
|
export default class Graph extends Component {
|
||||||
constructor(props) {
|
processData() {
|
||||||
super(props);
|
const { data: unprocessedData } = this.props;
|
||||||
|
|
||||||
const { width, height, data } = props;
|
const data = unprocessedData.map(([timestamp, latency]) => ({
|
||||||
this.xMax = width - margin.left - margin.right;
|
x: timestamp,
|
||||||
this.yMax = height - margin.top - margin.bottom;
|
y: latency,
|
||||||
|
})).reverse();
|
||||||
|
|
||||||
this.xScale = scaleTime({
|
return [
|
||||||
range: [0, this.xMax],
|
{
|
||||||
domain: extent(data, x),
|
id: 'latency',
|
||||||
});
|
color: 'hsl(220, 100%, 75%)',
|
||||||
this.yScale = scaleLinear({
|
data,
|
||||||
range: [this.yMax, 0],
|
},
|
||||||
domain: [0, max(data, y)],
|
];
|
||||||
nice: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<svg className="graph" width={this.props.width} height={this.props.height}>
|
<div className="graph-container">
|
||||||
<GradientOrangeRed id="gradient"/>
|
<ResponsiveLine
|
||||||
|
data={this.processData()}
|
||||||
|
margin={{ top: 30, left: 70, bottom: 50 }}
|
||||||
|
minY="auto"
|
||||||
|
maxY={900}
|
||||||
|
curve="monotoneX"
|
||||||
|
|
||||||
<Group top={margin.top} left={margin.left}>
|
tooltipFormat={(d) => `${d}ms`}
|
||||||
<AreaClosed
|
|
||||||
data={this.props.data}
|
axisLeft={{
|
||||||
xScale={this.xScale} yScale={this.yScale}
|
format: (d) => `${d}ms`,
|
||||||
x={x} y={y}
|
tickCount: 3,
|
||||||
stroke=""
|
legend: 'latency',
|
||||||
fill="url(#gradient)"
|
legendPosition: 'center',
|
||||||
curve={curveNatural}
|
legendOffset: -55,
|
||||||
/>
|
tickSize: 0,
|
||||||
<AxisLeft
|
}}
|
||||||
scale={this.yScale}
|
|
||||||
top={0} left={0}
|
axisBottom={{
|
||||||
labelProps={label}
|
format: (epoch) => {
|
||||||
tickFormat={(value, index) => `${value}ms`}
|
const minutesAgo = Math.floor((Date.now() - epoch) / (1000 * 60));
|
||||||
tickLabelProps={(value, index) => leftTick}
|
if (minutesAgo % 5 !== 0 || minutesAgo === 0) {
|
||||||
label=''
|
return undefined;
|
||||||
/>
|
}
|
||||||
<AxisBottom
|
|
||||||
scale={this.xScale}
|
return ms(Date.now() - epoch);
|
||||||
top={this.yMax}
|
},
|
||||||
tickFormat={(value, index) => `${value.toLocaleTimeString()}`}
|
tickSize: 0,
|
||||||
labelProps={label}
|
legend: 'time ago',
|
||||||
tickLabelProps={(value, index) => tick}
|
legendPosition: 'center',
|
||||||
label=''
|
legendOffset: 40,
|
||||||
/>
|
}}
|
||||||
</Group>
|
|
||||||
</svg>
|
enableDots={false}
|
||||||
|
enableArea
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,19 +3,27 @@ import React from 'react';
|
||||||
import Graph from './Graph.js';
|
import Graph from './Graph.js';
|
||||||
import './Service.css';
|
import './Service.css';
|
||||||
|
|
||||||
const Service = ({ graph, name, status, latency, description }) =>
|
const Service = ({ graph, name, status, latency, description }) => (
|
||||||
<div className="service">
|
<div className="service">
|
||||||
<header className="service__header">
|
<header className="service__header">
|
||||||
<div className="service__header__emoji">{status ? '✅' : '🚫'}</div>
|
<div className="service__header__emoji">
|
||||||
|
{status ? '✅' : '🚫'}
|
||||||
|
</div>
|
||||||
<h2 className="service__title">
|
<h2 className="service__title">
|
||||||
{name}
|
{name}
|
||||||
{latency ? <span className="service__title__latency">{latency}ms</span> : null}
|
{latency ? (
|
||||||
|
<span className="service__title__latency">
|
||||||
|
{latency}
|
||||||
|
ms
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
<p className="service__description">
|
<p className="service__description">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
{graph ? <Graph width={500} height={175} data={graph}/> : null}
|
{graph ? <Graph width={500} height={175} data={graph} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default Service;
|
export default Service;
|
||||||
|
|
|
@ -6,5 +6,5 @@ import App from './App';
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<App />,
|
<App />,
|
||||||
document.getElementById('root')
|
document.getElementById('root'),
|
||||||
);
|
);
|
||||||
|
|
7
priv/frontend/src/ws/op.js
Normal file
7
priv/frontend/src/ws/op.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports = {
|
||||||
|
UNSUBSCRIBE: -1,
|
||||||
|
SUBSCRIBE: 0,
|
||||||
|
SUBSCRIBED: 1,
|
||||||
|
UNSUBSCRIBED: 2,
|
||||||
|
DATA: 3,
|
||||||
|
};
|
Loading…
Reference in a new issue