* Copyright (C) 2020 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import React, { useState, useRef, useLayoutEffect } from 'react';
import { useEvent } from "hooks.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Dropdown, DropdownItem, DropdownList } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
import { MenuToggle } from '@patternfly/react-core/dist/esm/components/MenuToggle/index.js';
import { AngleLeftIcon, AngleRightIcon, SearchMinusIcon } from '@patternfly/react-icons';
import * as timeformat from "timeformat";
import '@patternfly/patternfly/patternfly-charts.scss';
import "cockpit-components-plot.scss";
const _ = cockpit.gettext;
function time_ticks(data) {
const first_plot = data[0].data;
const start_ms = first_plot[0][0];
const end_ms = first_plot[first_plot.length - 1][0];
const sizes_in_seconds = [
60,
5 * 60,
10 * 60,
30 * 60,
60 * 60,
6 * 60 * 60,
12 * 60 * 60,
24 * 60 * 60,
7 * 24 * 60 * 60,
30 * 24 * 60 * 60,
183 * 24 * 60 * 60,
365 * 24 * 60 * 60,
10 * 365 * 24 * 60 * 60
];
let size;
for (let i = 0; i < sizes_in_seconds.length; i++) {
if (((end_ms - start_ms) / 1000) / sizes_in_seconds[i] < 10 || i === sizes_in_seconds.length - 1) {
size = sizes_in_seconds[i] * 1000;
break;
}
}
const now_date = new Date();
const start_date = new Date(start_ms);
let include_year = true;
let include_month_and_day = true;
if (start_date.getFullYear() === now_date.getFullYear()) {
include_year = false;
if (start_date.getMonth() === now_date.getMonth() && start_date.getDate() === now_date.getDate())
include_month_and_day = false;
}
const ticks = [];
let t = Math.ceil(start_ms / size) * size;
while (t < end_ms) {
ticks.push(t);
t += size;
}
function pad(n) {
let str = n.toFixed();
if (str.length === 1)
str = '0' + str;
return str;
}
function format_tick(val, index, ticks) {
const d = new Date(val);
let label = ' ';
if (include_month_and_day) {
if (include_year)
label += timeformat.date(d) + '\n';
else
label += timeformat.formatter({ month: "long" }).format(d) + ' ' + d.getDate().toFixed() + '\n';
}
label += pad(d.getHours()) + ':' + pad(d.getMinutes());
return label;
}
return {
ticks,
formatter: format_tick,
start: start_ms,
end: end_ms
};
}
function value_ticks(data, config) {
let max = config.min_max;
const last_plot = data[data.length - 1].data;
for (let i = 0; i < last_plot.length; i++) {
const s = last_plot[i][1] || last_plot[i][2];
if (s > max)
max = s;
}
let unit = 1;
while (config.base_unit && max > unit * config.base_unit)
unit *= config.base_unit;
let size = Math.pow(10, Math.floor(Math.log10(max / unit))) * unit;
while (max / size > 7)
size *= 2;
while (max / size < 3 && size / unit >= 10)
size /= 2;
const ticks = [];
for (let t = 0; t < max + size; t += size)
ticks.push(t);
if (config.pull_out_unit) {
const unit_str = config.formatter(unit, config.base_unit, true)[1];
return {
ticks,
formatter: (val) => config.formatter(val, unit_str, true)[0],
unit: unit_str,
max: ticks[ticks.length - 1]
};
} else {
return {
ticks,
formatter: config.formatter,
max: ticks[ticks.length - 1]
};
}
}
export const ZoomControls = ({ plot_state }) => {
function format_range(seconds) {
let n;
if (seconds >= 365 * 24 * 60 * 60) {
n = Math.ceil(seconds / (365 * 24 * 60 * 60));
return cockpit.format(cockpit.ngettext("$0 year", "$0 years", n), n);
} else if (seconds >= 30 * 24 * 60 * 60) {
n = Math.ceil(seconds / (30 * 24 * 60 * 60));
return cockpit.format(cockpit.ngettext("$0 month", "$0 months", n), n);
} else if (seconds >= 7 * 24 * 60 * 60) {
n = Math.ceil(seconds / (7 * 24 * 60 * 60));
return cockpit.format(cockpit.ngettext("$0 week", "$0 weeks", n), n);
} else if (seconds >= 24 * 60 * 60) {
n = Math.ceil(seconds / (24 * 60 * 60));
return cockpit.format(cockpit.ngettext("$0 day", "$0 days", n), n);
} else if (seconds >= 60 * 60) {
n = Math.ceil(seconds / (60 * 60));
return cockpit.format(cockpit.ngettext("$0 hour", "$0 hours", n), n);
} else {
n = Math.ceil(seconds / 60);
return cockpit.format(cockpit.ngettext("$0 minute", "$0 minutes", n), n);
}
}
const zoom_state = plot_state.zoom_state;
const [isOpen, setIsOpen] = useState(false);
useEvent(plot_state, "changed");
useEvent(zoom_state, "changed");
function range_item(seconds, title) {
return (
<DropdownItem key={title}
onClick={() => {
setIsOpen(false);
zoom_state.set_range(seconds);
}}>
{title}
</DropdownItem>
);
}
if (!zoom_state)
return null;
const dropdownItems = [
<DropdownItem key="now" onClick={() => { zoom_state.goto_now(); setIsOpen(false) }}>
{_("Go to now")}
</DropdownItem>,
<Divider key="sep" />,
range_item(5 * 60, _("5 minutes")),
range_item(60 * 60, _("1 hour")),
range_item(6 * 60 * 60, _("6 hours")),
range_item(24 * 60 * 60, _("1 day")),
range_item(7 * 24 * 60 * 60, _("1 week"))
];
return (
<div id="zoom-control">
<Dropdown
isOpen={isOpen}
toggle={(toggleRef) => (
<MenuToggle ref={toggleRef} onClick={() => setIsOpen(!isOpen)} isExpanded={isOpen}>
{format_range(zoom_state.x_range)}
</MenuToggle>
)}
>
<DropdownList>
{dropdownItems}
</DropdownList>
</Dropdown>
{ "\n" }
<Button icon={<SearchMinusIcon />} variant="secondary" onClick={() => zoom_state.zoom_out()}
isDisabled={!zoom_state.enable_zoom_out} />
{ "\n" }
<Button icon={<AngleLeftIcon />} variant="secondary" onClick={() => zoom_state.scroll_left()}
isDisabled={!zoom_state.enable_scroll_left} />
<Button icon={<AngleRightIcon />} variant="secondary" onClick={() => zoom_state.scroll_right()}
isDisabled={!zoom_state.enable_scroll_right} />
</div>
);
};
const useLayoutSize = (init_width, init_height) => {
const ref = useRef(null);
const [size, setSize] = useState({ width: init_width, height: init_height });
useLayoutEffect(() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
if (Math.abs(rect.width - size.width) > 2 || Math.abs(rect.height - size.height) > 2)
setSize({ width: rect.width, height: rect.height });
}
});
return [ref, size];
};
export const SvgPlot = ({ title, config, plot_state, plot_id, className }) => {
const [container_ref, container_size] = useLayoutSize(0, 0);
const [measure_ref, measure_size] = useLayoutSize(36, 20);
useEvent(plot_state, "plot:" + plot_id);
useEvent(plot_state, "changed");
useEvent(window, "resize");
const [selection, setSelection] = useState(null);
const chart_data = plot_state.data(plot_id);
if (!chart_data || chart_data.length === 0)
return null;
const t_ticks = time_ticks(chart_data);
const y_ticks = value_ticks(chart_data, config);
function make_chart() {
const w = container_size.width;
const h = container_size.height;
if (w === 0 || h === 0)
return null;
const x_off = t_ticks.start;
const x_range = (t_ticks.end - t_ticks.start);
const y_range = y_ticks.max;
const tick_length = 5;
const tick_gap = 3;
const m_left = Math.ceil(measure_size.width) + tick_gap + tick_length;
const m_right = 30;
const m_top = (y_ticks.unit || title ? 1.5 : 0.5) * Math.ceil(measure_size.height);
const m_bottom = tick_length + tick_gap + 2 * Math.ceil(measure_size.height);
function x_coord(x) {
return (x - x_off) / x_range * (w - m_left - m_right) + m_left;
}
function x_value(c) {
return (c - m_left) / (w - m_left - m_right) * x_range + x_off;
}
function y_coord(y) {
return h - Math.max(y, 0) / y_range * (h - m_top - m_bottom) - m_bottom;
}
function cmd(op, x, y) {
return op + x.toFixed() + "," + y.toFixed() + " ";
}
function path(data, hover_arg) {
let d = cmd("M", m_left, h - m_bottom);
for (let i = 0; i < data.length; i++) {
d += cmd("L", x_coord(data[i][0]), y_coord(data[i][1]));
}
d += cmd("L", w - m_right, h - m_bottom);
d += "z";
return (
<path key={hover_arg} d={d}
role="presentation">
<title>{hover_arg}</title>
</path>
);
}
const paths = [];
for (let i = chart_data.length - 1; i >= 0; i--)
paths.push(path(chart_data[i].data, chart_data[i].name || true));
function start_dragging(event) {
if (event.button !== 0)
return;
const bounds = container_ref.current.getBoundingClientRect();
const x = event.clientX - bounds.x;
if (x >= m_left && x < w - m_right)
setSelection({ start: x, stop: x, left: x, right: x });
}
function drag(event) {
const bounds = container_ref.current.getBoundingClientRect();
let x = event.clientX - bounds.x;
if (x < m_left) x = m_left;
if (x > w - m_right) x = w - m_right;
setSelection({
start: selection.start,
stop: x,
left: Math.min(selection.start, x),
right: Math.max(selection.start, x)
});
}
function stop_dragging() {
const left = x_value(selection.left) / 1000;
const right = x_value(selection.right) / 1000;
plot_state.zoom_state.zoom_in(right - left, right);
setSelection(null);
}
function cancel_dragging() {
setSelection(null);
}
const hover_guard =
<rect x={0} y={h - m_bottom - 1} width={w} height={5} fill="transparent" />;
return (
<svg width={w} height={h}
className="ct-plot"
aria-label={title}
aria-hidden="true"
role="img"
onMouseDown={plot_state.zoom_state?.enable_zoom_in ? start_dragging : null}
onMouseUp={selection ? stop_dragging : null}
onMouseMove={selection ? drag : null}
onMouseLeave={cancel_dragging}>
<title>{title}</title>
<text x={0} y={-20} className="ct-plot-widest" ref={measure_ref} aria-hidden="true">{config.widest_string}</text>
<rect x={m_left} y={m_top} width={w - m_left - m_right} height={h - m_top - m_bottom}
className="ct-plot-border" />
{ y_ticks.unit && <text x={m_left - tick_length - tick_gap} y={0.5 * m_top}
className="ct-plot-unit"
textAnchor="end">
{y_ticks.unit}
</text>
}
{ title && <text x={m_left} y={0.5 * m_top} className="ct-plot-title">
{title}
</text>
}
<g className="ct-plot-lines" role="presentation">
{ y_ticks.ticks.map((t, i) => <line key={i}
x1={m_left - tick_length} x2={w - m_right}
y1={y_coord(t)} y2={y_coord(t)} />) }
</g>
<g className="ct-plot-ticks" role="presentation">
{ t_ticks.ticks.map((t, i) => <line key={i}
x1={x_coord(t)} x2={x_coord(t)}
y1={h - m_bottom} y2={h - m_bottom + tick_length} />) }
</g>
<g className="ct-plot-paths">
{ paths }
</g>
{ hover_guard }
<g className="ct-plot-axis ct-plot-axis-y" textAnchor="end">
{ y_ticks.ticks.map((t, i) => <text key={i} x={m_left - tick_length - tick_gap} y={y_coord(t) + 5}>
{y_ticks.formatter(t)}
</text>) }
</g>
<g className="ct-plot-axis ct-plot-axis-x" textAnchor="middle">
{ t_ticks.ticks.map((t, i) => <text key={i} y={h - m_bottom + tick_length + tick_gap}>
{ t_ticks.formatter(t).split("\n")
.map((s, j) =>
<tspan key={i + "." + j} x={x_coord(t)} dy="1.2em">{s}</tspan>) }
</text>) }
</g>
{ selection &&
<rect x={selection.left} y={m_top} width={selection.right - selection.left} height={h - m_top - m_bottom}
className="ct-plot-selection" /> }
</svg>
);
}
return (
<div className={className} ref={container_ref}>
{make_chart()}
</div>);
};
export const bytes_config = {
base_unit: 1024,
min_max: 10240,
pull_out_unit: true,
widest_string: "MiB",
formatter: cockpit.format_bytes
};
export const bytes_per_sec_config = {
base_unit: 1024,
min_max: 10240,
pull_out_unit: true,
widest_string: "MiB/s",
formatter: cockpit.format_bytes_per_sec
};
export const bits_per_sec_config = {
base_unit: 1000,
min_max: 10000,
pull_out_unit: true,
widest_string: "Mbps",
formatter: cockpit.format_bits_per_sec
};