Lens app source code (#119)

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2020-03-15 09:52:02 +02:00
1 parent 936cbd53d4
commit 1d0815abd2
797 files changed
+73714 -215

No files matched your search

@@ -0,0 +1,42 @@
import { default as ChartJS } from "chart.js";
import get from "lodash/get";
const defaultOptions = {
interval: 61,
coverBars: 3,
borderColor: "#44474A",
backgroundColor: "#00000033"
}
export const BackgroundBlock = {
options: {},
getOptions(chart: ChartJS) {
return get(chart, "options.plugins.BackgroundBlock");
},
afterInit(chart: ChartJS) {
this.options = {
...defaultOptions,
...this.getOptions(chart)
}
},
beforeDraw(chart: ChartJS) {
if (!chart.chartArea) return;
const { interval, coverBars, borderColor, backgroundColor } = this.options;
const { ctx, chartArea } = chart;
const { left, right, top, bottom } = chartArea;
const blockWidth = (right - left) / interval * coverBars;
ctx.save();
ctx.fillStyle = backgroundColor;
ctx.strokeStyle = borderColor;
ctx.fillRect(right - blockWidth, top, blockWidth, bottom - top);
ctx.beginPath();
ctx.moveTo(right - blockWidth + 1.5, top);
ctx.lineTo(right - blockWidth + 1.5, bottom);
ctx.stroke();
ctx.restore();
}
}
@@ -0,0 +1,215 @@
import * as React from "react";
import * as ChartJS from "chart.js";
import { useEffect, useRef } from "react";
import merge from "lodash/merge";
import moment from "moment";
import Color from "color";
import { Chart, ChartDataSet, ChartKind, ChartProps } from "./chart";
import { ChartData, ChartOptions, ChartPoint } from "chart.js";
import { cssNames } from "../../utils";
import { bytesToUnits } from "../../utils";
import { ZebraStripes } from "./zebra-stripes.plugin";
import { themeStore } from "../../theme.store";
import { NoMetrics } from "../resource-metrics/no-metrics";
interface Props extends ChartProps {
name?: string;
title?: string;
timeLabelStep?: number; // Minute labels appearance step
}
interface IChartContext {
chart: ChartJS;
dataIndex?: number;
dataset?: ChartDataSet;
}
const defaultProps: Partial<Props> = {
timeLabelStep: 10,
plugins: [ZebraStripes]
};
BarChart.defaultProps = defaultProps;
export function BarChart(props: Props) {
const { name, data, className, timeLabelStep, plugins, options: customOptions, ...settings } = props;
const { textColorPrimary, borderFaintColor, chartStripesColor } = themeStore.activeTheme.colors;
const savedName = useRef<string>();
useEffect(() => {
savedName.current = props.name;
});
const getBarColor = (ctx: IChartContext) => {
const { dataset } = ctx;
const color = dataset.borderColor;
return Color(color).alpha(0.2).string();
}
// Remove empty sets and insert default data
const chartData: ChartData = {
...data,
datasets: data.datasets
.filter(set => set.data.length)
.map(item => {
return {
type: ChartKind.BAR,
borderWidth: { top: 3 },
barPercentage: 1,
categoryPercentage: 1,
...item
}
})
};
const formatTimeLabels = (timestamp: string, index: number) => {
const label = moment(parseInt(timestamp)).format("HH:mm");
const offset = " ";
if (index == 0) return offset + label;
if (index == 60) return label + offset;
return index % timeLabelStep == 0 ? label : "";
};
const barOptions: ChartOptions = {
maintainAspectRatio: false,
responsive: true,
scales: {
xAxes: [{
type: "time",
offset: true,
gridLines: {
display: false,
},
stacked: true,
ticks: {
callback: formatTimeLabels,
autoSkip: false,
source: "data",
backdropColor: "white",
fontColor: textColorPrimary,
fontSize: 11,
maxRotation: 0,
minRotation: 0
},
bounds: "data",
time: {
unit: "minute",
displayFormats: {
minute: "x"
},
parser: timestamp => moment.unix(parseInt(timestamp))
}
}],
yAxes: [{
position: "right",
gridLines: {
color: borderFaintColor,
drawBorder: false,
tickMarkLength: 0,
zeroLineWidth: 0
},
ticks: {
maxTicksLimit: 6,
fontColor: textColorPrimary,
fontSize: 11,
padding: 8,
min: 0
}
}]
},
tooltips: {
mode: "index",
position: "cursor",
callbacks: {
title: tooltipItems => {
const now = new Date().getTime()
if (new Date(tooltipItems[0].xLabel).getTime() > now) return "";
return `${tooltipItems[0].xLabel}`
},
labelColor: ({ datasetIndex }) => {
return {
borderColor: "darkgray",
backgroundColor: chartData.datasets[datasetIndex].borderColor as string
}
}
}
},
animation: {
duration: 0
},
elements: {
rectangle: {
backgroundColor: getBarColor.bind(null)
}
},
plugins: {
ZebraStripes: {
stripeColor: chartStripesColor
}
}
};
const options = merge(barOptions, customOptions);
if (!chartData.datasets.length) {
return <NoMetrics/>
}
return (
<Chart
className={cssNames("BarChart flex box grow column", className)}
type={ChartKind.BAR}
data={chartData}
options={options}
plugins={plugins}
{...settings}
/>
)
}
// Default options for all charts containing memory units (network, disk, memory, etc)
export const memoryOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
callback: value => {
if (!value) return 0;
return parseFloat(value) < 1 ? value.toFixed(3) : bytesToUnits(parseInt(value));
},
stepSize: 1
}
}]
},
tooltips: {
callbacks: {
label: ({ datasetIndex, index }, { datasets }) => {
const { label, data } = datasets[datasetIndex];
const value = data[index] as ChartPoint;
return `${label}: ${bytesToUnits(parseInt(value.y.toString()), 3)}`;
}
}
}
}
// Default options for all charts with cpu units or other decimal numbers
export const cpuOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
callback: value => {
if (value == 0) return 0;
if (value < 10) return value.toFixed(3);
if (value < 100) return value.toFixed(2);
return value.toFixed(1);
}
}
}]
},
tooltips: {
callbacks: {
label: ({ datasetIndex, index }, { datasets }) => {
const { label, data } = datasets[datasetIndex];
const value = data[index] as ChartPoint;
return `${label}: ${parseFloat(value.y as string).toPrecision(2)}`;
}
}
}
}
@@ -0,0 +1,27 @@
.Chart {
position: relative;
&.BarChart {
margin-left: -$margin * 2.2;
height: 100%;
overflow: hidden;
}
.legend {
.Badge {
background: transparent;
transition: background-color 250ms;
white-space: normal;
&:hover {
background: $colorVague;
cursor: default;
}
}
}
.zebra-cover {
position: absolute;
pointer-events: none;
}
}
+211
View File
@@ -0,0 +1,211 @@
import "./chart.scss";
import React, { createRef, PureComponent } from "react";
import isEqual from "lodash/isEqual";
import remove from "lodash/remove";
import * as ChartJS from "chart.js";
import { ChartData as ChartDataOrig, ChartDataSets, ChartOptions } from "chart.js";
import { StatusBrick } from "../status-brick";
import { cssNames } from "../../utils";
import { Badge } from "../badge";
export interface ChartData extends ChartDataOrig {
datasets?: ChartDataSet[];
}
export interface ChartDataSet extends ChartDataSets {
id?: string;
borderWidth?: { top: number } | any;
tooltip?: string;
}
export interface ChartProps {
data: {
labels?: Array<string | string[]>;
datasets?: ChartDataSet[];
};
width?: number | string;
height?: number | string;
options?: ChartOptions; // Passed to ChartJS instance
type?: ChartKind;
showChart?: boolean; // Possible to show legend only if false
showLegend?: boolean;
legendPosition?: "bottom";
legendColors?: string[]; // Hex colors for each of the labels in data object
plugins?: any[];
redraw?: boolean; // If true - recreate chart instance with no animation
title?: string;
className?: string;
}
export enum ChartKind {
PIE = "pie",
BAR = "bar",
LINE = "line",
DOUGHNUT = "doughnut"
}
const defaultProps: Partial<ChartProps> = {
type: ChartKind.DOUGHNUT,
options: {},
showChart: true,
showLegend: true,
legendPosition: "bottom",
plugins: [],
redraw: false
};
export class Chart extends PureComponent<ChartProps> {
static defaultProps = defaultProps as object;
private canvas = createRef<HTMLCanvasElement>()
private chart: ChartJS
// ChartJS adds _meta field to any data object passed to it.
// We clone new data prop into currentChartData to compare props and prevProps
private currentChartData: ChartData
componentDidMount() {
const { showChart } = this.props
if (!showChart) return
this.renderChart()
}
componentDidUpdate(prevProps: ChartProps) {
const { data, showChart, redraw } = this.props
if (redraw) {
this.chart.destroy()
this.renderChart()
return
}
if (!isEqual(prevProps.data, data) && showChart) {
if (!this.chart) this.renderChart()
else this.updateChart()
}
}
memoizeDataProps() {
const { data } = this.props
this.currentChartData = {
...data,
datasets: data.datasets && data.datasets.map(set => {
return {
...set
}
})
}
}
updateChart() {
const { options } = this.props
if (!this.chart) return
this.chart.options = ChartJS.helpers.configMerge(this.chart.options, options)
this.memoizeDataProps()
const datasets: ChartDataSet[] = this.chart.config.data.datasets
const nextDatasets: ChartDataSet[] = this.currentChartData.datasets || []
// Remove stale datasets if they're not available in nextDatasets
if (datasets.length > nextDatasets.length) {
const sets = [...datasets];
sets.forEach(set => {
if (!nextDatasets.find(next => next.id === set.id)) {
remove(datasets, (item => item.id === set.id))
}
})
}
// Mutating inner chart datasets to enable seamless transitions
nextDatasets.forEach((next, datasetIndex) => {
const index = datasets.findIndex(set => set.id === next.id)
if (index !== -1) {
datasets[index].data = datasets[index].data.slice() // "Clean" mobx observables data to use in ChartJS
datasets[index].data.splice(next.data.length)
next.data.forEach((point: any, dataIndex: number) => {
datasets[index].data[dataIndex] = next.data[dataIndex]
})
// Merge other fields
const { data, ...props } = next
datasets[index] = {
...datasets[index],
...props
}
}
else {
datasets[datasetIndex] = next
}
})
this.chart.update()
}
renderLegend() {
if (!this.props.showLegend) return null
const { data, legendColors } = this.props
const { labels, datasets } = data
const labelElem = (title: string, color: string, tooltip?: string) => (
<Badge
key={title}
className="flex gaps align-center"
label={(
<div>
<StatusBrick style={{ backgroundColor: color }}/>
<span>{title}</span>
</div>
)}
tooltip={tooltip}
/>
)
return (
<div className="legend flex wrap gaps">
{labels && labels.map((label: string, index) => {
const { backgroundColor } = datasets[0] as any
const color = legendColors ? legendColors[index] : backgroundColor[index]
return labelElem(label, color)
})}
{!labels && datasets.map(({ borderColor, label, tooltip }) =>
labelElem(label, borderColor as any, tooltip)
)}
</div>
)
}
renderChart() {
const { type, options, plugins } = this.props
this.memoizeDataProps()
this.chart = new ChartJS(this.canvas.current, {
type,
plugins,
options: {
...options,
legend: {
display: false
},
},
data: this.currentChartData,
})
}
render() {
const { width, height, showChart, title, className } = this.props
return (
<>
<div className={cssNames("Chart", className)}>
{title && <div className="chart-title">{title}</div>}
{showChart &&
<div className="chart-container">
<canvas
ref={this.canvas}
width={width}
height={height}
/>
<div className="chartjs-tooltip flex column"></div>
</div>
}
{this.renderLegend()}
</div>
</>
)
}
}
@@ -0,0 +1,3 @@
export * from "./chart"
export * from "./pie-chart"
export * from "./bar-chart"
@@ -0,0 +1,18 @@
.PieChart {
.chart-container {
width: 120px;
}
.legend {
margin-top: $margin;
flex-direction: column;
> * {
&.Badge:hover {
background-color: transparent;
}
margin-bottom: $margin;
}
}
}
@@ -0,0 +1,63 @@
import "./pie-chart.scss";
import * as React from "react";
import * as ChartJS from "chart.js";
import { ChartData, ChartOptions } from "chart.js";
import { Chart, ChartProps } from "./chart";
import { cssNames } from "../../utils";
import { themeStore } from "../../theme.store";
interface Props extends ChartProps {
data: ChartData;
title?: string;
}
export class PieChart extends React.Component<Props> {
render() {
const { data, className, options, ...settings } = this.props
const { contentColor } = themeStore.activeTheme.colors;
const cutouts = [88, 76, 63]
const opts: ChartOptions = this.props.showChart === false ? {} : {
maintainAspectRatio: false,
tooltips: {
mode: "index",
callbacks: {
title: () => "",
label: (tooltipItem, data) => {
const dataset: any = data["datasets"][tooltipItem.datasetIndex]
const metaData = Object.values<{ total: number }>(dataset["_meta"])[0]
const percent = Math.round((dataset["data"][tooltipItem["index"]] / metaData.total) * 100)
if (isNaN(percent)) return "N/A";
return percent + "%";
},
},
filter: ({ datasetIndex, index }, { datasets }) => {
const { data } = datasets[datasetIndex];
if (datasets.length === 1) return true;
return index !== data.length - 1;
},
position: "cursor",
},
elements: {
arc: {
borderWidth: 1,
borderColor: contentColor
},
},
cutoutPercentage: cutouts[data.datasets.length - 1] || 50,
responsive: true,
...options
}
return (
<Chart
className={cssNames("PieChart flex column align-center", className)}
data={data}
options={opts}
{...settings}
/>
)
}
}
ChartJS.Tooltip.positioners.cursor = function (elements: any, position: { x: number; y: number }) {
return position;
};
@@ -0,0 +1,43 @@
import moment from "moment";
import { useState, useEffect } from "react";
import { useInterval } from "../../hooks";
type IMetricValues = [number, string][];
type IChartData = { x: number; y: string }[]
const defaultParams = {
fetchInterval: 15,
updateInterval: 5
}
export function useRealTimeMetrics(metrics: IMetricValues, chartData: IChartData, params = defaultParams) {
const [index, setIndex] = useState(0);
const { fetchInterval, updateInterval } = params;
const rangeMetrics = metrics.slice(-updateInterval);
const steps = fetchInterval / updateInterval;
const data = [...chartData];
useEffect(() => {
setIndex(0);
}, [metrics]);
useInterval(() => {
if (index < steps + 1) {
setIndex(index + steps - 1);
}
}, updateInterval * 1000);
if (data.length && metrics.length) {
const lastTime = data[data.length - 1].x;
const values = [];
for (let i = 0; i < 3; i++) {
values[i] = moment.unix(lastTime).add(i + 1, "m").unix();
}
data.push(
{ x: values[0], y: "0" },
{ x: values[1], y: parseFloat(rangeMetrics[index][1]).toFixed(3) },
{ x: values[2], y: "0" }
);
}
return data;
}
@@ -0,0 +1,95 @@
// Plugin for drawing stripe bars on top of any timeseries barchart
// Based on cover DIV element with repeating-linear-gradient style
import { ChartPoint, default as ChartJS } from "chart.js";
import moment, { Moment } from "moment";
import get from "lodash/get";
const defaultOptions = {
interval: 61,
stripeMinutes: 10,
stripeColor: "#ffffff08",
}
export const ZebraStripes = {
updated: null as Moment, // timestamp which all stripe movements based on
options: {},
getOptions(chart: ChartJS) {
return get(chart, "options.plugins.ZebraStripes");
},
getLastUpdate(chart: ChartJS) {
const data = chart.data.datasets[0].data[0] as ChartPoint;
return moment.unix(parseInt(data.x as string));
},
getStripesElem(chart: ChartJS) {
return chart.canvas.parentElement.querySelector(".zebra-cover");
},
removeStripesElem(chart: ChartJS) {
const elem = this.getStripesElem(chart);
if (!elem) return;
chart.canvas.parentElement.removeChild(elem);
},
renderStripes(chart: ChartJS) {
if (!chart.data.datasets.length) return;
const { interval, stripeMinutes, stripeColor } = this.options;
const { top, left, bottom, right } = chart.chartArea;
const step = (right - left) / interval;
const stripeWidth = step * stripeMinutes;
const cover = document.createElement("div");
const styles = cover.style;
if (this.getStripesElem(chart)) return;
cover.className = "zebra-cover";
styles.width = right - left + "px";
styles.left = left + "px";
styles.top = top + "px";
styles.height = bottom - top + "px";
styles.backgroundImage = `
repeating-linear-gradient(to right, ${stripeColor} 0px, ${stripeColor} ${stripeWidth}px,
transparent ${stripeWidth}px, transparent ${stripeWidth * 2 + step}px)
`;
chart.canvas.parentElement.appendChild(cover);
},
afterInit(chart: ChartJS) {
if (!chart.data.datasets.length) return;
this.options = {
...defaultOptions,
...this.getOptions(chart)
}
this.updated = this.getLastUpdate(chart);
},
afterUpdate(chart: ChartJS) {
this.renderStripes(chart);
},
resize(chart: ChartJS) {
this.removeStripesElem(chart);
},
afterDatasetUpdate(chart: ChartJS): void {
if (!this.updated) this.updated = this.getLastUpdate(chart);
const { interval } = this.options;
const { left, right } = chart.chartArea;
const step = (right - left) / interval;
const diff = moment(this.updated).diff(this.getLastUpdate(chart), "minutes");
const minutes = Math.abs(diff);
this.removeStripesElem(chart);
this.renderStripes(chart);
if (minutes > 0) {
// Move position regarding to difference in time
const cover = this.getStripesElem(chart);
cover.style.backgroundPositionX = -step * minutes + "px";
}
}
}