diff --git a/packages/backend/src/Node.ts b/packages/backend/src/Node.ts index e725a2f..ab967c8 100644 --- a/packages/backend/src/Node.ts +++ b/packages/backend/src/Node.ts @@ -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; - private cpu = null as Maybe; + private memory = Array(); + private cpu = Array(); 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) { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index bcfe9ea..1c06a99 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index d237511..feda6c1 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -25,5 +25,5 @@ export type CPUUse = Opaque; export type BlockDetails = [BlockNumber, BlockHash, Milliseconds, Timestamp, Maybe]; export type NodeDetails = [NodeName, NodeImplementation, NodeVersion, Maybe
]; -export type NodeStats = [PeerCount, TransactionCount, Maybe, Maybe]; +export type NodeStats = [PeerCount, TransactionCount, Array, Array]; export type NodeLocation = [Latitude, Longitude, City]; diff --git a/packages/frontend/declarations/index.d.ts b/packages/frontend/declarations/index.d.ts new file mode 100644 index 0000000..201acf7 --- /dev/null +++ b/packages/frontend/declarations/index.d.ts @@ -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; +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 76c2a63..7641b08 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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", diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index cd722e7..ac90161 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -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, diff --git a/packages/frontend/src/Connection.ts b/packages/frontend/src/Connection.ts index 7159dc0..5bd55fd 100644 --- a/packages/frontend/src/Connection.ts +++ b/packages/frontend/src/Connection.ts @@ -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; diff --git a/packages/frontend/src/components/Chain/Chain.tsx b/packages/frontend/src/components/Chain/Chain.tsx index b23e959..168db2c 100644 --- a/packages/frontend/src/components/Chain/Chain.tsx +++ b/packages/frontend/src/components/Chain/Chain.tsx @@ -252,6 +252,10 @@ export class Chain extends React.Component { } private onKeyPress = (event: KeyboardEvent) => { + if (event.ctrlKey) { + return; + } + const { filter } = this.state; const key = event.key; const code = event.keyCode; diff --git a/packages/frontend/src/components/Chain/Filter.tsx b/packages/frontend/src/components/Chain/Filter.tsx index bd0ac4d..d6f3d24 100644 --- a/packages/frontend/src/components/Chain/Filter.tsx +++ b/packages/frontend/src/components/Chain/Filter.tsx @@ -12,7 +12,7 @@ export namespace Filter { export class Filter extends React.Component { private filterInput: HTMLInputElement; - public componentDidMount(){ + public componentDidMount() { this.filterInput.focus(); } diff --git a/packages/frontend/src/components/Node/Row.css b/packages/frontend/src/components/Node/Row.css index 2e29db4..44f1473 100644 --- a/packages/frontend/src/components/Node/Row.css +++ b/packages/frontend/src/components/Node/Row.css @@ -23,6 +23,11 @@ text-overflow: ellipsis; } +.Node-Row .Node-Row-Tooltip { + position: initial; + padding: inherit; +} + .Node-Row-synced { color: #fff; } diff --git a/packages/frontend/src/components/Node/Row.tsx b/packages/frontend/src/components/Node/Row.tsx index ff68f0c..ee5194b 100644 --- a/packages/frontend/src/components/Node/Row.tsx +++ b/packages/frontend/src/components/Node/Row.tsx @@ -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 | string; } -function Truncate(props: { text: string }): React.ReactElement { - const { text } = props; +function Truncate(props: { text: string, position?: 'left' | 'right' | 'center' }): React.ReactElement { + const { text, position } = props; - return
{text}
; + return ( + +
{text}
+
+ ); +} + +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 { @@ -56,7 +79,7 @@ export default class Row extends React.Component { { label: 'Node', icon: nodeIcon, - render: ({ name }) => + render: ({ name }) => }, { label: 'Validator', @@ -64,7 +87,7 @@ export default class Row extends React.Component { width: 26, setting: 'validator', render: ({ validator }) => { - return validator ? : '-'; + return validator ? : '-'; } }, { @@ -72,7 +95,7 @@ export default class Row extends React.Component { icon: nodeLocationIcon, width: 140, setting: 'location', - render: ({ city }) => city ? : '-' + render: ({ city }) => city ? : '-' }, { label: 'Implementation', @@ -85,7 +108,11 @@ export default class Row extends React.Component { : implementation === 'substrate-node' ? paritySubstrateIcon : unknownImplementationIcon; - return {semver}; + return ( + + {semver} + + ); } }, { @@ -105,16 +132,32 @@ export default class Row extends React.Component { { 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 ( + + ); + } }, { label: 'Memory Use', icon: memoryIcon, - width: 26, + width: 40, setting: 'mem', - render: ({ mem }) => mem ? {mem / 1024 | 0}mb : '-' + render: ({ mem }) => { + if (mem.length < 3) { + return '-'; + } + + return ( + + ); + } }, { label: 'Block', @@ -128,7 +171,7 @@ export default class Row extends React.Component { icon: blockHashIcon, width: 154, setting: 'blockhash', - render: ({ hash }) => + render: ({ hash }) => }, { label: 'Block Time', @@ -155,16 +198,24 @@ export default class Row extends React.Component { public static Header = (props: HeaderProps) => { const { settings } = props; + const columns = Row.columns.filter(({ setting }) => setting == null || settings[setting]); + const last = columns.length - 1; return ( { - Row.columns - .filter(({ setting }) => setting == null || settings[setting]) - .map(({ icon, width, label }, index) => ( - - )) + columns.map(({ icon, width, label }, index) => { + const position = index === 0 ? 'left' + : index === last ? 'right' + : 'center'; + + return ( + + + + ) + }) } diff --git a/packages/frontend/src/components/Sparkline.css b/packages/frontend/src/components/Sparkline.css new file mode 100644 index 0000000..bb27399 --- /dev/null +++ b/packages/frontend/src/components/Sparkline.css @@ -0,0 +1,6 @@ +.Sparkline { + fill: currentcolor; /* rgba(255,255,255,0.5); */ + fill-opacity: 0.5; + stroke: currentcolor; + margin: 0 -14px 0 -4px; +} diff --git a/packages/frontend/src/components/Sparkline.tsx b/packages/frontend/src/components/Sparkline.tsx new file mode 100644 index 0000000..5f744d6 --- /dev/null +++ b/packages/frontend/src/components/Sparkline.tsx @@ -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 { + 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 ( + + + + ); + } + + 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); + } +} diff --git a/packages/frontend/src/components/Tooltip.css b/packages/frontend/src/components/Tooltip.css new file mode 100644 index 0000000..2de568e --- /dev/null +++ b/packages/frontend/src/components/Tooltip.css @@ -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; + } +} diff --git a/packages/frontend/src/components/Tooltip.tsx b/packages/frontend/src/components/Tooltip.tsx new file mode 100644 index 0000000..82e0381 --- /dev/null +++ b/packages/frontend/src/components/Tooltip.tsx @@ -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 { + 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 ( +
+
{text}
+ {this.props.children} +
+ ); + } + + private onRef = (el: HTMLDivElement) => { + this.el = el; + } + + private update = (text: string) => { + this.el.textContent = text; + } +} diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 88adeb3..2f2cffc 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -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'; diff --git a/packages/frontend/src/state.ts b/packages/frontend/src/state.ts index 37a9859..9dfc095 100644 --- a/packages/frontend/src/state.ts +++ b/packages/frontend/src/state.ts @@ -27,8 +27,8 @@ export class Node { public pinned: boolean; public peers: Types.PeerCount; public txs: Types.TransactionCount; - public mem: Maybe; - public cpu: Maybe; + public mem: Types.MemoryUse[]; + public cpu: Types.CPUUse[]; public height: Types.BlockNumber; public hash: Types.BlockHash; diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index e972877..a6c48b9 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -21,6 +21,7 @@ ], "include": [ "src/**/*.ts", - "src/**/*.tsx" + "src/**/*.tsx", + "./declarations/**/*.d.ts" ] } diff --git a/yarn.lock b/yarn.lock index f7af65a..22d7dae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"