LCOV - code coverage report
Current view: top level - pkg/lib - cockpit-components-plot.jsx Coverage Total Hit
Test: cockpit Lines: 89.2 % 351 313
Test Date: 2026-07-13 10:00:01

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2020 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          151 : import cockpit from "cockpit";
       7              : 
       8          151 : 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          151 : const _ = cockpit.gettext;
      23              : 
      24          133 : function time_ticks(data) {
      25          133 :     const first_plot = data[0].data;
      26          133 :     const start_ms = first_plot[0][0];
      27          133 :     const end_ms = first_plot[first_plot.length - 1][0];
      28              : 
      29              :     // Determine size between ticks
      30              : 
      31          133 :     const sizes_in_seconds = [
      32          133 :         60, // minute
      33          133 :         5 * 60, // 5 minutes
      34          133 :         10 * 60, // 10 minutes
      35          133 :         30 * 60, // half hour
      36          133 :         60 * 60, // hour
      37          133 :         6 * 60 * 60, // quarter day
      38          133 :         12 * 60 * 60, // half day
      39          133 :         24 * 60 * 60, // day
      40          133 :         7 * 24 * 60 * 60, // week
      41          133 :         30 * 24 * 60 * 60, // month
      42          133 :         183 * 24 * 60 * 60, // half a year
      43          133 :         365 * 24 * 60 * 60, // year
      44          133 :         10 * 365 * 24 * 60 * 60 // 10 years
      45          133 :     ];
      46              : 
      47          133 :     let size;
      48          133 :     for (let i = 0; i < sizes_in_seconds.length; i++) {
      49           13 :         if (((end_ms - start_ms) / 1000) / sizes_in_seconds[i] < 10 || i == sizes_in_seconds.length - 1) {
      50          133 :             size = sizes_in_seconds[i] * 1000;
      51          133 :             break;
      52          133 :         }
      53          133 :     }
      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          133 :     const now_date = new Date();
      60          133 :     const start_date = new Date(start_ms);
      61              : 
      62          133 :     let include_year = true;
      63          133 :     let include_month_and_day = true;
      64              : 
      65          133 :     if (start_date.getFullYear() == now_date.getFullYear()) {
      66          133 :         include_year = false;
      67          133 :         if (start_date.getMonth() == now_date.getMonth() && start_date.getDate() == now_date.getDate())
      68          133 :             include_month_and_day = false;
      69          133 :     }
      70              : 
      71              :     // Compute the actual ticks
      72              : 
      73          133 :     const ticks = [];
      74          133 :     let t = Math.ceil(start_ms / size) * size;
      75          133 :     while (t < end_ms) {
      76          133 :         ticks.push(t);
      77          133 :         t += size;
      78          133 :     }
      79              : 
      80              :     // Render the label
      81              : 
      82          130 :     function pad(n) {
      83          130 :         let str = n.toFixed();
      84          130 :         if (str.length == 1)
      85          130 :             str = '0' + str;
      86          130 :         return str;
      87          130 :     }
      88              : 
      89          130 :     function format_tick(val, index, ticks) {
      90          130 :         const d = new Date(val);
      91          130 :         let label = ' ';
      92              : 
      93           12 :         if (include_month_and_day) {
      94           12 :             if (include_year)
      95           12 :                 label += timeformat.date(d) + '\n';
      96              :             else
      97           12 :                 label += timeformat.formatter({ month: "long" }).format(d) + ' ' + d.getDate().toFixed() + '\n';
      98           12 :         }
      99          130 :         label += pad(d.getHours()) + ':' + pad(d.getMinutes());
     100              : 
     101          130 :         return label;
     102          130 :     }
     103              : 
     104          133 :     return {
     105          133 :         ticks,
     106          133 :         formatter: format_tick,
     107          133 :         start: start_ms,
     108          133 :         end: end_ms
     109          133 :     };
     110          133 : }
     111              : 
     112          133 : function value_ticks(data, config) {
     113          133 :     let max = config.min_max;
     114          133 :     const last_plot = data[data.length - 1].data;
     115          133 :     for (let i = 0; i < last_plot.length; i++) {
     116          133 :         const s = last_plot[i][1] || last_plot[i][2];
     117          133 :         if (s > max)
     118           85 :             max = s;
     119          133 :     }
     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          133 :     let unit = 1;
     128          133 :     while (config.base_unit && max > unit * config.base_unit)
     129          133 :         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          133 :     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          133 :     while (max / size > 7)
     167           52 :         size *= 2;
     168          133 :     while (max / size < 3 && size / unit >= 10)
     169          133 :         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          133 :     const ticks = [];
     179          133 :     for (let t = 0; t < max + size; t += size)
     180          133 :         ticks.push(t);
     181              : 
     182          133 :     if (config.pull_out_unit) {
     183          133 :         const unit_str = config.formatter(unit, config.base_unit, true)[1];
     184              : 
     185          133 :         return {
     186          133 :             ticks,
     187          130 :             formatter: (val) => config.formatter(val, unit_str, true)[0],
     188          133 :             unit: unit_str,
     189          133 :             max: ticks[ticks.length - 1]
     190          133 :         };
     191           13 :     } else {
     192           13 :         return {
     193           13 :             ticks,
     194           13 :             formatter: config.formatter,
     195           13 :             max: ticks[ticks.length - 1]
     196           13 :         };
     197           13 :     }
     198          133 : }
     199              : 
     200          132 : export const ZoomControls = ({ plot_state }) => {
     201           30 :     function format_range(seconds) {
     202           30 :         let n;
     203            3 :         if (seconds >= 365 * 24 * 60 * 60) {
     204            3 :             n = Math.ceil(seconds / (365 * 24 * 60 * 60));
     205            3 :             return cockpit.format(cockpit.ngettext("$0 year", "$0 years", n), n);
     206            3 :         } else if (seconds >= 30 * 24 * 60 * 60) {
     207            3 :             n = Math.ceil(seconds / (30 * 24 * 60 * 60));
     208            3 :             return cockpit.format(cockpit.ngettext("$0 month", "$0 months", n), n);
     209            3 :         } else if (seconds >= 7 * 24 * 60 * 60) {
     210            3 :             n = Math.ceil(seconds / (7 * 24 * 60 * 60));
     211            3 :             return cockpit.format(cockpit.ngettext("$0 week", "$0 weeks", n), n);
     212            3 :         } else if (seconds >= 24 * 60 * 60) {
     213            3 :             n = Math.ceil(seconds / (24 * 60 * 60));
     214            3 :             return cockpit.format(cockpit.ngettext("$0 day", "$0 days", n), n);
     215            3 :         } else if (seconds >= 60 * 60) {
     216            3 :             n = Math.ceil(seconds / (60 * 60));
     217            3 :             return cockpit.format(cockpit.ngettext("$0 hour", "$0 hours", n), n);
     218            3 :         } else {
     219           30 :             n = Math.ceil(seconds / 60);
     220           30 :             return cockpit.format(cockpit.ngettext("$0 minute", "$0 minutes", n), n);
     221           30 :         }
     222           30 :     }
     223              : 
     224          132 :     const zoom_state = plot_state.zoom_state;
     225              : 
     226          132 :     const [isOpen, setIsOpen] = useState(false);
     227          132 :     useEvent(plot_state, "changed");
     228          132 :     useEvent(zoom_state, "changed");
     229              : 
     230           30 :     function range_item(seconds, title) {
     231           30 :         return (
     232           30 :             <DropdownItem key={title}
     233            0 :                           onClick={() => {
     234            0 :                               setIsOpen(false);
     235            0 :                               zoom_state.set_range(seconds);
     236            0 :                           }}>
     237           30 :                 {title}
     238           30 :             </DropdownItem>
     239              :         );
     240           30 :     }
     241              : 
     242          132 :     if (!zoom_state)
     243          132 :         return null;
     244              : 
     245           40 :     const dropdownItems = [
     246            0 :         <DropdownItem key="now" onClick={() => { zoom_state.goto_now(); setIsOpen(false) }}>
     247           40 :             {_("Go to now")}
     248           40 :         </DropdownItem>,
     249           40 :         <Divider key="sep" />,
     250           40 :         range_item(5 * 60, _("5 minutes")),
     251           40 :         range_item(60 * 60, _("1 hour")),
     252           40 :         range_item(6 * 60 * 60, _("6 hours")),
     253           40 :         range_item(24 * 60 * 60, _("1 day")),
     254           40 :         range_item(7 * 24 * 60 * 60, _("1 week"))
     255           40 :     ];
     256              : 
     257           40 :     return (
     258           40 :         <div id="zoom-control">
     259           40 :             <Dropdown
     260           40 :                 isOpen={isOpen}
     261           30 :                 toggle={(toggleRef) => (
     262            0 :                     <MenuToggle ref={toggleRef} onClick={() => setIsOpen(!isOpen)} isExpanded={isOpen}>
     263           30 :                         {format_range(zoom_state.x_range)}
     264           30 :                     </MenuToggle>
     265              :                 )}
     266              :             >
     267           40 :                 <DropdownList>
     268           40 :                     {dropdownItems}
     269           40 :                 </DropdownList>
     270           40 :             </Dropdown>
     271           40 :             { "\n" }
     272            0 :             <Button icon={<SearchMinusIcon />} variant="secondary" onClick={() => zoom_state.zoom_out()}
     273           40 :                 isDisabled={!zoom_state.enable_zoom_out} />
     274           40 :             { "\n" }
     275            0 :             <Button icon={<AngleLeftIcon />} variant="secondary" onClick={() => zoom_state.scroll_left()}
     276           40 :                 isDisabled={!zoom_state.enable_scroll_left} />
     277            0 :             <Button icon={<AngleRightIcon />} variant="secondary" onClick={() => zoom_state.scroll_right()}
     278           40 :                 isDisabled={!zoom_state.enable_scroll_right} />
     279           40 :         </div>
     280              :     );
     281          132 : };
     282              : 
     283          133 : const useLayoutSize = (init_width, init_height) => {
     284          133 :     const ref = useRef(null);
     285          133 :     const [size, setSize] = useState({ width: init_width, height: init_height });
     286              :     /* eslint-disable react-hooks/exhaustive-deps */
     287          133 :     useLayoutEffect(() => {
     288          133 :         if (ref.current) {
     289          133 :             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          133 :             if (Math.abs(rect.width - size.width) > 2 || Math.abs(rect.height - size.height) > 2)
     295          131 :                 setSize({ width: rect.width, height: rect.height });
     296          133 :         }
     297          133 :     });
     298              :     /* eslint-enable */
     299          133 :     return [ref, size];
     300          133 : };
     301              : 
     302          133 : export const SvgPlot = ({ title, config, plot_state, plot_id, className }) => {
     303          133 :     const [container_ref, container_size] = useLayoutSize(0, 0);
     304          133 :     const [measure_ref, measure_size] = useLayoutSize(36, 20);
     305              : 
     306          133 :     useEvent(plot_state, "plot:" + plot_id);
     307          133 :     useEvent(plot_state, "changed");
     308          133 :     useEvent(window, "resize");
     309              : 
     310          133 :     const [selection, setSelection] = useState(null);
     311              : 
     312          133 :     const chart_data = plot_state.data(plot_id);
     313          133 :     if (!chart_data || chart_data.length == 0)
     314           16 :         return null;
     315              : 
     316          133 :     const t_ticks = time_ticks(chart_data);
     317          133 :     const y_ticks = value_ticks(chart_data, config);
     318              : 
     319          133 :     function make_chart() {
     320          133 :         const w = container_size.width;
     321          133 :         const h = container_size.height;
     322              : 
     323          131 :         if (w == 0 || h == 0)
     324          133 :             return null;
     325              : 
     326          131 :         const x_off = t_ticks.start;
     327          131 :         const x_range = (t_ticks.end - t_ticks.start);
     328          131 :         const y_range = y_ticks.max;
     329              : 
     330          131 :         const tick_length = 5;
     331          131 :         const tick_gap = 3;
     332              : 
     333              :         // widest string plus gap plus tick
     334          131 :         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          131 :         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           13 :         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          133 :         const m_bottom = tick_length + tick_gap + 2 * Math.ceil(measure_size.height);
     345              : 
     346          130 :         function x_coord(x) {
     347          130 :             return (x - x_off) / x_range * (w - m_left - m_right) + m_left;
     348          130 :         }
     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          130 :         function y_coord(y) {
     355          130 :             return h - Math.max(y, 0) / y_range * (h - m_top - m_bottom) - m_bottom;
     356          130 :         }
     357              : 
     358          130 :         function cmd(op, x, y) {
     359          130 :             return op + x.toFixed() + "," + y.toFixed() + " ";
     360          130 :         }
     361              : 
     362          130 :         function path(data, hover_arg) {
     363          130 :             let d = cmd("M", m_left, h - m_bottom);
     364          130 :             for (let i = 0; i < data.length; i++) {
     365          130 :                 d += cmd("L", x_coord(data[i][0]), y_coord(data[i][1]));
     366          130 :             }
     367          130 :             d += cmd("L", w - m_right, h - m_bottom);
     368          130 :             d += "z";
     369              : 
     370          130 :             return (
     371          130 :                 <path key={hover_arg} d={d}
     372          130 :                       role="presentation">
     373          130 :                     <title>{hover_arg}</title>
     374          130 :                 </path>
     375              :             );
     376          130 :         }
     377              : 
     378          133 :         const paths = [];
     379          133 :         for (let i = chart_data.length - 1; i >= 0; i--)
     380           15 :             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           15 :         function cancel_dragging() {
     413           15 :             setSelection(null);
     414           15 :         }
     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          131 :         const hover_guard =
     421          131 :             <rect x={0} y={h - m_bottom - 1} width={w} height={5} fill="transparent" />;
     422              : 
     423          131 :         return (
     424          131 :             <svg width={w} height={h}
     425          131 :                  className="ct-plot"
     426          131 :                  aria-label={title}
     427              :                  // TODO: Figure out a way to handle a11y without entirely hiding the live-updating graphs
     428          131 :                  aria-hidden="true"
     429          131 :                  role="img"
     430           13 :                  onMouseDown={plot_state.zoom_state?.enable_zoom_in ? start_dragging : null}
     431           13 :                  onMouseUp={selection ? stop_dragging : null}
     432           13 :                  onMouseMove={selection ? drag : null}
     433          133 :                  onMouseLeave={cancel_dragging}>
     434          133 :                 <title>{title}</title>
     435          133 :                 <text x={0} y={-20} className="ct-plot-widest" ref={measure_ref} aria-hidden="true">{config.widest_string}</text>
     436          133 :                 <rect x={m_left} y={m_top} width={w - m_left - m_right} height={h - m_top - m_bottom}
     437          133 :                       className="ct-plot-border" />
     438          131 :                 { y_ticks.unit && <text x={m_left - tick_length - tick_gap} y={0.5 * m_top}
     439          131 :                                         className="ct-plot-unit"
     440          131 :                                         textAnchor="end">
     441          131 :                     {y_ticks.unit}
     442          131 :                 </text>
     443              :                 }
     444          131 :                 { title && <text x={m_left} y={0.5 * m_top} className="ct-plot-title">
     445          131 :                     {title}
     446          131 :                 </text>
     447              :                 }
     448          133 :                 <g className="ct-plot-lines" role="presentation">
     449          130 :                     { y_ticks.ticks.map((t, i) => <line key={i}
     450          130 :                                                         x1={m_left - tick_length} x2={w - m_right}
     451          130 :                                                         y1={y_coord(t)} y2={y_coord(t)} />) }
     452          133 :                 </g>
     453          133 :                 <g className="ct-plot-ticks" role="presentation">
     454          130 :                     { t_ticks.ticks.map((t, i) => <line key={i}
     455          130 :                                                         x1={x_coord(t)} x2={x_coord(t)}
     456          130 :                                                         y1={h - m_bottom} y2={h - m_bottom + tick_length} />) }
     457          133 :                 </g>
     458          133 :                 <g className="ct-plot-paths">
     459          133 :                     { paths }
     460          133 :                 </g>
     461          133 :                 { hover_guard }
     462          133 :                 <g className="ct-plot-axis ct-plot-axis-y" textAnchor="end">
     463          130 :                     { y_ticks.ticks.map((t, i) => <text key={i} x={m_left - tick_length - tick_gap} y={y_coord(t) + 5}>
     464          130 :                         {y_ticks.formatter(t)}
     465          130 :                     </text>) }
     466          133 :                 </g>
     467          133 :                 <g className="ct-plot-axis ct-plot-axis-x" textAnchor="middle">
     468          130 :                     { t_ticks.ticks.map((t, i) => <text key={i} y={h - m_bottom + tick_length + tick_gap}>
     469          130 :                         { t_ticks.formatter(t).split("\n")
     470          130 :                                 .map((s, j) =>
     471          130 :                                     <tspan key={i + "." + j} x={x_coord(t)} dy="1.2em">{s}</tspan>) }
     472          130 :                     </text>) }
     473          133 :                 </g>
     474          133 :                 { selection &&
     475           13 :                 <rect x={selection.left} y={m_top} width={selection.right - selection.left} height={h - m_top - m_bottom}
     476           13 :                         className="ct-plot-selection" /> }
     477          133 :             </svg>
     478              :         );
     479          133 :     }
     480              : 
     481          133 :     return (
     482          133 :         <div className={className} ref={container_ref}>
     483          133 :             {make_chart()}
     484          133 :         </div>);
     485          133 : };
     486              : 
     487          151 : export const bytes_config = {
     488          151 :     base_unit: 1024,
     489          151 :     min_max: 10240,
     490          151 :     pull_out_unit: true,
     491          151 :     widest_string: "MiB",
     492          151 :     formatter: cockpit.format_bytes
     493          151 : };
     494              : 
     495          151 : export const bytes_per_sec_config = {
     496          151 :     base_unit: 1024,
     497          151 :     min_max: 10240,
     498          151 :     pull_out_unit: true,
     499          151 :     widest_string: "MiB/s",
     500          151 :     formatter: cockpit.format_bytes_per_sec
     501          151 : };
     502              : 
     503          151 : export const bits_per_sec_config = {
     504          151 :     base_unit: 1000,
     505          151 :     min_max: 10000,
     506          151 :     pull_out_unit: true,
     507          151 :     widest_string: "Mbps",
     508          151 :     formatter: cockpit.format_bits_per_sec
     509          151 : };
        

Generated by: LCOV version 2.0-1