Line data Source code
1 : /*
2 : * Copyright (C) 2020 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 149 : import cockpit from "cockpit";
7 :
8 149 : import React, { useState, useRef, useLayoutEffect } from 'react';
9 : import { useEvent } from "hooks.js";
10 :
11 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
12 : import { Dropdown, DropdownItem, DropdownList } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
13 : import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
14 : import { MenuToggle } from '@patternfly/react-core/dist/esm/components/MenuToggle/index.js';
15 :
16 : import { AngleLeftIcon, AngleRightIcon, SearchMinusIcon } from '@patternfly/react-icons';
17 :
18 : import * as timeformat from "timeformat";
19 : import '@patternfly/patternfly/patternfly-charts.scss';
20 : import "cockpit-components-plot.scss";
21 :
22 149 : const _ = cockpit.gettext;
23 :
24 129 : function time_ticks(data) {
25 129 : const first_plot = data[0].data;
26 129 : const start_ms = first_plot[0][0];
27 129 : const end_ms = first_plot[first_plot.length - 1][0];
28 :
29 : // Determine size between ticks
30 :
31 129 : const sizes_in_seconds = [
32 129 : 60, // minute
33 129 : 5 * 60, // 5 minutes
34 129 : 10 * 60, // 10 minutes
35 129 : 30 * 60, // half hour
36 129 : 60 * 60, // hour
37 129 : 6 * 60 * 60, // quarter day
38 129 : 12 * 60 * 60, // half day
39 129 : 24 * 60 * 60, // day
40 129 : 7 * 24 * 60 * 60, // week
41 129 : 30 * 24 * 60 * 60, // month
42 129 : 183 * 24 * 60 * 60, // half a year
43 129 : 365 * 24 * 60 * 60, // year
44 129 : 10 * 365 * 24 * 60 * 60 // 10 years
45 129 : ];
46 :
47 129 : let size;
48 129 : for (let i = 0; i < sizes_in_seconds.length; i++) {
49 10 : if (((end_ms - start_ms) / 1000) / sizes_in_seconds[i] < 10 || i == sizes_in_seconds.length - 1) {
50 129 : size = sizes_in_seconds[i] * 1000;
51 129 : break;
52 129 : }
53 129 : }
54 :
55 : // Determine what to omit from the tick label. If it's all in the
56 : // current year, we don't need to include the year; and if it's
57 : // all happening today, we don't include the month and date.
58 :
59 129 : const now_date = new Date();
60 129 : const start_date = new Date(start_ms);
61 :
62 129 : let include_year = true;
63 129 : let include_month_and_day = true;
64 :
65 129 : if (start_date.getFullYear() == now_date.getFullYear()) {
66 129 : include_year = false;
67 129 : if (start_date.getMonth() == now_date.getMonth() && start_date.getDate() == now_date.getDate())
68 129 : include_month_and_day = false;
69 129 : }
70 :
71 : // Compute the actual ticks
72 :
73 129 : const ticks = [];
74 129 : let t = Math.ceil(start_ms / size) * size;
75 129 : while (t < end_ms) {
76 129 : ticks.push(t);
77 129 : t += size;
78 129 : }
79 :
80 : // Render the label
81 :
82 126 : function pad(n) {
83 126 : let str = n.toFixed();
84 126 : if (str.length == 1)
85 126 : str = '0' + str;
86 126 : return str;
87 126 : }
88 :
89 126 : function format_tick(val, index, ticks) {
90 126 : const d = new Date(val);
91 126 : let label = ' ';
92 :
93 9 : if (include_month_and_day) {
94 9 : if (include_year)
95 9 : label += timeformat.date(d) + '\n';
96 : else
97 9 : label += timeformat.formatter({ month: "long" }).format(d) + ' ' + d.getDate().toFixed() + '\n';
98 9 : }
99 126 : label += pad(d.getHours()) + ':' + pad(d.getMinutes());
100 :
101 126 : return label;
102 126 : }
103 :
104 129 : return {
105 129 : ticks,
106 129 : formatter: format_tick,
107 129 : start: start_ms,
108 129 : end: end_ms
109 129 : };
110 129 : }
111 :
112 129 : function value_ticks(data, config) {
113 129 : let max = config.min_max;
114 129 : const last_plot = data[data.length - 1].data;
115 129 : for (let i = 0; i < last_plot.length; i++) {
116 129 : const s = last_plot[i][1] || last_plot[i][2];
117 129 : if (s > max)
118 65 : max = s;
119 129 : }
120 :
121 : // Find the highest power of the base unit that is still below
122 : // MAX.
123 : //
124 : // For example, if the base unit is 1000 and MAX is 402,345,765
125 : // this will set UNIT to 1,000,000, aka "Mega".
126 : //
127 129 : let unit = 1;
128 129 : while (config.base_unit && max > unit * config.base_unit)
129 129 : unit *= config.base_unit;
130 :
131 : // Find the highest power of 10 that is below the maximum number
132 : // on a tick label. If we use that as the distance between ticks,
133 : // we get at most 10 ticks.
134 : //
135 : // To continue the example, MAX is 402,345,765 and UNIT is thus
136 : // 1,000,000. The highest number on a tick label would be MAX /
137 : // UNIT = 402ish. The highest power of 10 below that is 100. Thus
138 : // the size between ticks is 100*UNIT = 100,000,000. Ticks would
139 : // thus be "100 Mega" apart.
140 : //
141 : // If the highest number of would be only, say, 81, then we would get
142 : // a highest power of 10, and ticks would be 10 units apart.
143 : //
144 129 : let size = Math.pow(10, Math.floor(Math.log10(max / unit))) * unit;
145 :
146 : // Get the number of ticks to be around 4, but don't produce
147 : // fractional numbers. This is done by doubling or halving the
148 : // size between ticks until we get MAX / SIZE to be less than 8 or
149 : // greater than 2.
150 : //
151 : // In the example, MAX / SIZE is already in range, so nothing
152 : // changes here.
153 : //
154 : // If MAX / UNIT is close to the next power of ten, such as 999, we
155 : // would end up with a doubled SIZE of 200,000,000.
156 : //
157 : // If on the other hand MAX / UNIT would be closer to the next
158 : // lower power of 10, like say 110, then we would half the SIZE to
159 : // get moreticks. With 110, it will happen twice and SIZE ends up
160 : // being 25,000,000.
161 : //
162 : // However, if we only have single digit tick labels, we don't
163 : // want to halve them any further, since we don't want tick labels
164 : // like "0.75".
165 : //
166 129 : while (max / size > 7)
167 43 : size *= 2;
168 129 : while (max / size < 3 && size / unit >= 10)
169 129 : size /= 2;
170 :
171 : // Make a list of tick values, each SIZE apart until we are just
172 : // above MAX.
173 : //
174 : // In the example, we get
175 : //
176 : // [ 0, 100000000, 200000000, 300000000, 400000000, 500000000 ]
177 : //
178 129 : const ticks = [];
179 129 : for (let t = 0; t < max + size; t += size)
180 129 : ticks.push(t);
181 :
182 129 : if (config.pull_out_unit) {
183 129 : const unit_str = config.formatter(unit, config.base_unit, true)[1];
184 :
185 129 : return {
186 129 : ticks,
187 126 : formatter: (val) => config.formatter(val, unit_str, true)[0],
188 129 : unit: unit_str,
189 129 : max: ticks[ticks.length - 1]
190 129 : };
191 10 : } else {
192 10 : return {
193 10 : ticks,
194 10 : formatter: config.formatter,
195 10 : max: ticks[ticks.length - 1]
196 10 : };
197 10 : }
198 129 : }
199 :
200 128 : export const ZoomControls = ({ plot_state }) => {
201 3 : function format_range(seconds) {
202 3 : let n;
203 0 : if (seconds >= 365 * 24 * 60 * 60) {
204 0 : n = Math.ceil(seconds / (365 * 24 * 60 * 60));
205 0 : return cockpit.format(cockpit.ngettext("$0 year", "$0 years", n), n);
206 0 : } else if (seconds >= 30 * 24 * 60 * 60) {
207 0 : n = Math.ceil(seconds / (30 * 24 * 60 * 60));
208 0 : return cockpit.format(cockpit.ngettext("$0 month", "$0 months", n), n);
209 0 : } else if (seconds >= 7 * 24 * 60 * 60) {
210 0 : n = Math.ceil(seconds / (7 * 24 * 60 * 60));
211 0 : return cockpit.format(cockpit.ngettext("$0 week", "$0 weeks", n), n);
212 0 : } else if (seconds >= 24 * 60 * 60) {
213 0 : n = Math.ceil(seconds / (24 * 60 * 60));
214 0 : return cockpit.format(cockpit.ngettext("$0 day", "$0 days", n), n);
215 0 : } else if (seconds >= 60 * 60) {
216 0 : n = Math.ceil(seconds / (60 * 60));
217 0 : return cockpit.format(cockpit.ngettext("$0 hour", "$0 hours", n), n);
218 0 : } else {
219 3 : n = Math.ceil(seconds / 60);
220 3 : return cockpit.format(cockpit.ngettext("$0 minute", "$0 minutes", n), n);
221 3 : }
222 3 : }
223 :
224 128 : const zoom_state = plot_state.zoom_state;
225 :
226 128 : const [isOpen, setIsOpen] = useState(false);
227 128 : useEvent(plot_state, "changed");
228 128 : useEvent(zoom_state, "changed");
229 :
230 3 : function range_item(seconds, title) {
231 3 : return (
232 3 : <DropdownItem key={title}
233 0 : onClick={() => {
234 0 : setIsOpen(false);
235 0 : zoom_state.set_range(seconds);
236 0 : }}>
237 3 : {title}
238 3 : </DropdownItem>
239 : );
240 3 : }
241 :
242 128 : if (!zoom_state)
243 128 : return null;
244 :
245 13 : const dropdownItems = [
246 0 : <DropdownItem key="now" onClick={() => { zoom_state.goto_now(); setIsOpen(false) }}>
247 13 : {_("Go to now")}
248 13 : </DropdownItem>,
249 13 : <Divider key="sep" />,
250 13 : range_item(5 * 60, _("5 minutes")),
251 13 : range_item(60 * 60, _("1 hour")),
252 13 : range_item(6 * 60 * 60, _("6 hours")),
253 13 : range_item(24 * 60 * 60, _("1 day")),
254 13 : range_item(7 * 24 * 60 * 60, _("1 week"))
255 13 : ];
256 :
257 13 : return (
258 13 : <div id="zoom-control">
259 13 : <Dropdown
260 13 : isOpen={isOpen}
261 3 : toggle={(toggleRef) => (
262 0 : <MenuToggle ref={toggleRef} onClick={() => setIsOpen(!isOpen)} isExpanded={isOpen}>
263 3 : {format_range(zoom_state.x_range)}
264 3 : </MenuToggle>
265 : )}
266 : >
267 13 : <DropdownList>
268 13 : {dropdownItems}
269 13 : </DropdownList>
270 13 : </Dropdown>
271 13 : { "\n" }
272 0 : <Button icon={<SearchMinusIcon />} variant="secondary" onClick={() => zoom_state.zoom_out()}
273 13 : isDisabled={!zoom_state.enable_zoom_out} />
274 13 : { "\n" }
275 0 : <Button icon={<AngleLeftIcon />} variant="secondary" onClick={() => zoom_state.scroll_left()}
276 13 : isDisabled={!zoom_state.enable_scroll_left} />
277 0 : <Button icon={<AngleRightIcon />} variant="secondary" onClick={() => zoom_state.scroll_right()}
278 13 : isDisabled={!zoom_state.enable_scroll_right} />
279 13 : </div>
280 : );
281 128 : };
282 :
283 129 : const useLayoutSize = (init_width, init_height) => {
284 129 : const ref = useRef(null);
285 129 : const [size, setSize] = useState({ width: init_width, height: init_height });
286 : /* eslint-disable react-hooks/exhaustive-deps */
287 129 : useLayoutEffect(() => {
288 129 : if (ref.current) {
289 129 : const rect = ref.current.getBoundingClientRect();
290 : // Some browsers, such as Bromite, add noise to the result
291 : // of getBoundingClientRect in order to deter
292 : // fingerprinting. Let's allow for that by only reacting
293 : // to significant changes.
294 129 : if (Math.abs(rect.width - size.width) > 2 || Math.abs(rect.height - size.height) > 2)
295 127 : setSize({ width: rect.width, height: rect.height });
296 129 : }
297 129 : });
298 : /* eslint-enable */
299 129 : return [ref, size];
300 129 : };
301 :
302 129 : export const SvgPlot = ({ title, config, plot_state, plot_id, className }) => {
303 129 : const [container_ref, container_size] = useLayoutSize(0, 0);
304 129 : const [measure_ref, measure_size] = useLayoutSize(36, 20);
305 :
306 129 : useEvent(plot_state, "plot:" + plot_id);
307 129 : useEvent(plot_state, "changed");
308 129 : useEvent(window, "resize");
309 :
310 129 : const [selection, setSelection] = useState(null);
311 :
312 129 : const chart_data = plot_state.data(plot_id);
313 129 : if (!chart_data || chart_data.length == 0)
314 11 : return null;
315 :
316 129 : const t_ticks = time_ticks(chart_data);
317 129 : const y_ticks = value_ticks(chart_data, config);
318 :
319 129 : function make_chart() {
320 129 : const w = container_size.width;
321 129 : const h = container_size.height;
322 :
323 127 : if (w == 0 || h == 0)
324 129 : return null;
325 :
326 127 : const x_off = t_ticks.start;
327 127 : const x_range = (t_ticks.end - t_ticks.start);
328 127 : const y_range = y_ticks.max;
329 :
330 127 : const tick_length = 5;
331 127 : const tick_gap = 3;
332 :
333 : // widest string plus gap plus tick
334 127 : const m_left = Math.ceil(measure_size.width) + tick_gap + tick_length;
335 :
336 : // half of the time label so that it pops in fully formed at the far right edge
337 127 : const m_right = 30;
338 :
339 : // half a line for the top-half of the top-most y-axis label
340 : // plus one extra line if there is a unit or a title.
341 10 : const m_top = (y_ticks.unit || title ? 1.5 : 0.5) * Math.ceil(measure_size.height);
342 :
343 : // x-axis labels can be up to two lines
344 129 : const m_bottom = tick_length + tick_gap + 2 * Math.ceil(measure_size.height);
345 :
346 126 : function x_coord(x) {
347 126 : return (x - x_off) / x_range * (w - m_left - m_right) + m_left;
348 126 : }
349 :
350 0 : function x_value(c) {
351 0 : return (c - m_left) / (w - m_left - m_right) * x_range + x_off;
352 0 : }
353 :
354 126 : function y_coord(y) {
355 126 : return h - Math.max(y, 0) / y_range * (h - m_top - m_bottom) - m_bottom;
356 126 : }
357 :
358 126 : function cmd(op, x, y) {
359 126 : return op + x.toFixed() + "," + y.toFixed() + " ";
360 126 : }
361 :
362 126 : function path(data, hover_arg) {
363 126 : let d = cmd("M", m_left, h - m_bottom);
364 126 : for (let i = 0; i < data.length; i++) {
365 126 : d += cmd("L", x_coord(data[i][0]), y_coord(data[i][1]));
366 126 : }
367 126 : d += cmd("L", w - m_right, h - m_bottom);
368 126 : d += "z";
369 :
370 126 : return (
371 126 : <path key={hover_arg} d={d}
372 126 : role="presentation">
373 126 : <title>{hover_arg}</title>
374 126 : </path>
375 : );
376 126 : }
377 :
378 129 : const paths = [];
379 129 : for (let i = chart_data.length - 1; i >= 0; i--)
380 12 : paths.push(path(chart_data[i].data, chart_data[i].name || true));
381 :
382 0 : function start_dragging(event) {
383 0 : if (event.button !== 0)
384 0 : return;
385 :
386 0 : const bounds = container_ref.current.getBoundingClientRect();
387 0 : const x = event.clientX - bounds.x;
388 0 : if (x >= m_left && x < w - m_right)
389 0 : setSelection({ start: x, stop: x, left: x, right: x });
390 0 : }
391 :
392 0 : function drag(event) {
393 0 : const bounds = container_ref.current.getBoundingClientRect();
394 0 : let x = event.clientX - bounds.x;
395 0 : if (x < m_left) x = m_left;
396 0 : if (x > w - m_right) x = w - m_right;
397 0 : setSelection({
398 0 : start: selection.start,
399 0 : stop: x,
400 0 : left: Math.min(selection.start, x),
401 0 : right: Math.max(selection.start, x)
402 0 : });
403 0 : }
404 :
405 0 : function stop_dragging() {
406 0 : const left = x_value(selection.left) / 1000;
407 0 : const right = x_value(selection.right) / 1000;
408 0 : plot_state.zoom_state.zoom_in(right - left, right);
409 0 : setSelection(null);
410 0 : }
411 :
412 13 : function cancel_dragging() {
413 13 : setSelection(null);
414 13 : }
415 :
416 : // This is a thin transparent rectangle placed at the x-axis,
417 : // on top of all the graphs. It prevents bogus hover events
418 : // for parts of the graph that are zero or very very close to
419 : // it.
420 127 : const hover_guard =
421 127 : <rect x={0} y={h - m_bottom - 1} width={w} height={5} fill="transparent" />;
422 :
423 127 : return (
424 127 : <svg width={w} height={h}
425 127 : className="ct-plot"
426 127 : aria-label={title}
427 : // TODO: Figure out a way to handle a11y without entirely hiding the live-updating graphs
428 127 : aria-hidden="true"
429 127 : role="img"
430 10 : onMouseDown={plot_state.zoom_state?.enable_zoom_in ? start_dragging : null}
431 10 : onMouseUp={selection ? stop_dragging : null}
432 10 : onMouseMove={selection ? drag : null}
433 129 : onMouseLeave={cancel_dragging}>
434 129 : <title>{title}</title>
435 129 : <text x={0} y={-20} className="ct-plot-widest" ref={measure_ref} aria-hidden="true">{config.widest_string}</text>
436 129 : <rect x={m_left} y={m_top} width={w - m_left - m_right} height={h - m_top - m_bottom}
437 129 : className="ct-plot-border" />
438 127 : { y_ticks.unit && <text x={m_left - tick_length - tick_gap} y={0.5 * m_top}
439 127 : className="ct-plot-unit"
440 127 : textAnchor="end">
441 127 : {y_ticks.unit}
442 127 : </text>
443 : }
444 127 : { title && <text x={m_left} y={0.5 * m_top} className="ct-plot-title">
445 127 : {title}
446 127 : </text>
447 : }
448 129 : <g className="ct-plot-lines" role="presentation">
449 126 : { y_ticks.ticks.map((t, i) => <line key={i}
450 126 : x1={m_left - tick_length} x2={w - m_right}
451 126 : y1={y_coord(t)} y2={y_coord(t)} />) }
452 129 : </g>
453 129 : <g className="ct-plot-ticks" role="presentation">
454 126 : { t_ticks.ticks.map((t, i) => <line key={i}
455 126 : x1={x_coord(t)} x2={x_coord(t)}
456 126 : y1={h - m_bottom} y2={h - m_bottom + tick_length} />) }
457 129 : </g>
458 129 : <g className="ct-plot-paths">
459 129 : { paths }
460 129 : </g>
461 129 : { hover_guard }
462 129 : <g className="ct-plot-axis ct-plot-axis-y" textAnchor="end">
463 126 : { y_ticks.ticks.map((t, i) => <text key={i} x={m_left - tick_length - tick_gap} y={y_coord(t) + 5}>
464 126 : {y_ticks.formatter(t)}
465 126 : </text>) }
466 129 : </g>
467 129 : <g className="ct-plot-axis ct-plot-axis-x" textAnchor="middle">
468 126 : { t_ticks.ticks.map((t, i) => <text key={i} y={h - m_bottom + tick_length + tick_gap}>
469 126 : { t_ticks.formatter(t).split("\n")
470 126 : .map((s, j) =>
471 126 : <tspan key={i + "." + j} x={x_coord(t)} dy="1.2em">{s}</tspan>) }
472 126 : </text>) }
473 129 : </g>
474 129 : { selection &&
475 10 : <rect x={selection.left} y={m_top} width={selection.right - selection.left} height={h - m_top - m_bottom}
476 10 : className="ct-plot-selection" /> }
477 129 : </svg>
478 : );
479 129 : }
480 :
481 129 : return (
482 129 : <div className={className} ref={container_ref}>
483 129 : {make_chart()}
484 129 : </div>);
485 129 : };
486 :
487 149 : export const bytes_config = {
488 149 : base_unit: 1024,
489 149 : min_max: 10240,
490 149 : pull_out_unit: true,
491 149 : widest_string: "MiB",
492 149 : formatter: cockpit.format_bytes
493 149 : };
494 :
495 149 : export const bytes_per_sec_config = {
496 149 : base_unit: 1024,
497 149 : min_max: 10240,
498 149 : pull_out_unit: true,
499 149 : widest_string: "MiB/s",
500 149 : formatter: cockpit.format_bytes_per_sec
501 149 : };
502 :
503 149 : export const bits_per_sec_config = {
504 149 : base_unit: 1000,
505 149 : min_max: 10000,
506 149 : pull_out_unit: true,
507 149 : widest_string: "Mbps",
508 149 : formatter: cockpit.format_bits_per_sec
509 149 : };
|