mirror of
https://github.com/pezkuwichain/pezkuwi-telemetry.git
synced 2026-06-12 04:11:09 +00:00
Sparklines (#65)
This commit is contained in:
@@ -7,6 +7,8 @@ import { locate, Location } from './location';
|
||||
import { getId, refreshId } from './nodeId';
|
||||
|
||||
const BLOCK_TIME_HISTORY = 10;
|
||||
const MEMORY_RECORDS = 20;
|
||||
const CPU_RECORDS = 20;
|
||||
const TIMEOUT = (1000 * 60 * 1) as Types.Milliseconds; // 1 minute
|
||||
|
||||
export interface NodeEvents {
|
||||
@@ -37,8 +39,8 @@ export default class Node {
|
||||
|
||||
private peers = 0 as Types.PeerCount;
|
||||
private txcount = 0 as Types.TransactionCount;
|
||||
private memory = null as Maybe<Types.MemoryUse>;
|
||||
private cpu = null as Maybe<Types.CPUUse>;
|
||||
private memory = Array<Types.MemoryUse>();
|
||||
private cpu = Array<Types.CPUUse>();
|
||||
|
||||
private readonly ip: string;
|
||||
private readonly socket: WebSocket;
|
||||
@@ -227,14 +229,28 @@ export default class Node {
|
||||
private onSystemInterval(message: SystemInterval) {
|
||||
const { peers, txcount, cpu, memory } = message;
|
||||
|
||||
if (this.peers !== peers || this.txcount !== txcount || this.cpu !== cpu || this.memory !== memory) {
|
||||
this.peers = peers;
|
||||
this.txcount = txcount;
|
||||
this.cpu = cpu;
|
||||
this.memory = memory;
|
||||
this.peers = peers;
|
||||
this.txcount = txcount;
|
||||
|
||||
this.events.emit('stats');
|
||||
if (cpu) {
|
||||
if (this.cpu.length === CPU_RECORDS) {
|
||||
this.cpu.copyWithin(0, 1);
|
||||
this.cpu[CPU_RECORDS-1] = cpu;
|
||||
} else {
|
||||
this.cpu.push(cpu);
|
||||
}
|
||||
}
|
||||
|
||||
if (memory) {
|
||||
if (this.memory.length === MEMORY_RECORDS) {
|
||||
this.memory.copyWithin(0, 1);
|
||||
this.memory[MEMORY_RECORDS-1] = memory;
|
||||
} else {
|
||||
this.memory.push(memory);
|
||||
}
|
||||
}
|
||||
|
||||
this.events.emit('stats');
|
||||
}
|
||||
|
||||
private updateLatency(now: Types.Timestamp) {
|
||||
|
||||
@@ -8,4 +8,4 @@ import * as FeedMessage from './feed';
|
||||
export { Types, FeedMessage };
|
||||
|
||||
// Increment this if breaking changes were made to types in `feed.ts`
|
||||
export const VERSION: Types.FeedVersion = 14 as Types.FeedVersion;
|
||||
export const VERSION: Types.FeedVersion = 15 as Types.FeedVersion;
|
||||
|
||||
@@ -25,5 +25,5 @@ export type CPUUse = Opaque<number, 'CPUUse'>;
|
||||
|
||||
export type BlockDetails = [BlockNumber, BlockHash, Milliseconds, Timestamp, Maybe<PropagationTime>];
|
||||
export type NodeDetails = [NodeName, NodeImplementation, NodeVersion, Maybe<Address>];
|
||||
export type NodeStats = [PeerCount, TransactionCount, Maybe<MemoryUse>, Maybe<CPUUse>];
|
||||
export type NodeStats = [PeerCount, TransactionCount, Array<MemoryUse>, Array<CPUUse>];
|
||||
export type NodeLocation = [Latitude, Longitude, City];
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
declare module '@fnando/sparkline' {
|
||||
namespace sparkline {
|
||||
export interface Options {
|
||||
spotRadius?: number;
|
||||
cursorWidth?: number;
|
||||
interactive?: boolean;
|
||||
onmousemove?: (event: MouseEvent, datapoint: { x: number, y: number, index: number, value: number });
|
||||
onmouseout?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
function sparkline(svg: SVGSVGElement, values: number[], options?: sparkline.Options): void;
|
||||
|
||||
export = sparkline;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"license": "GPL-3.0",
|
||||
"description": "Polkadot Telemetry frontend",
|
||||
"dependencies": {
|
||||
"@fnando/sparkline": "^0.3.10",
|
||||
"polkadot-identicon": "^1.1.0",
|
||||
"react": "16.4.0",
|
||||
"react-dom": "16.4.0",
|
||||
|
||||
@@ -24,8 +24,8 @@ export default class App extends React.Component<{}, State> {
|
||||
implementation: true,
|
||||
peers: true,
|
||||
txs: true,
|
||||
cpu: false,
|
||||
mem: false,
|
||||
cpu: true,
|
||||
mem: true,
|
||||
blocknumber: true,
|
||||
blockhash: true,
|
||||
blocktime: true,
|
||||
|
||||
@@ -182,6 +182,7 @@ export class Connection {
|
||||
|
||||
if (nodes.size !== sortedNodes.length) {
|
||||
console.error('Node count in sorted array is wrong!');
|
||||
sortedNodes = Array.from(nodes.values()).sort(Node.compare);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -198,6 +199,7 @@ export class Connection {
|
||||
|
||||
if (nodes.size !== sortedNodes.length) {
|
||||
console.error('Node count in sorted array is wrong!');
|
||||
sortedNodes = Array.from(nodes.values()).sort(Node.compare);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -252,6 +252,10 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
|
||||
}
|
||||
|
||||
private onKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { filter } = this.state;
|
||||
const key = event.key;
|
||||
const code = event.keyCode;
|
||||
|
||||
@@ -12,7 +12,7 @@ export namespace Filter {
|
||||
export class Filter extends React.Component<Filter.Props, {}> {
|
||||
private filterInput: HTMLInputElement;
|
||||
|
||||
public componentDidMount(){
|
||||
public componentDidMount() {
|
||||
this.filterInput.focus();
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.Node-Row .Node-Row-Tooltip {
|
||||
position: initial;
|
||||
padding: inherit;
|
||||
}
|
||||
|
||||
.Node-Row-synced {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatNumber, milliOrSecond, secondsWithPrecision } from '../../utils';
|
||||
import { State as AppState, Node } from '../../state';
|
||||
import { PersistentSet } from '../../persist';
|
||||
import { SEMVER_PATTERN } from './';
|
||||
import { Ago, Icon } from '../';
|
||||
import { Ago, Icon, Tooltip, Sparkline } from '../';
|
||||
|
||||
import nodeIcon from '../../icons/server.svg';
|
||||
import nodeLocationIcon from '../../icons/location.svg';
|
||||
@@ -45,10 +45,33 @@ interface Column {
|
||||
render: (node: Node) => React.ReactElement<any> | string;
|
||||
}
|
||||
|
||||
function Truncate(props: { text: string }): React.ReactElement<any> {
|
||||
const { text } = props;
|
||||
function Truncate(props: { text: string, position?: 'left' | 'right' | 'center' }): React.ReactElement<any> {
|
||||
const { text, position } = props;
|
||||
|
||||
return <div className="Node-Row-truncate" title={text}>{text}</div>;
|
||||
return (
|
||||
<Tooltip text={text} position={position} className="Node-Row-Tooltip">
|
||||
<div className="Node-Row-truncate">{text}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMemory(kbs: number): string {
|
||||
const mbs = kbs / 1024 | 0;
|
||||
|
||||
if (mbs >= 1000) {
|
||||
return `${(mbs / 1024).toFixed(1)} GB`;
|
||||
} else {
|
||||
return `${mbs} MB`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCPU(cpu: number): string {
|
||||
const fractionDigits = cpu > 100 ? 0
|
||||
: cpu > 10 ? 1
|
||||
: cpu > 1 ? 2
|
||||
: 3;
|
||||
|
||||
return `${cpu.toFixed(fractionDigits)}%`;
|
||||
}
|
||||
|
||||
export default class Row extends React.Component<RowProps, {}> {
|
||||
@@ -56,7 +79,7 @@ export default class Row extends React.Component<RowProps, {}> {
|
||||
{
|
||||
label: 'Node',
|
||||
icon: nodeIcon,
|
||||
render: ({ name }) => <Truncate text={name} />
|
||||
render: ({ name }) => <Truncate text={name} position="left" />
|
||||
},
|
||||
{
|
||||
label: 'Validator',
|
||||
@@ -64,7 +87,7 @@ export default class Row extends React.Component<RowProps, {}> {
|
||||
width: 26,
|
||||
setting: 'validator',
|
||||
render: ({ validator }) => {
|
||||
return validator ? <span className="Node-Row-validator" title={validator}><Identicon id={validator} size={16} /></span> : '-';
|
||||
return validator ? <Tooltip text={validator}><span className="Node-Row-validator"><Identicon id={validator} size={16} /></span></Tooltip> : '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -72,7 +95,7 @@ export default class Row extends React.Component<RowProps, {}> {
|
||||
icon: nodeLocationIcon,
|
||||
width: 140,
|
||||
setting: 'location',
|
||||
render: ({ city }) => city ? <Truncate text={city} /> : '-'
|
||||
render: ({ city }) => city ? <Truncate position="left" text={city} /> : '-'
|
||||
},
|
||||
{
|
||||
label: 'Implementation',
|
||||
@@ -85,7 +108,11 @@ export default class Row extends React.Component<RowProps, {}> {
|
||||
: implementation === 'substrate-node' ? paritySubstrateIcon
|
||||
: unknownImplementationIcon;
|
||||
|
||||
return <span title={`${implementation} v${version}`}><Icon src={implIcon} /> {semver}</span>;
|
||||
return (
|
||||
<Tooltip text={`${implementation} v${version}`}>
|
||||
<Icon src={implIcon} /> {semver}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -105,16 +132,32 @@ export default class Row extends React.Component<RowProps, {}> {
|
||||
{
|
||||
label: '% CPU Use',
|
||||
icon: cpuIcon,
|
||||
width: 26,
|
||||
width: 40,
|
||||
setting: 'cpu',
|
||||
render: ({ cpu }) => cpu ? `${cpu.toFixed(1)}%` : '-'
|
||||
render: ({ cpu }) => {
|
||||
if (cpu.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline width={48} height={16} stroke={1} format={formatCPU} values={cpu} />
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Memory Use',
|
||||
icon: memoryIcon,
|
||||
width: 26,
|
||||
width: 40,
|
||||
setting: 'mem',
|
||||
render: ({ mem }) => mem ? <span title={`${mem}kb`}>{mem / 1024 | 0}mb</span> : '-'
|
||||
render: ({ mem }) => {
|
||||
if (mem.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline width={48} height={16} stroke={1} format={formatMemory} values={mem} />
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Block',
|
||||
@@ -128,7 +171,7 @@ export default class Row extends React.Component<RowProps, {}> {
|
||||
icon: blockHashIcon,
|
||||
width: 154,
|
||||
setting: 'blockhash',
|
||||
render: ({ hash }) => <Truncate text={hash} />
|
||||
render: ({ hash }) => <Truncate position="right" text={hash} />
|
||||
},
|
||||
{
|
||||
label: 'Block Time',
|
||||
@@ -155,16 +198,24 @@ export default class Row extends React.Component<RowProps, {}> {
|
||||
|
||||
public static Header = (props: HeaderProps) => {
|
||||
const { settings } = props;
|
||||
const columns = Row.columns.filter(({ setting }) => setting == null || settings[setting]);
|
||||
const last = columns.length - 1;
|
||||
|
||||
return (
|
||||
<thead>
|
||||
<tr className="Node-Row-Header">
|
||||
{
|
||||
Row.columns
|
||||
.filter(({ setting }) => setting == null || settings[setting])
|
||||
.map(({ icon, width, label }, index) => (
|
||||
<th key={index} style={width ? { width } : undefined}><Icon src={icon} alt={label} /></th>
|
||||
))
|
||||
columns.map(({ icon, width, label }, index) => {
|
||||
const position = index === 0 ? 'left'
|
||||
: index === last ? 'right'
|
||||
: 'center';
|
||||
|
||||
return (
|
||||
<th key={index} style={width ? { width } : undefined}>
|
||||
<Tooltip text={label} inline={true} position={position}><Icon src={icon} /></Tooltip>
|
||||
</th>
|
||||
)
|
||||
})
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.Sparkline {
|
||||
fill: currentcolor; /* rgba(255,255,255,0.5); */
|
||||
fill-opacity: 0.5;
|
||||
stroke: currentcolor;
|
||||
margin: 0 -14px 0 -4px;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import sparkline from "@fnando/sparkline";
|
||||
import { Tooltip } from './';
|
||||
|
||||
import './Sparkline.css';
|
||||
|
||||
export namespace Sparkline {
|
||||
export interface Props {
|
||||
stroke: number;
|
||||
width: number;
|
||||
height: number;
|
||||
values: number[];
|
||||
format?: (value: number) => string;
|
||||
}
|
||||
}
|
||||
|
||||
export class Sparkline extends React.Component<Sparkline.Props, {}> {
|
||||
private el: SVGSVGElement;
|
||||
private update: Tooltip.UpdateCallback;
|
||||
|
||||
public componentDidMount() {
|
||||
sparkline(this.el, this.props.values, {
|
||||
interactive: true,
|
||||
onmousemove: this.onMouseMove,
|
||||
});
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Sparkline.Props): boolean {
|
||||
const { stroke, width, height, format } = this.props;
|
||||
|
||||
if (stroke !== nextProps.stroke || width !== nextProps.width || height !== nextProps.height || format !== nextProps.format) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.props.values !== nextProps.values) {
|
||||
sparkline(this.el, nextProps.values, {
|
||||
interactive: true,
|
||||
onmousemove: this.onMouseMove,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { stroke, width, height } = this.props;
|
||||
|
||||
return (
|
||||
<Tooltip text="-" onInit={this.onTooltipInit}>
|
||||
<svg className="Sparkline" ref={this.onRef} width={width} height={height} strokeWidth={stroke} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
private onRef = (el: SVGSVGElement) => {
|
||||
this.el = el;
|
||||
}
|
||||
|
||||
private onTooltipInit = (update: Tooltip.UpdateCallback) => {
|
||||
this.update = update;
|
||||
}
|
||||
|
||||
private onMouseMove = (event: MouseEvent, data: { value: number }) => {
|
||||
const { format } = this.props;
|
||||
const str = format ? format(data.value) : `${data.value}`;
|
||||
this.update(str);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.Tooltip {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
padding: 3px 5px;
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
top: -32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: none;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.Tooltip::after {
|
||||
content: ' ';
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -6px;
|
||||
margin-left: -6px;
|
||||
border-top: 6px #000 solid;
|
||||
border-left: 6px transparent solid;
|
||||
border-right: 6px transparent solid;
|
||||
}
|
||||
|
||||
.Tooltip-left {
|
||||
left: 10px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.Tooltip-left::after {
|
||||
left: 3px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Tooltip-right {
|
||||
left: initial;
|
||||
right: 10px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.Tooltip-right::after {
|
||||
left: initial;
|
||||
right: 3px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Tooltip-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.Tooltip-container-inline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Tooltip-container-inline .Tooltip-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.Tooltip-container-inline .Tooltip-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.Tooltip-container:hover .Tooltip {
|
||||
display: block;
|
||||
animation: show 0.15s forwards;
|
||||
}
|
||||
|
||||
@keyframes show {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import './Tooltip.css';
|
||||
|
||||
export namespace Tooltip {
|
||||
export interface Props {
|
||||
text: string;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
position?: 'left' | 'right' | 'center';
|
||||
onInit?: (update: UpdateCallback) => void;
|
||||
}
|
||||
|
||||
export type UpdateCallback = (text: string) => void;
|
||||
}
|
||||
|
||||
export class Tooltip extends React.Component<Tooltip.Props, {}> {
|
||||
private el: HTMLDivElement;
|
||||
|
||||
public componentDidMount() {
|
||||
if (this.props.onInit) {
|
||||
this.props.onInit(this.update);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { text, inline, className, position } = this.props;
|
||||
|
||||
let containerClass = 'Tooltip-container';
|
||||
let tooltipClass = 'Tooltip';
|
||||
|
||||
if (className) {
|
||||
containerClass += ' ' + className;
|
||||
}
|
||||
|
||||
if (inline) {
|
||||
containerClass += ' Tooltip-container-inline';
|
||||
}
|
||||
|
||||
if (position && position !== 'center') {
|
||||
tooltipClass += ` Tooltip-${position}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className={tooltipClass} ref={this.onRef}>{text}</div>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private onRef = (el: HTMLDivElement) => {
|
||||
this.el = el;
|
||||
}
|
||||
|
||||
private update = (text: string) => {
|
||||
this.el.textContent = text;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export * from './Tile';
|
||||
export * from './Ago';
|
||||
export * from './OfflineIndicator';
|
||||
export * from './Setting';
|
||||
export * from './Sparkline';
|
||||
export * from './Tooltip';
|
||||
|
||||
import * as Node from './Node';
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ export class Node {
|
||||
public pinned: boolean;
|
||||
public peers: Types.PeerCount;
|
||||
public txs: Types.TransactionCount;
|
||||
public mem: Maybe<Types.MemoryUse>;
|
||||
public cpu: Maybe<Types.CPUUse>;
|
||||
public mem: Types.MemoryUse[];
|
||||
public cpu: Types.CPUUse[];
|
||||
|
||||
public height: Types.BlockNumber;
|
||||
public hash: Types.BlockHash;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
"src/**/*.tsx",
|
||||
"./declarations/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
esutils "^2.0.2"
|
||||
js-tokens "^3.0.0"
|
||||
|
||||
"@fnando/sparkline@^0.3.10":
|
||||
version "0.3.10"
|
||||
resolved "https://registry.yarnpkg.com/@fnando/sparkline/-/sparkline-0.3.10.tgz#0cb6549a232af0f19f75b33d38fddd4f5ed9f086"
|
||||
|
||||
"@tanem/svg-injector@^1.2.0":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@tanem/svg-injector/-/svg-injector-1.2.1.tgz#3120e90246d0eb3c4fc6c61586a6f028a3c658ae"
|
||||
|
||||
Reference in New Issue
Block a user