LCOV - code coverage report
Current view: top level - pkg/metrics - metrics.jsx Coverage Total Hit
Test: cockpit Lines: 96.6 % 1599 1544
Test Date: 2026-07-13 10:00:01

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2017 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           11 : import React, { useState, createRef } from 'react';
       7              : 
       8              : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
       9              : import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
      10              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      11              : import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js";
      12              : import { Gallery } from "@patternfly/react-core/dist/esm/layouts/Gallery/index.js";
      13              : import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
      14              : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
      15              : import { Grid, GridItem } from "@patternfly/react-core/dist/esm/layouts/Grid/index.js";
      16              : import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
      17              : import {
      18              :     Modal, ModalBody, ModalFooter, ModalHeader
      19              : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
      20              : import { Page, PageGroup, PageSection, PageBreadcrumb } from "@patternfly/react-core/dist/esm/components/Page/index.js";
      21              : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
      22              : import { Progress, ProgressVariant } from "@patternfly/react-core/dist/esm/components/Progress/index.js";
      23              : import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
      24              : import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
      25              : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
      26              : import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
      27              : import { Table, Thead, Td, Th, Tr, Tbody, TableGridBreakpoint, TableVariant, TableText } from '@patternfly/react-table';
      28              : import {
      29              :     AngleRightIcon, AngleDownIcon, ExclamationTriangleIcon, ExclamationCircleIcon, CogIcon, ExternalLinkAltIcon,
      30              :     ResourcesFullIcon, ResourcesAlmostFullIcon, ResourcesAlmostEmptyIcon
      31              : } from '@patternfly/react-icons';
      32              : 
      33           11 : import cockpit from 'cockpit';
      34              : import * as machine_info from "../lib/machine-info.js";
      35              : import * as service from "service";
      36              : import * as timeformat from "timeformat";
      37              : import { superuser } from "superuser";
      38              : import { journal } from "journal";
      39              : import { read_os_release } from "os-release";
      40              : import { get_manifest_config_matchlist } from "utils";
      41              : import { useObject, useEvent, useInit } from "hooks.js";
      42              : import { WithDialogs, useDialogs } from "dialogs.jsx";
      43              : 
      44              : import { SimpleSelect } from "cockpit-components-simple-select.jsx";
      45              : import { CheckboxSelect } from "cockpit-components-checkbox-select.jsx";
      46              : import { EmptyStatePanel } from "../lib/cockpit-components-empty-state.jsx";
      47              : import { JournalOutput } from "cockpit-components-logs-panel.jsx";
      48              : import { install_dialog } from "cockpit-components-install-dialog.jsx";
      49              : import { ModalError } from "cockpit-components-inline-notification.jsx";
      50              : import { FirewalldRequest } from "cockpit-components-firewalld-request.jsx";
      51              : 
      52              : import "./metrics.scss";
      53              : import "journal.css";
      54              : import { getPackageManager } from 'packagemanager.js';
      55              : 
      56           11 : const MSEC_PER_H = 3600000;
      57           11 : const INTERVAL = 5000;
      58           11 : const SAMPLES_PER_H = MSEC_PER_H / INTERVAL;
      59           11 : const SAMPLES_PER_MIN = SAMPLES_PER_H / 60;
      60           11 : const SVG_YMAX = (SAMPLES_PER_MIN - 1);
      61           11 : const LOAD_HOURS = 12;
      62           11 : const _ = cockpit.gettext;
      63              : 
      64              : // format Date as YYYY-MM-DD HH:mm:ss UTC which is human friendly and systemd compatible
      65            1 : const formatUTC_ISO = t => `${t.getUTCFullYear()}-${t.getUTCMonth() + 1}-${t.getUTCDate()} ${t.getUTCHours()}:${t.getUTCMinutes()}:${t.getUTCSeconds()} UTC`;
      66              : 
      67              : // podman's containers cgroup
      68           11 : const podmanCgroupRe = /libpod-(?<containerid>[a-z|0-9]{64})\.scope$/;
      69              : // cgroup userid
      70           11 : const useridCgroupRe = /user-(?<userid>\d+).slice/;
      71              : 
      72              : // keep track of maximum values for unbounded data, so that we can normalize it properly
      73              : // pre-init them to avoid inflating noise
      74           11 : let scaleSatCPU = 4;
      75           11 : let scaleUseDisks = 10000; // KB/s
      76           11 : let scaleUseNetwork = 100000; // B/s
      77              : 
      78           11 : let numCpu = 1;
      79           11 : let memTotal; // bytes
      80           11 : let swapTotal; // bytes, can be undefined
      81              : 
      82           11 : const machine_info_promise = machine_info.cpu_ram_info();
      83           11 : machine_info_promise.then(info => {
      84           11 :     numCpu = info.cpus;
      85           11 :     memTotal = info.memory;
      86           11 :     swapTotal = info.swap;
      87           11 : });
      88              : 
      89              : // round up to the nearest number that has all zeroes except for the first digit
      90              : // avoids over-aggressive scaling, but needs scaling more often
      91            3 : const scaleForValue = x => {
      92            3 :     const scale = Math.pow(10, Math.floor(Math.log10(x)));
      93              :     // this can be tweaked towards "less rescaling" with an additional scalar, like "x * 1.5 / scale"
      94            3 :     return Math.ceil(x / scale) * scale;
      95            3 : };
      96              : 
      97           11 : const RESOURCES = {
      98           11 :     use_cpu: {
      99           11 :         name: _("CPU usage"),
     100           11 :         event_description: _("CPU"),
     101              :         // all in msec/s
     102            4 :         normalize: ([nice, user, sys]) => (nice + user + sys) / 1000 / numCpu,
     103            0 :         format: ([nice, user, sys]) => `${_("nice")}: ${Math.round(nice / 10)}%, ${_("user")}: ${Math.round(user / 10)}%, ${_("sys")}: ${Math.round(sys / 10)}%`,
     104           11 :     },
     105           11 :     sat_cpu: {
     106           11 :         name: _("Load"),
     107           11 :         event_description: _("Load"),
     108              :         // unitless, unbounded, dynamic scaling for normalization
     109            4 :         normalize: load => Math.min(load, scaleSatCPU) / scaleSatCPU,
     110            0 :         format: load => cockpit.format_number(load),
     111           11 :     },
     112           11 :     use_memory: {
     113           11 :         name: _("Memory usage"),
     114           11 :         event_description: _("Memory"),
     115              :         // assume used == total - available
     116            4 :         normalize: ([totalKiB, availKiB]) => 1 - (availKiB / totalKiB),
     117            0 :         format: ([totalKiB, availKiB]) => `${cockpit.format_bytes((totalKiB - availKiB) * 1024)} / ${cockpit.format_bytes(totalKiB * 1024)}`,
     118           11 :     },
     119           11 :     sat_memory: {
     120           11 :         name: _("Swap out"),
     121           11 :         event_description: _("Swap"),
     122              :         // page/s, unbounded, and mostly 0; just categorize into "nothing" (most of the time),
     123              :         // "a little" (< 1000 pages), and "a lot" (> 1000 pages)
     124            1 :         normalize: swapout => swapout > 1000 ? 1 : (swapout > 1 ? 0.3 : 0),
     125            0 :         format: swapout => cockpit.format(cockpit.ngettext("$0 page", "$0 pages", Math.floor(swapout)), Math.floor(swapout)),
     126           11 :     },
     127           11 :     use_disks: {
     128           11 :         name: _("Disk I/O"),
     129           11 :         event_description: _("Disk I/O"),
     130              :         // KiB/s, unbounded, dynamic scaling for normalization
     131            4 :         normalize: KiBps => KiBps / scaleUseDisks,
     132            0 :         format: KiBps => cockpit.format_bytes_per_sec(KiBps * 1024),
     133           11 :     },
     134           11 :     use_network: {
     135           11 :         name: _("Network I/O"),
     136           11 :         event_description: _("Network I/O"),
     137              :         // B/s, unbounded, dynamic scaling for normalization
     138            5 :         normalize: bps => bps / scaleUseNetwork,
     139            0 :         format: bps => cockpit.format_bytes_per_sec(bps),
     140           11 :     },
     141           11 : };
     142              : 
     143           11 : const CURRENT_METRICS = [
     144           11 :     { name: "cpu.basic.user", derive: "rate" },
     145           11 :     { name: "cpu.basic.system", derive: "rate" },
     146           11 :     { name: "cpu.basic.nice", derive: "rate" },
     147           11 :     { name: "memory.used" },
     148           11 :     { name: "memory.swap-used" },
     149           11 :     { name: "disk.all.read", units: "bytes", derive: "rate" },
     150           11 :     { name: "disk.all.written", units: "bytes", derive: "rate" },
     151           11 :     { name: "network.interface.rx", units: "bytes", derive: "rate" },
     152           11 :     { name: "network.interface.tx", units: "bytes", derive: "rate" },
     153           11 :     { name: "cgroup.cpu.usage", derive: "rate" },
     154           11 :     { name: "cgroup.memory.usage" },
     155           11 :     { name: "cpu.core.user", derive: "rate" },
     156           11 :     { name: "cpu.core.system", derive: "rate" },
     157           11 :     { name: "cpu.core.nice", derive: "rate" },
     158           11 :     { name: "disk.dev.read", units: "bytes", derive: "rate" },
     159           11 :     { name: "disk.dev.written", units: "bytes", derive: "rate" },
     160           11 :     { name: "mount.total", units: "bytes" },
     161           11 :     { name: "mount.used", units: "bytes" },
     162           11 : ];
     163              : 
     164           11 : const CPU_TEMPERATURE_METRICS = [
     165           11 :     { name: "cpu.temperature" },
     166           11 : ];
     167              : 
     168           11 : const PRIVILEGED_METRICS = [
     169           11 :     { name: "disk.cgroup.read", units: "bytes", derive: "rate" },
     170           11 :     { name: "disk.cgroup.written", units: "bytes", derive: "rate" },
     171           11 : ];
     172              : 
     173           11 : const HISTORY_METRICS = [
     174              :     // CPU utilization
     175           11 :     { name: "kernel.all.cpu.nice", derive: "rate" },
     176           11 :     { name: "kernel.all.cpu.user", derive: "rate" },
     177           11 :     { name: "kernel.all.cpu.sys", derive: "rate" },
     178              : 
     179              :     // CPU saturation
     180           11 :     { name: "kernel.all.load" },
     181              : 
     182              :     // memory utilization (unit: KiB)
     183           11 :     { name: "mem.physmem" },
     184              :     // mem.util.used is useless, it includes cache (unit: KiB)
     185           11 :     { name: "mem.util.available" },
     186              : 
     187              :     // memory saturation
     188           11 :     { name: "swap.pagesout", derive: "rate" },
     189              : 
     190              :     // disk utilization; despite the name, the unit is in KiB! (pminfo -d -F disk.all.total_bytes)
     191           11 :     { name: "disk.all.total_bytes", derive: "rate" },
     192              : 
     193              :     // network utilization
     194           11 :     { name: "network.interface.total.bytes", derive: "rate" },
     195           11 : ];
     196              : 
     197           11 : function debug() {
     198            1 :     if (window.debugging == "all" || window.debugging?.includes("metrics"))
     199            1 :         console.debug.apply(console, arguments);
     200           11 : }
     201              : 
     202              : // metrics channel samples are compressed, see
     203              : // https://github.com/cockpit-project/cockpit/blob/main/doc/protocol.md#payload-metrics1
     204              : // samples is the compressed metrics channel value, state the last valid values (initialize once to empty array)
     205           11 : function decompress_samples(samples, state) {
     206           11 :     samples.forEach((sample, i) => {
     207           11 :         if (sample instanceof Array) {
     208           11 :             if (!state[i]) // uninitialized, create empty array
     209           11 :                 state[i] = [];
     210           11 :             sample.forEach((inst, k) => {
     211           11 :                 if (typeof inst === 'number')
     212           11 :                     state[i][k] = inst;
     213           11 :             });
     214           11 :         } else if (typeof sample === 'number') {
     215           11 :             state[i] = sample;
     216           11 :         }
     217           11 :     });
     218           11 : }
     219              : 
     220           11 : function make_rows(rows, rowProps, columnLabels) {
     221           11 :     return rows.map((columns, rowIndex) =>
     222           11 :         <Tr key={"row-" + rowIndex} {...rowProps?.(columns)}>
     223           11 :             {columns.map((column, columnIndex) =>
     224           11 :                 <Td data-label={columnLabels?.[columnIndex]} key={"column-" + columnIndex}>
     225           11 :                     {column}
     226           11 :                 </Td>
     227           11 :             )}
     228           11 :         </Tr>
     229           11 :     );
     230           11 : }
     231              : 
     232            1 : async function get_pcp_packages() {
     233            1 :     const os_release = await read_os_release();
     234            1 :     const pcp_packages = ["pcp"];
     235              : 
     236              :     // PCP contains the Python module on Arch Linux, for all other distro's it is split up.
     237            1 :     if (os_release.ID !== "arch") {
     238            1 :         pcp_packages.push("python3-pcp");
     239            1 :     }
     240              : 
     241            1 :     return pcp_packages;
     242            1 : }
     243              : 
     244           11 : class CurrentMetrics extends React.Component {
     245           11 :     constructor(props) {
     246           11 :         super(props);
     247              : 
     248           11 :         this.metrics_channel = null;
     249           11 :         this.temperature_channel = null;
     250           11 :         this.privileged_channel = null;
     251           11 :         this.samples = [];
     252           11 :         this.temperatureSamples = [];
     253           11 :         this.privilegedSamples = [];
     254           11 :         this.netInterfacesNames = [];
     255           11 :         this.cgroupCPUNames = [];
     256           11 :         this.cgroupMemoryNames = [];
     257           11 :         this.cgroupDiskNames = [];
     258           11 :         this.disksNames = [];
     259           11 :         this.cpuTemperature = null;
     260           11 :         this.cpuTemperatureColors = {
     261           11 :             textColor: "",
     262           11 :             iconColor: "",
     263           11 :             icon: null,
     264           11 :         };
     265              : 
     266           11 :         this.state = {
     267           11 :             userid: null,
     268           11 :             memUsed: 0, // bytes
     269           11 :             swapUsed: null, // bytes
     270           11 :             cpuUsed: 0, // percentage
     271           11 :             cpuCoresUsed: [], // [ percentage ]
     272           11 :             loadAvg: null, // [ 1min, 5min, 15min ]
     273           11 :             disksRead: 0, // B/s
     274           11 :             disksWritten: 0, // B/s
     275           11 :             mounts: [], // [{ target (string), use (percent), avail (bytes) }]
     276           11 :             netInterfacesRx: [],
     277           11 :             netInterfacesTx: [],
     278           11 :             topServicesCPU: [], // [ { name, percent } ]
     279           11 :             topServicesMemory: [], // [ { name, bytes } ]
     280           11 :             topServicesDiskIO: [], // [ [ name, read, write ] ]
     281           11 :             podNameMapping: {}, // { uid -> containerid -> name }
     282           11 :         };
     283              : 
     284           11 :         this.onVisibilityChange = this.onVisibilityChange.bind(this);
     285           11 :         this.onMetricsUpdate = this.onMetricsUpdate.bind(this);
     286           11 :         this.onTemperatureUpdate = this.onTemperatureUpdate.bind(this);
     287           11 :         this.onPrivilegedMetricsUpdate = this.onPrivilegedMetricsUpdate.bind(this);
     288           11 :         this.updateLoad = this.updateLoad.bind(this);
     289              : 
     290           11 :         cockpit.addEventListener("visibilitychange", this.onVisibilityChange);
     291           11 :         this.onVisibilityChange();
     292              : 
     293              :         // there is no internal metrics channel for load yet; see https://github.com/cockpit-project/cockpit/pull/14510
     294           11 :         this.updateLoad();
     295           11 :     }
     296              : 
     297           11 :     componentDidMount() {
     298           11 :         superuser.addEventListener("changed", () => this.setState({ podNameMapping: {} }));
     299           11 :         cockpit.user().then(user => this.setState({ userid: user.id }));
     300           11 :     }
     301              : 
     302           12 :     onVisibilityChange() {
     303            8 :         if (cockpit.hidden && this.temperature_channel !== null) {
     304            8 :             this.temperature_channel.removeEventListener("message", this.onTemperatureUpdate);
     305            8 :             this.temperature_channel.close();
     306            8 :             this.temperature_channel = null;
     307            8 :         }
     308              : 
     309            8 :         if (cockpit.hidden && this.privileged_channel !== null) {
     310            8 :             this.privileged_channel.removeEventListener("message", this.onPrivilegedMetricsUpdate);
     311            8 :             this.privileged_channel.close();
     312            8 :             this.privileged_channel = null;
     313            8 :         }
     314              : 
     315            8 :         if (cockpit.hidden && this.metrics_channel !== null) {
     316            8 :             this.metrics_channel.removeEventListener("message", this.onMetricsUpdate);
     317            8 :             this.metrics_channel.close();
     318            8 :             this.metrics_channel = null;
     319            8 :             return;
     320            8 :         }
     321              : 
     322           12 :         if (!cockpit.hidden && (this.temperature_channel === null)) {
     323           12 :             this.temperature_channel = cockpit.channel({ payload: "metrics1", source: "internal", interval: INTERVAL, metrics: CPU_TEMPERATURE_METRICS });
     324           11 :             this.temperature_channel.addEventListener("close", (ev, error) => console.warn("CPU temperature metric closed:", error));
     325           12 :             this.temperature_channel.addEventListener("message", this.onTemperatureUpdate);
     326           12 :         }
     327              : 
     328              :         // requires sudo access in order to sample every cgroup
     329              :         // limited access only allows sampling of current user's cgroups
     330           12 :         if (!cockpit.hidden && (this.privileged_channel === null)) {
     331           12 :             this.privileged_channel = cockpit.channel({ superuser: "try", payload: "metrics1", source: "internal", interval: INTERVAL, metrics: PRIVILEGED_METRICS });
     332           12 :             this.privileged_channel.addEventListener("message", this.onPrivilegedMetricsUpdate);
     333           12 :         }
     334              : 
     335           12 :         if (!cockpit.hidden && this.metrics_channel === null) {
     336           12 :             this.metrics_channel = cockpit.channel({ payload: "metrics1", source: "internal", interval: INTERVAL, metrics: CURRENT_METRICS });
     337           12 :             this.metrics_channel.addEventListener("message", this.onMetricsUpdate);
     338           12 :         }
     339           12 :     }
     340              : 
     341           11 :     updateLoad() {
     342           11 :         cockpit.file("/proc/loadavg").read()
     343           11 :                 .then(content => {
     344              :                     // format: three load averages, then process counters; e.g.: 0.67 1.00 0.78 2/725 87151
     345           11 :                     this.setState({ loadAvg: content.split(' ').slice(0, 3) });
     346              :                     // update it again regularly
     347           11 :                     window.setTimeout(this.updateLoad, 5000);
     348           11 :                 })
     349            0 :                 .catch(ex => {
     350            0 :                     console.warn("Failed to read /proc/loadavg:", ex.toString());
     351            0 :                     this.setState({ loadAvg: null });
     352            0 :                 });
     353           11 :     }
     354              : 
     355           11 :     onTemperatureUpdate(event, message) {
     356           11 :         debug("current CPU temperature  message", message);
     357           11 :         const data = JSON.parse(message);
     358              : 
     359           11 :         if (!Array.isArray(data)) {
     360           11 :             return;
     361           11 :         }
     362              : 
     363           11 :         data.forEach(temperatureSamples => decompress_samples(temperatureSamples, this.temperatureSamples));
     364              : 
     365            2 :         if (this.temperatureSamples[0].length > 0) {
     366            2 :             this.cpuTemperature = Math.round(Math.max(...this.temperatureSamples[0]));
     367            1 :         } else {
     368              :             // close the channel when bridge couldn't sample temperature
     369           10 :             this.temperature_channel.close("No samples received");
     370           10 :             return;
     371           10 :         }
     372              : 
     373            2 :         if (this.cpuTemperature <= 80) {
     374            2 :             this.cpuTemperatureColors.textColor = "";
     375            2 :             this.cpuTemperatureColors.iconColor = "";
     376            2 :             this.cpuTemperatureColors.icon = null;
     377            2 :         } else if (this.cpuTemperature < 95) {
     378            2 :             this.cpuTemperatureColors.textColor = "text-color-warning";
     379            2 :             this.cpuTemperatureColors.iconColor = "icon-color-warning";
     380            2 :             this.cpuTemperatureColors.icon = <ExclamationTriangleIcon />;
     381            1 :         } else {
     382            1 :             this.cpuTemperatureColors.textColor = "text-color-critical";
     383            1 :             this.cpuTemperatureColors.iconColor = "icon-color-critical";
     384            1 :             this.cpuTemperatureColors.icon = <ExclamationCircleIcon />;
     385            1 :         }
     386           11 :     }
     387              : 
     388           11 :     onMetricsUpdate(event, message) {
     389           11 :         debug("current metrics message", message);
     390           11 :         const data = JSON.parse(message);
     391              : 
     392              :         // reset state on meta messages
     393           11 :         if (!Array.isArray(data)) {
     394           11 :             this.samples = [];
     395           11 :             console.assert(data.metrics[7].name === 'network.interface.rx');
     396           11 :             this.netInterfacesNames = data.metrics[7].instances.slice();
     397           11 :             console.assert(data.metrics[9].name === 'cgroup.cpu.usage');
     398           11 :             this.cgroupCPUNames = data.metrics[9].instances.slice();
     399           11 :             this.cgroupMemoryNames = data.metrics[10].instances.slice();
     400           11 :             console.assert(data.metrics[14].name === 'disk.dev.read');
     401           11 :             this.disksNames = data.metrics[14].instances.slice();
     402           11 :             console.assert(data.metrics[16].name === 'mount.total');
     403           11 :             this.mountPoints = data.metrics[16].instances.slice();
     404           11 :             debug("metrics message was meta, new net instance names", JSON.stringify(this.netInterfacesNames));
     405           11 :             return;
     406           11 :         }
     407              : 
     408           11 :         data.forEach(samples => decompress_samples(samples, this.samples));
     409              : 
     410           11 :         const newState = {};
     411              :         // CPU metrics are in ms/s; divide by 10 to get percentage
     412            9 :         if (typeof this.samples[0] === 'number') {
     413            9 :             const cpu = Math.round((this.samples[0] + this.samples[1] + this.samples[2]) / 10 / numCpu);
     414            9 :             newState.cpuUsed = cpu;
     415            9 :         }
     416              : 
     417           11 :         newState.memUsed = this.samples[3];
     418           11 :         newState.swapUsed = this.samples[4];
     419              : 
     420           11 :         if (typeof this.samples[5] === 'number')
     421            9 :             newState.disksRead = this.samples[5];
     422           11 :         if (typeof this.samples[6] === 'number')
     423            9 :             newState.disksWritten = this.samples[6];
     424              : 
     425           11 :         newState.netInterfacesRx = this.samples[7];
     426           11 :         newState.netInterfacesTx = this.samples[8];
     427              : 
     428              :         // Collect CPU cores
     429           11 :         newState.cpuCoresUsed = [];
     430           11 :         if (this.samples[11] && this.samples[11].length == this.samples[12].length && this.samples[12].length == this.samples[13].length) {
     431            9 :             for (let i = 0; i < this.samples[11].length; i++) {
     432              :                 // CPU cores metrics are in ms/s; divide by 10 to get percentage
     433            9 :                 newState.cpuCoresUsed[i] = Math.round((this.samples[11][i] + this.samples[12][i] + this.samples[13][i]) / 10);
     434            9 :             }
     435           11 :         }
     436              : 
     437              :         // return [ { [key, value, is_user, is_container, userid | cgroup] } ] list of the biggest n values
     438           11 :         const n_biggest = (names, values, n) => {
     439           11 :             const merged = [];
     440           11 :             names.forEach((k, i) => {
     441           11 :                 const v = values[i];
     442              :                 // filter out invalid values, the empty (root) cgroup, non-services
     443           11 :                 if (k.endsWith('.service') && typeof v === 'number' && v != 0) {
     444           11 :                     const is_user = k.match(/^user.*user@\d+\.service.+/);
     445           11 :                     const label = k.replace(/.*\//, '').replace(/\.service$/, '');
     446              :                     // only keep cgroup basenames, and drop redundant .service suffix
     447           11 :                     merged.push([label, v, is_user, false, k]);
     448           11 :                 }
     449              :                 // filter out podman containers, but only for the logged in
     450              :                 // user or root user if the user is privileged. Other users
     451              :                 // containers will show up under the user@uid cgroup
     452           11 :                 const matches = k.match(podmanCgroupRe);
     453            2 :                 if (matches && v) {
     454            2 :                     let is_user = false;
     455            2 :                     let uid = 0;
     456            2 :                     const containerid = matches.groups.containerid;
     457            2 :                     const umatches = k.match(useridCgroupRe);
     458            2 :                     if (umatches) {
     459            2 :                         is_user = true;
     460            2 :                         uid = parseInt(umatches.groups.userid);
     461            2 :                     }
     462              : 
     463            2 :                     if (uid === 0 || this.state.userid == uid) {
     464            2 :                         merged.push([containerid, v, is_user, true, uid]);
     465            2 :                     }
     466            2 :                 }
     467           11 :             });
     468           11 :             merged.sort((a, b) => b[1] - a[1]);
     469           11 :             return merged.slice(0, n);
     470           11 :         };
     471              : 
     472              :         // top 5 CPU and memory consuming systemd units
     473           11 :         const topServicesCPU = n_biggest(this.cgroupCPUNames, this.samples[9], 5);
     474           11 :         newState.topServicesCPU = topServicesCPU.map(
     475            8 :             ([key, value, is_user, is_container, userid]) => this.cgroupRow(key, is_user, is_container, userid, Number(value / 10 / numCpu).toFixed(1)) // usec/s → percent
     476           11 :         );
     477              : 
     478           11 :         const topServicesMemory = n_biggest(this.cgroupMemoryNames, this.samples[10], 5);
     479           11 :         newState.topServicesMemory = topServicesMemory.map(
     480           11 :             ([key, value, is_user, is_container, userid]) => this.cgroupRow(key, is_user, is_container, userid, cockpit.format_bytes(value))
     481           11 :         );
     482              : 
     483            2 :         const notMappedContainers = topServicesMemory.concat(topServicesCPU).filter(([key, value, is_user, is_container, userid]) => is_container && this.getCachedPodName(userid, key) === undefined);
     484            2 :         if (notMappedContainers.length !== 0) {
     485            2 :             this.update_podman_name_mapping(notMappedContainers);
     486            2 :         }
     487              : 
     488           11 :         const mountsTotal = this.samples[16];
     489           11 :         const mountsUsed = this.samples[17];
     490           11 :         newState.mounts = mountsTotal.map((mountTotal, i) => {
     491           11 :             return {
     492           11 :                 target: this.mountPoints[i],
     493           11 :                 size: mountTotal,
     494           11 :                 avail: mountTotal - mountsUsed[i],
     495           11 :                 use: Math.round(mountsUsed[i] / mountTotal * 100),
     496           11 :             };
     497           11 :         });
     498              : 
     499           11 :         this.setState(newState);
     500           11 :     }
     501              : 
     502           11 :     onPrivilegedMetricsUpdate(event, message) {
     503           11 :         debug("process metrics message", message);
     504           11 :         const data = JSON.parse(message);
     505              : 
     506           11 :         if (!Array.isArray(data)) {
     507           11 :             this.cgroupDiskNames = data.metrics[0].instances.slice();
     508           11 :             return;
     509           11 :         }
     510              : 
     511           11 :         data.forEach(privilegedSamples => decompress_samples(privilegedSamples, this.privilegedSamples));
     512              : 
     513              :         // return [ name, read, write, isUser, isContainer, uid | cgroup ]
     514           11 :         const n_biggest = (n, names, valuesA, valuesB) => {
     515           11 :             const merged = [];
     516           11 :             const userSlices = {};
     517           11 :             names.forEach((name, i) => {
     518              :                 // filter out invalid values, the empty (root) cgroup, non-services
     519           11 :                 if (name.endsWith('.service')) {
     520              :                     // only keep cgroup basenames, and drop redundant .service suffix
     521           11 :                     const label = name.replace(/.*\//, '').replace(/\.service$/, '');
     522           11 :                     const isUser = name.match(useridCgroupRe);
     523           11 :                     merged.push([label, valuesA[i], valuesB[i], isUser, false, name]);
     524           11 :                     return;
     525           11 :                 }
     526              : 
     527              :                 // filter out podman containers, but only for the logged in
     528              :                 // user or root user if the user is privileged. Other users
     529              :                 // containers will show up under the user@uid cgroup
     530           11 :                 const matches = name.match(/libpod-(?<containerid>[a-z|0-9]{64})\.scope/);
     531            2 :                 if (matches && valuesA[i] !== undefined && valuesB[i] !== undefined) {
     532            2 :                     const containerid = matches.groups.containerid;
     533            2 :                     const umatches = name.match(useridCgroupRe);
     534            2 :                     const isUser = !!umatches;
     535            2 :                     const uid = parseInt(umatches?.groups.userid) || 0;
     536              : 
     537            2 :                     if (uid === 0 || this.state.userid == uid) {
     538            2 :                         merged.push([containerid, valuesA[i], valuesB[i], isUser, true, uid]);
     539            2 :                         return;
     540            2 :                     }
     541            2 :                 }
     542              : 
     543              :                 // combine user slices into user@ID
     544              :                 // { name: [ read, write ] }
     545           11 :                 const umatches = name.match(useridCgroupRe);
     546           11 :                 if (umatches) {
     547           11 :                     if (userSlices[umatches.groups.userid] === undefined) {
     548           11 :                         userSlices[umatches.groups.userid] = [valuesA[i], valuesB[i]];
     549           11 :                     } else {
     550           11 :                         userSlices[umatches.groups.userid][0] += valuesA[i];
     551           11 :                         userSlices[umatches.groups.userid][1] += valuesB[i];
     552           11 :                     }
     553           11 :                 }
     554           11 :             });
     555              : 
     556           11 :             Object.keys(userSlices).forEach((key) => {
     557           11 :                 merged.push(["user@" + key, userSlices[key][0], userSlices[key][1], false, false]);
     558           11 :             });
     559              : 
     560              :             // sort by overall (read + write) disk usage
     561           11 :             merged.sort((a, b) => (b[1] + b[2]) - (a[1] + a[2]));
     562           11 :             return merged.slice(0, n);
     563           11 :         };
     564              : 
     565           11 :         const newState = {};
     566              : 
     567           11 :         const topServicesDiskIO = n_biggest(5, this.cgroupDiskNames, this.privilegedSamples[0], this.privilegedSamples[1]);
     568            9 :         newState.topServicesDiskIO = topServicesDiskIO.filter(([_, read, write, ..._rest]) => read !== 0 || write !== 0).map(([name, read, write, isUser, isContainer, uid]) => {
     569            6 :             return this.cgroupRow(name, isUser, isContainer, uid, read > 1 ? cockpit.format_bytes_per_sec(read) : 0, write > 1 ? cockpit.format_bytes_per_sec(write) : 0);
     570           11 :         });
     571              : 
     572           11 :         this.setState(newState);
     573           11 :     }
     574              : 
     575            1 :     getCachedPodName = (uid, containerid) => this.state.podNameMapping[uid] && this.state.podNameMapping[uid][containerid];
     576              : 
     577           11 :     cgroupRow = (name, is_user, is_container, uid, ...values) => {
     578           11 :         const podman_installed = cockpit.manifests?.podman;
     579              : 
     580            2 :         const cgroupClickHandler = (name, isUser, isContainer, uid) => {
     581            0 :             if (isContainer) {
     582            0 :                 const containerName = this.getCachedPodName(uid, name);
     583            0 :                 if (containerName) {
     584            0 :                     cockpit.jump("/podman#/?name=" + containerName);
     585            0 :                 } else {
     586            0 :                     cockpit.jump("/podman");
     587            0 :                 }
     588            0 :             } else {
     589            1 :                 cockpit.jump("/system/services#/" + name + ".service" + (isUser ? "?owner=user" : ""));
     590            2 :             }
     591            2 :         };
     592              : 
     593           11 :         let name_text = (
     594           11 :             <Button variant="link" isInline isBlock component="a" key={name}
     595            2 :                     onClick={() => cgroupClickHandler(name, is_user, is_container, uid)}
     596            2 :                     isDisabled={is_container && !podman_installed}>
     597           11 :                 <TableText wrapModifier="truncate">
     598            2 :                     {is_container ? _("pod") + " " + (this.getCachedPodName(uid, name) || name.substring(0, 12)) : name}
     599           11 :                 </TableText>
     600           11 :             </Button>
     601              :         );
     602            2 :         if (is_container && !podman_installed) {
     603            2 :             name_text = (
     604            2 :                 <Tooltip content={_("cockpit-podman is not installed")} key={name + "_tooltip"}>
     605            2 :                     <div>
     606            2 :                         {name_text}
     607            2 :                     </div>
     608            2 :                 </Tooltip>);
     609            2 :         }
     610              : 
     611           11 :         const values_text = values.map((value, idx) => {
     612           11 :             return <TableText key={idx} wrapModifier="nowrap">{value}</TableText>;
     613           11 :         });
     614              : 
     615           11 :         return [name_text, ...values_text];
     616           11 :     };
     617              : 
     618              :     /**
     619              :      * Look up the container names using podman ps for the given cgroups.
     620              :      */
     621            1 :     update_podman_name_mapping = cgroups => {
     622              :         // New mapping state
     623            1 :         const podNameMapping = {};
     624              : 
     625            1 :         const promises = cgroups.map(([containerid, value, is_user, is_container, userid]) => {
     626            1 :             if (!(userid in podNameMapping)) {
     627            1 :                 podNameMapping[userid] = {};
     628            1 :             }
     629              :             // Always initialize the cache for when we hit an error.
     630            1 :             podNameMapping[userid][containerid] = null;
     631              : 
     632            0 :             if ((userid === 0 && !superuser.allowed) && userid !== this.state.userid) {
     633            0 :                 return null;
     634            0 :             }
     635            1 :             return cockpit.spawn(["podman", "ps", "--format", "json"], { superuser: userid === 0 ? "required" : null })
     636            1 :                     .then(result => [result, userid]);
     637            1 :         }).filter(prom => prom !== null);
     638              : 
     639            1 :         Promise.all(promises).then(results => {
     640            1 :             for (const [output, uid] of results) {
     641            1 :                 try {
     642            1 :                     const containers = JSON.parse(output);
     643            1 :                     for (const container of containers) {
     644            1 :                         podNameMapping[uid][container.Id] = container.Names[0];
     645            1 :                     }
     646            0 :                 } catch (err) {
     647            0 :                     console.error("podman ps outputs invalid JSON", err.toString());
     648            0 :                 }
     649            1 :             }
     650            1 :         })
     651            0 :                 .catch(err => console.error("could not obtain podman names:", err))
     652            1 :                 .finally(() => this.setState(prevState => ({ podNameMapping: { ...prevState.podNameMapping, ...podNameMapping } })));
     653            1 :     };
     654              : 
     655           11 :     render() {
     656           11 :         const memUsedFraction = memTotal ? this.state.memUsed / memTotal : 0;
     657           11 :         const memAvail = memTotal ? (memTotal - this.state.memUsed) : 0;
     658           11 :         const num_cpu_str = cockpit.format(cockpit.ngettext("$0 CPU", "$0 CPUs", numCpu), numCpu);
     659              : 
     660           11 :         const netIO = this.netInterfacesNames.map((iface, i) => [
     661            1 :             <Button variant="link" isInline onClick={() => cockpit.jump(`/network#/${iface}`) } key={iface}>{iface}</Button>,
     662            9 :             this.state.netInterfacesRx[i] >= 1 ? cockpit.format_bytes_per_sec(this.state.netInterfacesRx[i]) : "0",
     663            9 :             this.state.netInterfacesTx[i] >= 1 ? cockpit.format_bytes_per_sec(this.state.netInterfacesTx[i]) : "0",
     664           11 :         ]);
     665              : 
     666           11 :         let swapProgress;
     667              : 
     668           11 :         if (swapTotal) {
     669           11 :             const swapUsedFraction = this.state.swapUsed / swapTotal;
     670           11 :             const swapAvail = swapTotal - this.state.swapUsed;
     671           11 :             swapProgress = (
     672           11 :                 <Tooltip content={ cockpit.format(_("$0 total"), cockpit.format_bytes(swapTotal)) } position="bottom">
     673           11 :                     <Progress
     674           11 :                         id="current-swap-usage"
     675           11 :                         title={ _("Swap") }
     676           11 :                         value={this.state.swapUsed}
     677           11 :                         className="pf-m-sm"
     678           11 :                         min={0} max={swapTotal}
     679            1 :                         variant={swapUsedFraction > 0.9 ? ProgressVariant.danger : swapUsedFraction >= 0.8 ? ProgressVariant.warning : null}
     680           11 :                         label={ cockpit.format(_("$0 available"), cockpit.format_bytes(swapAvail)) } />
     681           11 :                 </Tooltip>);
     682           11 :         }
     683              : 
     684           11 :         let cores = null;
     685           11 :         let topCore = null;
     686           11 :         let allCpus = null;
     687           11 :         let cpu_label = null;
     688            2 :         if (this.state.cpuCoresUsed.length > 1) {
     689            1 :             const top_cores = this.state.cpuCoresUsed.map((v, i) => [i, v]).sort((a, b) => b[1] - a[1])
     690            2 :                     .slice(0, 16);
     691            2 :             cores = (<Grid className='cpu-all' component='dl'>
     692            1 :                 {top_cores.map(c =>
     693            1 :                     <React.Fragment key={c[0]}>
     694            1 :                         <GridItem component='dt'>{ cockpit.format(_("Core $0"), c[0]) }</GridItem>
     695            1 :                         <GridItem component='dd'>{c[1]}%</GridItem>
     696            1 :                     </React.Fragment>)
     697              :                 }
     698            2 :             </Grid>);
     699              : 
     700            2 :             cpu_label = (
     701            2 :                 <Flex spaceItems={{ default: 'spaceItemsNone' }} justifyContent={{ default: 'justifyContentFlexEnd' }}>
     702            2 :                     <FlexItem>&nbsp;{ cockpit.format(_("average: $0%"), this.state.cpuUsed) }</FlexItem>
     703            2 :                     <FlexItem>&nbsp;{ cockpit.format(_("max: $0%"), top_cores[0][1]) }</FlexItem>
     704            2 :                 </Flex>);
     705              : 
     706            2 :             topCore = <Progress
     707            2 :                            aria-label={_("Current top CPU usage")}
     708            2 :                            id="current-top-cpu-usage"
     709            2 :                            value={top_cores[0][1]}
     710            2 :                            className="current-top-cpu-usage pf-m-sm"
     711            2 :                            min={0} max={100}
     712            1 :                            variant={ top_cores[0][1] > 90 ? ProgressVariant.danger : top_cores[0][1] >= 80 ? ProgressVariant.warning : ProgressVariant.info }
     713            2 :                            measureLocation="none" />;
     714              : 
     715            2 :             allCpus = (
     716            2 :                 <Popover minWidth={0} aria-label={ _("View all CPUs") } bodyContent={cores}>
     717            2 :                     <Button variant="link" className='pf-v6-u-font-size-sm'>{ _("View all CPUs") }</Button>
     718            2 :                 </Popover>);
     719            2 :         } else {
     720           11 :             cpu_label = this.state.cpuUsed + '%';
     721           11 :         }
     722              : 
     723           11 :         const disksUsage = (this.disksNames.length > 0 && this.samples[14] && this.samples[15])
     724              :             ? (
     725           11 :                 this.disksNames.map((name, i) => [
     726           11 :                     name,
     727            8 :                     this.samples[14][i] >= 1 ? cockpit.format_bytes_per_sec(this.samples[14][i]) : "0",
     728            8 :                     this.samples[15][i] >= 1 ? cockpit.format_bytes_per_sec(this.samples[15][i]) : "0",
     729           11 :                 ])
     730              :             )
     731           11 :             : [];
     732              : 
     733           11 :         let allDisks = null;
     734           11 :         const rowPropsDisks = row => ({ 'device-name': row[0] });
     735           11 :         const diskColumns = [_("Device"), _("Read"), _("Write")];
     736           11 :         if (disksUsage.length > 1) {
     737           11 :             const disksTableContent = (
     738           11 :                 <Table
     739           11 :                     variant={TableVariant.compact}
     740           11 :                     gridBreakPoint={TableGridBreakpoint.gridLg}
     741           11 :                     borders={false}
     742           11 :                     aria-label={ _("Disks usage") }>
     743           11 :                     <Thead>
     744           11 :                         <Tr>{diskColumns.map(col => <Th key={col}>{col}</Th>)}</Tr>
     745           11 :                     </Thead>
     746           11 :                     <Tbody className="pf-v6-m-tabular-nums disks-nowrap">
     747           11 :                         {make_rows(disksUsage, rowPropsDisks, diskColumns)}
     748           11 :                     </Tbody>
     749           11 :                 </Table>
     750              :             );
     751              : 
     752           11 :             allDisks = (
     753           11 :                 <Popover minWidth={0} aria-label={ _("View all disks") } bodyContent={disksTableContent}>
     754           11 :                     <Button variant="link" className='pf-v6-u-font-size-sm'>{ _("View per-disk throughput") }</Button>
     755           11 :                 </Popover>
     756              :             );
     757           11 :         }
     758              : 
     759              :         // first element is the jump button, key is interface name
     760           11 :         const rowPropsIface = row => ({ 'data-interface': row[0].key });
     761           11 :         const rowPropsDiskIO = row => ({ 'cgroup-name': row[0] });
     762           11 :         const topServicesCPUColumns = [_("Service"), "%"];
     763           11 :         const topServicesMemoryColumns = [_("Service"), _("Used")];
     764           11 :         const ifaceColumns = [_("Interface"), _("In"), _("Out")];
     765              : 
     766           11 :         return (
     767           11 :             <Gallery className="current-metrics" hasGutter>
     768           11 :                 <Card id="current-metrics-card-cpu">
     769           11 :                     <CardHeader className='align-baseline'>
     770           11 :                         <CardTitle>{ _("CPU") }</CardTitle>
     771           11 :                         { this.cpuTemperature !== null &&
     772            2 :                         <span className="temperature">
     773            2 :                             <span className={this.cpuTemperatureColors.iconColor}>
     774            2 :                                 {this.cpuTemperatureColors.icon}
     775            2 :                             </span>
     776              :                             &nbsp;
     777            2 :                             <span className={this.cpuTemperatureColors.textColor}>
     778            2 :                                 { cockpit.format("$0 °C", this.cpuTemperature) }
     779            2 :                             </span>
     780            2 :                         </span> }
     781           11 :                     </CardHeader>
     782           11 :                     <CardBody>
     783           11 :                         <div className="progress-stack-no-space">
     784           11 :                             <Progress
     785           11 :                                 id="current-cpu-usage"
     786           11 :                                 value={this.state.cpuUsed}
     787           11 :                                 className="current-cpu-usage pf-m-sm"
     788           11 :                                 min={0} max={100}
     789            3 :                                 variant={ this.state.cpuUsed > 90 ? ProgressVariant.danger : this.state.cpuUsed >= 80 ? ProgressVariant.warning : null }
     790           11 :                                 title={ num_cpu_str }
     791           11 :                                 label={ cpu_label } />
     792           11 :                             {topCore}
     793           11 :                             {allCpus}
     794           11 :                         </div>
     795              : 
     796           11 :                         { this.state.loadAvg &&
     797           11 :                             <DescriptionList className="pf-m-horizontal-on-sm">
     798           11 :                                 <DescriptionListGroup>
     799           11 :                                     <DescriptionListTerm>{ _("Load") }</DescriptionListTerm>
     800           11 :                                     <DescriptionListDescription id="load-avg">
     801           11 :                                         <Flex spaceItems={{ default: 'spaceItemsXs' }}>
     802           11 :                                             <FlexItem>{ _("1 min") }:&nbsp;{ this.state.loadAvg[0] },</FlexItem>
     803           11 :                                             <FlexItem>{ _("5 min") }:&nbsp;{ this.state.loadAvg[1] },</FlexItem>
     804           11 :                                             <FlexItem>{ _("15 min") }:&nbsp;{ this.state.loadAvg[2] }</FlexItem>
     805           11 :                                         </Flex>
     806           11 :                                     </DescriptionListDescription>
     807           11 :                                 </DescriptionListGroup>
     808           11 :                             </DescriptionList> }
     809              : 
     810           11 :                         { this.state.topServicesCPU.length > 0 &&
     811            9 :                             <Table
     812            9 :                                 variant={TableVariant.compact}
     813            9 :                                 gridBreakPoint={TableGridBreakpoint.none}
     814            9 :                                 borders={false}
     815            9 :                                 aria-label={ _("Top 5 CPU services") }>
     816            9 :                                 <Thead>
     817            9 :                                     <Tr>
     818            9 :                                         <Th width={80}>{_("Service")}</Th>
     819            9 :                                         <Th>%</Th>
     820            9 :                                     </Tr>
     821            9 :                                 </Thead>
     822            9 :                                 <Tbody>
     823            9 :                                     {make_rows(this.state.topServicesCPU, undefined, topServicesCPUColumns)}
     824            9 :                                 </Tbody>
     825            9 :                             </Table> }
     826           11 :                     </CardBody>
     827           11 :                 </Card>
     828              : 
     829           11 :                 <Card>
     830           11 :                     <CardTitle>{ _("Memory") }</CardTitle>
     831           11 :                     <CardBody>
     832           11 :                         <div className="progress-stack">
     833           11 :                             <Tooltip
     834           11 :                                 content={ cockpit.format(_("$0 total"), cockpit.format_bytes(memTotal)) }
     835           11 :                                 position="bottom">
     836           11 :                                 <Progress
     837           11 :                                     id="current-memory-usage"
     838           11 :                                     title={ _("RAM") }
     839           11 :                                     value={memTotal ? this.state.memUsed : undefined}
     840           11 :                                     className="pf-m-sm"
     841           11 :                                     min={0} max={memTotal}
     842            1 :                                     variant={memUsedFraction > 0.9 ? ProgressVariant.danger : memUsedFraction >= 0.8 ? ProgressVariant.warning : null}
     843           11 :                                     label={ memAvail ? cockpit.format(_("$0 available"), cockpit.format_bytes(memAvail)) : "" } />
     844           11 :                             </Tooltip>
     845           11 :                             {swapProgress}
     846           11 :                         </div>
     847              : 
     848           11 :                         { this.state.topServicesMemory.length > 0 &&
     849           11 :                             <Table
     850           11 :                                 variant={TableVariant.compact}
     851           11 :                                 gridBreakPoint={TableGridBreakpoint.none}
     852           11 :                                 borders={false}
     853           11 :                                 aria-label={ _("Top 5 memory services") }>
     854           11 :                                 <Thead>
     855           11 :                                     <Tr>
     856           11 :                                         <Th width={80}>{_("Service")}</Th>
     857           11 :                                         <Th>{_("Used")}</Th>
     858           11 :                                     </Tr>
     859           11 :                                 </Thead>
     860           11 :                                 <Tbody>
     861           11 :                                     {make_rows(this.state.topServicesMemory, undefined, topServicesMemoryColumns)}
     862           11 :                                 </Tbody>
     863           11 :                             </Table> }
     864           11 :                     </CardBody>
     865           11 :                 </Card>
     866              : 
     867           11 :                 <Card id="current-metrics-card-disks">
     868           11 :                     <CardTitle>{ _("Disks") }</CardTitle>
     869           11 :                     <CardBody>
     870           11 :                         <DescriptionList isHorizontal columnModifier={{ default: '2Col' }}>
     871           11 :                             <DescriptionListGroup>
     872           11 :                                 <DescriptionListTerm>{ _("Read") }</DescriptionListTerm>
     873            8 :                                 <DescriptionListDescription id="current-disks-read">{ this.state.disksRead >= 1 ? cockpit.format_bytes_per_sec(this.state.disksRead) : "0" }</DescriptionListDescription>
     874           11 :                             </DescriptionListGroup>
     875           11 :                             <DescriptionListGroup>
     876           11 :                                 <DescriptionListTerm>{ _("Write") }</DescriptionListTerm>
     877            8 :                                 <DescriptionListDescription id="current-disks-write">{ this.state.disksWritten >= 1 ? cockpit.format_bytes_per_sec(this.state.disksWritten) : "0" }</DescriptionListDescription>
     878           11 :                             </DescriptionListGroup>
     879           11 :                         </DescriptionList>
     880           11 :                         <div className="all-disks-no-gap">
     881           11 :                             {allDisks}
     882           11 :                         </div>
     883           11 :                         <div id="current-disks-usage" className="progress-stack"> {
     884           11 :                             this.state.mounts.map(info => {
     885           11 :                                 let progress = (
     886           11 :                                     <Progress
     887           11 :                                         data-disk-usage-target={info.target}
     888           11 :                                         value={info.use} min={0} max={100}
     889           11 :                                         className="pf-m-sm"
     890            1 :                                         variant={info.use > 90 ? ProgressVariant.danger : info.use >= 80 ? ProgressVariant.warning : null}
     891           11 :                                         title={info.target}
     892           11 :                                         label={ cockpit.format(_("$0 free"), cockpit.format_bytes(info.avail)) } />
     893              :                                 );
     894           11 :                                 if (cockpit.manifests?.storage)
     895            0 :                                     progress = <Button variant="link" isInline onClick={() => cockpit.jump("/storage") }>{progress}</Button>;
     896              : 
     897           11 :                                 return (
     898           11 :                                     <Tooltip
     899           11 :                                         key={info.target}
     900           11 :                                         content={ cockpit.format(_("$0 total"), cockpit.format_bytes(info.size)) }
     901           11 :                                         position="bottom">
     902           11 :                                         {progress}
     903           11 :                                     </Tooltip>
     904              :                                 );
     905           11 :                             })
     906              :                         }
     907           11 :                         </div>
     908           11 :                         { this.state.topServicesDiskIO.length > 0 &&
     909           11 :                             <Table
     910           11 :                                 variant={TableVariant.compact}
     911           11 :                                 gridBreakPoint={TableGridBreakpoint.none}
     912           11 :                                 borders={false}
     913           11 :                                 aria-label={ _("Top 5 disk usage services") }>
     914           11 :                                 <Thead>
     915           11 :                                     <Tr>
     916           11 :                                         <Th width={50}>{_("Service")}</Th>
     917           11 :                                         <Th>{_("Read")}</Th>
     918           11 :                                         <Th>{_("Write")}</Th>
     919           11 :                                     </Tr>
     920           11 :                                 </Thead>
     921           11 :                                 <Tbody className="pf-v6-m-tabular-nums">
     922           11 :                                     {make_rows(this.state.topServicesDiskIO, rowPropsDiskIO, [_("Service"), _("Read"), _("Write")])}
     923           11 :                                 </Tbody>
     924           11 :                             </Table> }
     925           11 :                     </CardBody>
     926           11 :                 </Card>
     927              : 
     928           11 :                 <Card className="current-metrics-network">
     929           11 :                     <CardTitle>{ _("Network") }</CardTitle>
     930           11 :                     <CardBody>
     931           11 :                         <Table
     932           11 :                             variant={TableVariant.compact}
     933              :                             // FIXME: If we can make the table less wide, then we can switch from gridLg to none
     934              :                             // and (possibly) dropping (at least some of) the font size overrides
     935              :                             // this would require breaking out the units/s into its own row
     936           11 :                             gridBreakPoint={TableGridBreakpoint.gridLg}
     937           11 :                             borders={false}
     938           11 :                             aria-label={ _("Network usage") }>
     939           11 :                             <Thead>
     940           11 :                                 <Tr>{ifaceColumns.map(col => <Th key={col}>{col}</Th>)}</Tr>
     941           11 :                             </Thead>
     942           11 :                             <Tbody className="network-nowrap-shrink">
     943           11 :                                 {make_rows(netIO, rowPropsIface, ifaceColumns)}
     944           11 :                             </Tbody>
     945           11 :                         </Table>
     946           11 :                     </CardBody>
     947           11 :                 </Card>
     948           11 :             </Gallery>
     949              :         );
     950           11 :     }
     951           11 : }
     952              : 
     953            4 : const SvgGraph = ({ data, resource, have_sat }) => {
     954            4 :     const dataPoints = key => (
     955            4 :         "0 0, " + // start polygon at (0, 0)
     956            3 :         data.map((samples, index) => (samples && typeof samples[key] === 'number') ? parseInt(samples[key] * 100).toString() + "% " + (((index / SVG_YMAX) * 100).toString() + "%") : "").join(", ") +
     957            4 :         ", 0 " + ((data.length - 1) / SVG_YMAX) * 100 + "%" // close polygon
     958              :     );
     959              : 
     960            4 :     return (
     961            4 :         <>
     962            4 :             <div
     963            4 :                  className="polygon polygon-use"
     964            4 :                  style={{ "--points": dataPoints("use_" + resource) }}
     965            4 :                  points={dataPoints("use_" + resource)}
     966            4 :             />
     967            4 :             { have_sat && <div
     968            4 :                 className="polygon polygon-sat"
     969            4 :                 style={{ "--points": dataPoints("sat_" + resource) }}
     970            4 :                 points={dataPoints("sat_" + resource)}
     971            4 :             /> }
     972            4 :         </>
     973              :     );
     974            4 : };
     975              : 
     976           11 : class MetricsMinute extends React.Component {
     977            5 :     constructor(props) {
     978            5 :         super(props);
     979              : 
     980            5 :         this.state = {
     981            5 :             logs: null,
     982            5 :             logsUrl: null,
     983            5 :         };
     984            5 :         this.onHover = this.onHover.bind(this);
     985            5 :         this.findLogs = this.findLogs.bind(this);
     986              : 
     987            5 :         this.spikesButtonRef = createRef();
     988            5 :     }
     989              : 
     990            5 :     componentDidMount() {
     991            1 :         if (this.props.isExpanded && this.props.events)
     992            1 :             this.findLogs(this.props.events.start - 4, this.props.events.end + 4); // +- 20s
     993            5 :     }
     994              : 
     995            0 :     onHover(ev) {
     996              :         // FIXME - throttle debounce this
     997            0 :         const bounds = ev.target.getBoundingClientRect();
     998            0 :         const offsetY = (ev.clientY - bounds.y) / bounds.height;
     999            0 :         const indexOffset = Math.floor((1 - offsetY) * SAMPLES_PER_MIN);
    1000            0 :         const sample = this.props.rawData[indexOffset];
    1001            0 :         if (!sample)
    1002            0 :             return;
    1003              : 
    1004            0 :         const time = this.props.startTime + this.props.minute * 60000 + indexOffset * INTERVAL;
    1005            0 :         let tooltip = timeformat.timeSeconds(time) + "\n\n";
    1006            0 :         Object.entries(sample).forEach(([t, v]) => {
    1007            0 :             if (v !== null && v !== undefined)
    1008            0 :                 tooltip += `${RESOURCES[t].name}: ${RESOURCES[t].format(v)}\n`;
    1009            0 :         });
    1010            0 :         ev.target.setAttribute("title", tooltip);
    1011            0 :     }
    1012              : 
    1013            1 :     findLogs(start, end) {
    1014            1 :         const timestamp = this.props.startTime + (this.props.minute * 60000);
    1015            1 :         const start_minute = Math.floor(start / SAMPLES_PER_MIN);
    1016            1 :         const start_second = (start - (start_minute * SAMPLES_PER_MIN)) * (60 / SAMPLES_PER_MIN);
    1017            1 :         const end_minute = Math.floor(end / SAMPLES_PER_MIN);
    1018            1 :         const end_second = (end - (end_minute * SAMPLES_PER_MIN)) * (60 / SAMPLES_PER_MIN);
    1019              : 
    1020            1 :         const time = new Date(timestamp);
    1021            1 :         time.setUTCMinutes(start_minute);
    1022            1 :         time.setUTCSeconds(start_second);
    1023            1 :         const since = formatUTC_ISO(time);
    1024              : 
    1025            1 :         time.setUTCMinutes(end_minute);
    1026            1 :         time.setUTCSeconds(end_second);
    1027            1 :         const until = formatUTC_ISO(time);
    1028              : 
    1029            1 :         const match = { priority: "info", since, until, follow: false, count: 10 };
    1030            1 :         const journalctl = journal.journalctl(match);
    1031              : 
    1032            1 :         const out = new JournalOutput(match);
    1033            1 :         out.render_day_header = () => { return null };
    1034            1 :         const render = journal.renderer(out);
    1035              : 
    1036            1 :         journalctl.stream(entries => {
    1037            1 :             entries.forEach(entry => render.prepend(entry));
    1038            1 :             render.prepend_flush();
    1039            1 :         })
    1040            1 :                 .then(() => {
    1041            1 :                     let logsUrl;
    1042            1 :                     if (out.logs.length === 0) {
    1043              :                         // without logs, increase verbosity and time range (-15 mins to + 1 min)
    1044            1 :                         const since = formatUTC_ISO(new Date(timestamp - 15 * 60000));
    1045            1 :                         const until = formatUTC_ISO(new Date(timestamp + 60000));
    1046            1 :                         logsUrl = `/system/logs/#/?priority=debug&since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}&follow=false`;
    1047            1 :                     } else {
    1048              :                         // with logs, show the exact minute and same log level as on the metrics page
    1049            1 :                         logsUrl = `/system/logs/#/?priority=info&since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}&follow=false`;
    1050            1 :                     }
    1051              : 
    1052            1 :                     this.setState({ logs: out.logs, logsUrl });
    1053            1 :                 });
    1054            1 :     }
    1055              : 
    1056            5 :     render() {
    1057            5 :         const first = this.props.data.find(i => i !== null);
    1058              : 
    1059            5 :         const graphs = Object.keys(this.props.selectedVisibility).filter(itm => this.props.selectedVisibility[itm]).map(resource => {
    1060              :             // not all resources have a saturation metric
    1061            5 :             let have_sat = !!RESOURCES["sat_" + resource];
    1062              : 
    1063              :             // If there is no swap, don't render it
    1064            5 :             if (resource === "memory" && !swapTotal)
    1065            1 :                 have_sat = false;
    1066              : 
    1067            5 :             let graph = null;
    1068            4 :             if (this.props.events) {
    1069              :                 // render full SVG graphs for "expanded" minutes with events
    1070            4 :                 graph = <SvgGraph key={resource} data={this.props.data} resource={resource} have_sat={have_sat} />;
    1071            4 :             } else if (first) {
    1072              :                 // render simple bars for "compressed" minutes without events
    1073            4 :                 graph = <>
    1074            1 :                     <div className="polygon-use compressed" style={{ "--utilization": first["use_" + resource] || 0 }} />
    1075            4 :                     { have_sat && <div className="polygon-sat compressed" style={{ "--saturation": first["sat_" + resource] || 0 }} /> }
    1076            4 :                 </>;
    1077            4 :             }
    1078              : 
    1079            5 :             return (
    1080            5 :                 <div
    1081            5 :                     key={ resource + this.props.startTime + this.props.minute }
    1082            4 :                     className={ ("metrics-data metrics-data-" + resource) + (first ? " valid-data" : " empty-data") + (have_sat ? " have-saturation" : "") }
    1083            5 :                     aria-hidden="true"
    1084            1 :                     { ...(this.props.isExpanded && { onMouseMove: this.onHover }) }
    1085              :                 >
    1086            5 :                     {graph}
    1087            5 :                 </div>
    1088              :             );
    1089            5 :         });
    1090              : 
    1091            5 :         let desc;
    1092            1 :         if (this.props.isExpanded && this.props.booted) {
    1093            1 :             const timestamp = this.props.startTime + (this.props.minute * 60000);
    1094            1 :             desc = (
    1095            1 :                 <div className="metrics-events">
    1096            1 :                     <time>{ timeformat.time(timestamp) }</time>
    1097            1 :                     <span className="spikes_count" />
    1098            1 :                     <span className="spikes_info">
    1099            1 :                         <span className="type">
    1100            1 :                             {_("Boot")}
    1101            1 :                         </span>
    1102            1 :                     </span>
    1103            1 :                 </div>
    1104              :             );
    1105            1 :         } else if (this.props.isExpanded && this.props.events) {
    1106            1 :             const timestamp = this.props.startTime + (this.props.minute * 60000);
    1107              : 
    1108            1 :             const logsPanel = (
    1109            1 :                 <>
    1110            1 :                     {(this.state.logs?.length && this.state.logsUrl) && <Button variant="secondary" onClick={e => cockpit.jump(this.state.logsUrl)}>{_("View detailed logs")}</Button>}
    1111            1 :                     <div className="cockpit-log-panel">
    1112            1 :                         {this.state.logs?.length ? this.state.logs : _("No log entries")}
    1113            1 :                     </div>
    1114            1 :                 </>
    1115              :             );
    1116              : 
    1117            1 :             const resourceDesc = (
    1118            1 :                 <span className="type">
    1119            1 :                     {this.props.events.events.map(t => RESOURCES[t].event_description).join(", ")}
    1120            1 :                 </span>
    1121              :             );
    1122            1 :             desc = (
    1123            1 :                 <>
    1124            1 :                     <div className="metrics-events">
    1125            1 :                         <time>{ timeformat.time(timestamp) }</time>
    1126            1 :                         <span className="spikes_count" />
    1127            1 :                         {this.state.logs?.length > 0
    1128            1 :                             ? <Button
    1129            1 :                                     ref={this.spikesButtonRef}
    1130            1 :                                     variant="link" isInline
    1131            1 :                                     className="spikes_info">
    1132            1 :                                 {resourceDesc}
    1133            1 :                             </Button>
    1134            1 :                             : <span className="spikes_info">{resourceDesc}</span>}
    1135            1 :                     </div>
    1136            1 :                     {this.state.logs?.length > 0 && <Popover position="right" hasAutoWidth className="metrics-events-popover" bodyContent={logsPanel} triggerRef={this.spikesButtonRef} />}
    1137            1 :                 </>
    1138              :             );
    1139            1 :         }
    1140              : 
    1141            5 :         return (
    1142            5 :             <div className="metrics-minute" data-minute={this.props.minute}>
    1143            1 :                 { this.props.isExpanded && desc }
    1144            5 :                 <div className="metrics-graphs">
    1145            5 :                     { graphs }
    1146            5 :                 </div>
    1147            5 :             </div>
    1148              :         );
    1149            5 :     }
    1150           11 : }
    1151              : 
    1152           11 : class MetricsHour extends React.Component {
    1153            5 :     constructor(props) {
    1154            5 :         super(props);
    1155              : 
    1156            5 :         this.state = {
    1157            5 :             minuteGraphs: [],
    1158            5 :             minute_events: {},
    1159            5 :             isHourExpanded: false,
    1160            5 :             dataItems: 0,
    1161            5 :         };
    1162              : 
    1163            5 :         this.updateGraphs = this.updateGraphs.bind(this);
    1164            5 :     }
    1165              : 
    1166            5 :     componentDidMount() {
    1167            5 :         this.updateGraphs(this.props.data, this.props.startTime, this.props.selectedVisibility);
    1168            5 :     }
    1169              : 
    1170            5 :     shouldComponentUpdate(nextProps, nextState) {
    1171            5 :         if (this.state.dataItems !== nextProps.data.length ||
    1172            5 :             this.state.isHourExpanded !== nextState.isHourExpanded ||
    1173            5 :             this.props.startTime !== nextProps.startTime ||
    1174            5 :             this.props.boots !== nextProps.boots ||
    1175            5 :             Object.keys(this.props.selectedVisibility).some(itm => this.props.selectedVisibility[itm] != nextProps.selectedVisibility[itm])) {
    1176            5 :             this.updateGraphs(nextProps.data, nextProps.startTime, nextProps.selectedVisibility, nextState.isHourExpanded);
    1177            5 :             return false;
    1178            5 :         }
    1179              : 
    1180            5 :         return true;
    1181            5 :     }
    1182              : 
    1183              :     // data: type → SAMPLES_PER_H objects from startTime
    1184            5 :     updateGraphs(data, startTime, selectedVisibility, isHourExpanded) {
    1185            5 :         const filteredData = data.map(sample => Object.keys(sample)
    1186            5 :                 .filter(key => selectedVisibility[key.split("_")[1]])
    1187            5 :                 .reduce((cur, key) => Object.assign(cur, { [key]: sample[key] }), {}));
    1188              :         // Normalize data
    1189            5 :         const normData = filteredData.map(sample => {
    1190            5 :             if (sample === null)
    1191            1 :                 return null;
    1192            5 :             const n = {};
    1193            5 :             for (const type in sample)
    1194            5 :                 n[type] = (sample[type] !== null && sample[type] !== undefined) ? RESOURCES[type].normalize(sample[type]) : null;
    1195            5 :             return n;
    1196            5 :         });
    1197              : 
    1198              :         // Count minutes to render
    1199            5 :         let minutes = 60;
    1200            5 :         if (this.props.clipLeading) {
    1201              :             // When clipping of empty leading minutes is allowed, find the highest 5 minute interval with valid data
    1202            5 :             let m = 55;
    1203            5 :             for (; m >= 0; m = m - 5) {
    1204            5 :                 const dataOffset = m * SAMPLES_PER_MIN;
    1205            5 :                 const dataSlice = normData.slice(dataOffset, dataOffset + SAMPLES_PER_MIN * 5);
    1206            5 :                 if (dataSlice.some(i => i !== null && i !== undefined))
    1207            5 :                     break;
    1208            5 :             }
    1209            5 :             minutes = m + 5;
    1210            5 :         }
    1211              : 
    1212              :         // Compute spike events
    1213            5 :         const minute_events = {};
    1214            5 :         for (const type in RESOURCES) {
    1215            1 :             let prev_val = data[0] ? data[0][type] : null;
    1216            5 :             normData.forEach((samples, i) => {
    1217            5 :                 if (samples === null)
    1218            5 :                     return;
    1219            5 :                 const value = samples[type];
    1220              :                 // either high enough slope, or crossing the 80% threshold
    1221            4 :                 if (prev_val !== null && (value - prev_val > 0.25 || (prev_val < 0.75 && value >= 0.8))) {
    1222            4 :                     const minute = Math.floor(i / SAMPLES_PER_MIN);
    1223            4 :                     if (minute_events[minute] === undefined)
    1224            4 :                         minute_events[minute] = { events: [], start: i - 1 };
    1225              : 
    1226            4 :                     minute_events[minute].end = i;
    1227              : 
    1228              :                     // For every minute show each type of event max once
    1229            4 :                     if (minute_events[minute].events.indexOf(type) === -1)
    1230            4 :                         minute_events[minute].events.push(type);
    1231            4 :                 }
    1232            5 :                 prev_val = value;
    1233            5 :             });
    1234            5 :         }
    1235              : 
    1236            5 :         const minuteGraphs = [];
    1237              : 
    1238            5 :         for (let minute = minutes - 1; minute >= 0; --minute) {
    1239            5 :             const dataOffset = minute * SAMPLES_PER_MIN;
    1240            5 :             const dataSlice = normData.slice(dataOffset, dataOffset + SAMPLES_PER_MIN);
    1241            5 :             const rawSlice = this.props.data.slice(dataOffset, dataOffset + SAMPLES_PER_MIN);
    1242            5 :             const is_boot = this.props.boots.includes(minute);
    1243              : 
    1244            5 :             minuteGraphs.push(
    1245            5 :                 <MetricsMinute
    1246            5 :                     isExpanded={isHourExpanded}
    1247            5 :                     key={minute}
    1248            5 :                     minute={minute}
    1249            5 :                     data={dataSlice}
    1250            5 :                     rawData={rawSlice}
    1251            5 :                     events={minute_events[minute]}
    1252            5 :                     startTime={this.props.startTime}
    1253            5 :                     selectedVisibility={selectedVisibility}
    1254            5 :                     booted={is_boot} />
    1255            5 :             );
    1256            5 :         }
    1257              : 
    1258            5 :         this.setState((_, prevProps) => ({
    1259            5 :             isHourExpanded,
    1260            5 :             minute_events,
    1261            5 :             minuteGraphs,
    1262            5 :             dataItems: prevProps.data.length
    1263            5 :         }));
    1264            5 :     }
    1265              : 
    1266            5 :     render() {
    1267            5 :         const hourDesc = (
    1268            5 :             <HourDescription
    1269            5 :                minute_events={this.state.minute_events}
    1270            1 :                onToggleHourExpanded={isHourExpanded => this.setState({ isHourExpanded })}
    1271            5 :                startTime={this.props.startTime}
    1272            5 :                isHourExpanded={this.state.isHourExpanded} />
    1273              :         );
    1274              : 
    1275            5 :         return (
    1276            5 :             <div id={ "metrics-hour-" + this.props.startTime.toString() }
    1277            1 :                  className={"metrics-hour" + (!this.state.isHourExpanded ? " metrics-hour-compressed" : "")}>
    1278            5 :                 {hourDesc}
    1279            1 :                 {!this.state.isHourExpanded ? <div className="metrics-minutes">{this.state.minuteGraphs}</div> : this.state.minuteGraphs}
    1280            5 :             </div>
    1281              :         );
    1282            5 :     }
    1283           11 : }
    1284              : 
    1285            5 : const HourDescription = ({ minute_events, isHourExpanded, onToggleHourExpanded, startTime }) => {
    1286            5 :     const event_types = {};
    1287            5 :     Object.keys(RESOURCES).forEach(t => { event_types[t] = 0 });
    1288            4 :     Object.values(minute_events).forEach(event => { event.events.forEach(t => { event_types[t] += 1 }) });
    1289            5 :     const spikes = Object.values(event_types).reduce((acc, event_type_count) => acc + event_type_count, 0);
    1290            5 :     return (
    1291            1 :         <span className={"metrics-events" + (isHourExpanded ? " metrics-events-hour-header-expanded" : "")}>
    1292            5 :             {spikes > 0 &&
    1293            1 :                 <Button variant="plain" className="metrics-events-expander" onClick={() => onToggleHourExpanded(!isHourExpanded)} icon={isHourExpanded ? <AngleDownIcon /> : <Icon shouldMirrorRTL><AngleRightIcon /></Icon>} />}
    1294            5 :             <time>{ timeformat.time(startTime) }</time>
    1295            5 :             <Flex flexWrap={{ default: 'nowrap' }} spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsBaseline' }} className="spikes_count">
    1296            1 :                 {spikes >= 10 && <ResourcesFullIcon color="var(--resource-icon-color-full)" />}
    1297            1 :                 {spikes >= 5 && spikes < 10 && <ResourcesAlmostFullIcon color="var(--resource-icon-color-middle)" />}
    1298            4 :                 {spikes < 5 && spikes > 0 && <ResourcesAlmostEmptyIcon color="var(--resource-icon-color-empty)" />}
    1299            5 :                 <FlexItem>
    1300            4 :                     {spikes ? cockpit.format(cockpit.ngettext("$0 spike", "$0 spikes", spikes), spikes) : _("No events")}
    1301            5 :                 </FlexItem>
    1302            5 :             </Flex>
    1303            5 :             <span className="spikes_info">
    1304            4 :                 {spikes > 0 && Object.entries(event_types)
    1305            4 :                         .filter(([_, count]) => count > 0)
    1306            4 :                         .map(([event_type, count]) => cockpit.format("$0 $1", count, RESOURCES[event_type].event_description))
    1307            4 :                         .join(", ")}
    1308            5 :             </span>
    1309            5 :         </span>
    1310              :     );
    1311            5 : };
    1312              : 
    1313              : // null means "not initialized yet"
    1314           11 : const invalidService = proxy => proxy.state === null;
    1315            7 : const runningService = proxy => ['running', 'starting'].indexOf(proxy.state) >= 0;
    1316              : 
    1317            1 : const wait_cond = (cond, objects) => {
    1318            1 :     return new Promise((resolve, reject) => {
    1319            1 :         const check = () => {
    1320            1 :             if (cond()) {
    1321            1 :                 objects.forEach(o => o.removeEventListener("changed", check));
    1322            1 :                 resolve();
    1323            1 :             }
    1324            1 :         };
    1325            1 :         objects.forEach(o => o.addEventListener("changed", check));
    1326            1 :         check();
    1327            1 :     });
    1328            1 : };
    1329              : 
    1330            3 : const PCPConfigDialog = ({
    1331            3 :     firewalldRequest,
    1332            3 :     s_pmlogger, s_pmproxy, s_redis, s_redis_server, s_valkey,
    1333            3 :     packageInstallCallback,
    1334            3 : }) => {
    1335            3 :     const Dialogs = useDialogs();
    1336            3 :     const dialogInitialProxyValue = runningService(s_pmproxy) && (
    1337            1 :         runningService(s_redis) || runningService(s_redis_server) || runningService(s_valkey));
    1338            3 :     const [dialogError, setDialogError] = useState(null);
    1339            3 :     const [dialogLoggerValue, setDialogLoggerValue] = useState(runningService(s_pmlogger));
    1340            3 :     const [dialogProxyValue, setDialogProxyValue] = useState(dialogInitialProxyValue);
    1341            3 :     const [pending, setPending] = useState(false);
    1342            3 :     const [packageManager, setPackageManager] = useState(null);
    1343              : 
    1344            3 :     useInit(() => getPackageManager().then(setPackageManager));
    1345              : 
    1346            3 :     const handleInstall = async () => {
    1347              :     // when enabling services, install missing packages on demand
    1348            3 :         const missing = [];
    1349            1 :         if (dialogLoggerValue && !s_pmlogger.exists) {
    1350            1 :             missing.push(...await get_pcp_packages());
    1351            1 :         }
    1352            1 :         const redisExists = () => s_redis.exists || s_redis_server.exists || s_valkey.exists;
    1353            1 :         if (dialogProxyValue && !redisExists()) {
    1354            1 :             const os_release = await read_os_release();
    1355            1 :             missing.push(get_manifest_config_matchlist("metrics", "redis_package", "redis",
    1356            1 :                                                        [os_release.PLATFORM_ID, os_release.ID]));
    1357            1 :         }
    1358              : 
    1359            1 :         if (missing.length > 0) {
    1360            1 :             debug("PCPConfig: missing packages", JSON.stringify(missing), ", offering install");
    1361            1 :             Dialogs.close();
    1362            1 :             await install_dialog(missing);
    1363            1 :             debug("PCPConfig: package installation successful");
    1364            1 :             await wait_cond(() => (s_pmlogger.exists &&
    1365            1 :                                    (!dialogProxyValue || (s_pmproxy.exists && redisExists()))),
    1366            1 :                             [s_pmlogger, s_pmproxy, s_redis, s_redis_server, s_valkey]);
    1367            1 :         }
    1368            3 :     };
    1369              : 
    1370            3 :     const handleSave = () => {
    1371            3 :         debug("PCPConfig handleSave(): dialogLoggerValue", dialogLoggerValue, "dialogInitialProxyValue", dialogInitialProxyValue, "dialogProxyValue", dialogProxyValue);
    1372              : 
    1373            3 :         handleInstall()
    1374            3 :                 .then(() => {
    1375            3 :                     setPending(true);
    1376              : 
    1377            3 :                     let real_redis;
    1378            3 :                     let redis_name;
    1379            3 :                     if (s_valkey.exists && s_valkey.unit?.UnitFileState !== 'masked') {
    1380            3 :                         real_redis = s_valkey;
    1381            3 :                         redis_name = "valkey.service";
    1382            0 :                     } else if (s_redis_server.exists && s_redis_server.unit?.UnitFileState !== 'masked') {
    1383            0 :                         real_redis = s_redis_server;
    1384            0 :                         redis_name = "redis-server.service";
    1385            0 :                     } else {
    1386            1 :                         real_redis = s_redis;
    1387            1 :                         redis_name = "redis.service";
    1388            1 :                     }
    1389              : 
    1390            3 :                     const redis_enable_cmd = `mkdir -p /etc/systemd/system/pmproxy.service.wants; ln -sf ../${redis_name} /etc/systemd/system/pmproxy.service.wants/${redis_name}`;
    1391            3 :                     const redis_disable_cmd = `rm -f /etc/systemd/system/pmproxy.service.wants/${redis_name}; rmdir -p /etc/systemd/system/pmproxy.service.wants 2>/dev/null || true`;
    1392            3 :                     let action;
    1393              : 
    1394              :                     // enable/disable does a daemon-reload, which interferes with start on some distros; so don't run them in parallel
    1395            3 :                     if (dialogLoggerValue)
    1396            3 :                         action = s_pmlogger.start().then(() => s_pmlogger.enable());
    1397              :                     else
    1398            1 :                         action = s_pmlogger.stop().finally(() => s_pmlogger.disable());
    1399              : 
    1400            2 :                     if (dialogProxyValue !== null && dialogInitialProxyValue !== dialogProxyValue) {
    1401            2 :                         if (dialogProxyValue === true) {
    1402              :                         // pmproxy.service needs to (re)start *after* redis to recognize it
    1403            2 :                             action = action
    1404            2 :                                     .then(() => real_redis.start())
    1405            2 :                                     .then(() => s_pmproxy.restart())
    1406              :                             // turn redis into a dependency, as the metrics API requires it
    1407            2 :                                     .then(() => cockpit.script(redis_enable_cmd, { superuser: "require", err: "message" }))
    1408            2 :                                     .then(() => s_pmproxy.enable());
    1409            1 :                         } else {
    1410              :                         // don't stop redis here -- it's a shared service, other things may be using it
    1411            1 :                             action = action
    1412            1 :                                     .then(() => s_pmproxy.stop())
    1413            1 :                                     .then(() => cockpit.script(redis_disable_cmd, { superuser: "require", err: "message" }))
    1414            1 :                                     .then(() => s_pmproxy.disable());
    1415            1 :                         }
    1416            2 :                     }
    1417              : 
    1418            3 :                     action
    1419            3 :                             .then(() => {
    1420            3 :                                 Dialogs.close();
    1421            1 :                                 if (dialogProxyValue && !dialogInitialProxyValue && firewalldRequest)
    1422            1 :                                     firewalldRequest({ service: "pmproxy", title: _("Open the pmproxy service in the firewall to share metrics.") });
    1423              :                                 else
    1424            2 :                                     firewalldRequest(null);
    1425            2 :                                 packageInstallCallback();
    1426            3 :                             })
    1427            1 :                             .catch(err => { packageInstallCallback(); setPending(false); setDialogError(err.toString()) });
    1428            3 :                 })
    1429            0 :                 .catch(() => null); // ignore cancel in install dialog
    1430            3 :     };
    1431              : 
    1432            3 :     return (
    1433            3 :         <Modal position="top" variant="small" isOpen
    1434            3 :             id="pcp-settings-modal"
    1435            3 :             onClose={Dialogs.close}
    1436              :         >
    1437            3 :             <ModalHeader title={ _("Metrics settings") }
    1438            3 :                 description={
    1439            3 :                     <div className="pcp-settings-modal-text">
    1440            3 :                         { _("Performance Co-Pilot collects and analyzes performance metrics from your system.") }
    1441              : 
    1442            3 :                         <Button component="a" variant="link" href="https://cockpit-project.org/guide/latest/feature-pcp.html"
    1443            3 :                                       isInline
    1444            3 :                                       target="_blank" rel="noopener noreferrer"
    1445            3 :                                       icon={<ExternalLinkAltIcon />}>
    1446            3 :                             { _("Read more...") }
    1447            3 :                         </Button>
    1448            3 :                     </div>}
    1449            3 :             />
    1450            3 :             <ModalBody>
    1451            3 :                 <Stack hasGutter>
    1452            0 :                     { dialogError && <ModalError dialogError={ _("Failed to configure PCP") } dialogErrorDetail={dialogError} /> }
    1453            3 :                     <StackItem>
    1454            3 :                         <Switch id="switch-pmlogger"
    1455            3 :                                     isChecked={dialogLoggerValue}
    1456            1 :                                     isDisabled={!s_pmlogger.exists && !packageManager}
    1457            3 :                                     label={
    1458            3 :                                         <Flex>
    1459            3 :                                             <FlexItem>{ _("Collect metrics") }</FlexItem>
    1460            3 :                                             <Content>
    1461            3 :                                                 <Content component={ContentVariants.small}>(pmlogger.service)</Content>
    1462            3 :                                             </Content>
    1463            3 :                                         </Flex>
    1464              :                                     }
    1465            3 :                                     onChange={(_event, enable) => {
    1466              :                                         // pmproxy needs pmlogger, auto-disable it
    1467            3 :                                         setDialogLoggerValue(enable);
    1468            3 :                                         if (!enable)
    1469            1 :                                             setDialogProxyValue(false);
    1470            3 :                                     }} />
    1471              : 
    1472            3 :                         <Switch id="switch-pmproxy"
    1473            3 :                                     isChecked={dialogProxyValue}
    1474            3 :                                     label={
    1475            3 :                                         <Flex>
    1476            3 :                                             <FlexItem>{ _("Export to network") }</FlexItem>
    1477            3 :                                             <Content component={ContentVariants.small}>(pmproxy.service)</Content>
    1478            3 :                                         </Flex>
    1479              :                                     }
    1480            3 :                                     isDisabled={ !dialogLoggerValue }
    1481            2 :                                 onChange={(_event, enable) => setDialogProxyValue(enable)} />
    1482            3 :                     </StackItem>
    1483            3 :                 </Stack>
    1484            3 :             </ModalBody>
    1485            3 :             <ModalFooter>
    1486            3 :                 <Button variant='primary' onClick={handleSave} isDisabled={pending} isLoading={pending}>
    1487            3 :                     { _("Save") }
    1488            3 :                 </Button>
    1489            3 :                 <Button variant='link' className='btn-cancel' onClick={Dialogs.close}>
    1490            3 :                     {_("Cancel")}
    1491            3 :                 </Button>
    1492            3 :             </ModalFooter>
    1493            3 :         </Modal>);
    1494            3 : };
    1495              : 
    1496           11 : const PCPConfig = ({ buttonVariant, firewalldRequest }) => {
    1497           11 :     const Dialogs = useDialogs();
    1498           11 :     const [packageInstallStatus, setPackageInstallStatus] = useState(null);
    1499              : 
    1500           11 :     const s_pmlogger = useObject(() => service.proxy("pmlogger.service"), null, []);
    1501           11 :     const s_pmproxy = useObject(() => service.proxy("pmproxy.service"), null, []);
    1502              :     // redis.service on Fedora/RHEL, redis-server.service on Debian/Ubuntu with an Alias=redis
    1503           11 :     const s_redis = useObject(() => service.proxy("redis.service"), null, []);
    1504           11 :     const s_redis_server = useObject(() => service.proxy("redis-server.service"), null, []);
    1505           11 :     const s_valkey = useObject(() => service.proxy("valkey.service"), null, []);
    1506              : 
    1507           11 :     useEvent(superuser, "changed");
    1508           11 :     useEvent(s_pmlogger, "changed");
    1509           11 :     useEvent(s_pmproxy, "changed");
    1510           11 :     useEvent(s_redis, "changed");
    1511           11 :     useEvent(s_redis_server, "changed");
    1512           11 :     useEvent(s_valkey, "changed");
    1513              : 
    1514           11 :     debug("PCPConfig s_pmlogger.state", s_pmlogger.state);
    1515           11 :     debug("PCPConfig s_pmproxy state", s_pmproxy.state,
    1516           11 :           "redis exists", s_redis.exists, "state", s_redis.state,
    1517           11 :           "redis-server exists", s_redis_server.exists, "state", s_redis_server.state,
    1518           11 :           "valkey exists", s_valkey.exists, "state", s_valkey.state);
    1519              : 
    1520           11 :     if (!superuser.allowed)
    1521           11 :         return null;
    1522              : 
    1523            3 :     function show_dialog() {
    1524            3 :         setPackageInstallStatus(null);
    1525            3 :         Dialogs.show(<PCPConfigDialog firewalldRequest={firewalldRequest}
    1526            3 :                                       s_pmlogger={s_pmlogger}
    1527            3 :                                       s_pmproxy={s_pmproxy}
    1528            3 :                                       s_redis={s_redis} s_redis_server={s_redis_server} s_valkey={s_valkey}
    1529            3 :                                       packageInstallCallback={() => setPackageInstallStatus("done")} />);
    1530            3 :     }
    1531              : 
    1532           11 :     return (
    1533           11 :         <Button variant={buttonVariant} icon={<CogIcon />}
    1534           10 :                 isDisabled={ invalidService(s_pmlogger) || invalidService(s_pmproxy) ||
    1535           10 :                              invalidService(s_redis) || invalidService(s_redis_server) || invalidService(s_valkey) }
    1536           11 :                 onClick={show_dialog}
    1537           11 :                 data-test-install-finished={packageInstallStatus}>
    1538           11 :             { _("Metrics settings") }
    1539           11 :         </Button>);
    1540           11 : };
    1541              : 
    1542           11 : class MetricsHistory extends React.Component {
    1543           11 :     constructor(props) {
    1544           11 :         super(props);
    1545              :         // metrics data: hour timestamp → array of SAMPLES_PER_H objects of { type → value } or null
    1546           11 :         this.data = {};
    1547              :         // timestamp of the most recent sample that we got (for auto-refresh)
    1548           11 :         this.most_recent = 0;
    1549              :         // Oldest read data
    1550           11 :         this.oldest_timestamp = 0;
    1551              :         // Timestamp representing today midnight to calculate other days for date select
    1552           11 :         this.today_midnight = null;
    1553           11 :         this.columns = [["cpu", _("CPU")], ["memory", _("Memory")], ["disks", _("Disk I/O")], ["network", _("Network")]];
    1554              : 
    1555           11 :         this.state = {
    1556           11 :             hours: [], // available hours for rendering in descending order
    1557           11 :             loading: true, // show loading indicator
    1558           11 :             metricsAvailable: true,
    1559           11 :             pmLoggerState: null,
    1560           11 :             error: null,
    1561           11 :             selectedDate: null,
    1562           11 :             packageManager: null,
    1563           11 :             isBeibootBridge: false,
    1564           11 :             isPythonPCPInstalled: null,
    1565           11 :             selectedVisibility: this.columns.reduce((a, v) => ({ ...a, [v[0]]: true }), {}),
    1566           11 :             boots: [], // journalctl --list-boots as [{started: Date, ended: Date}]
    1567           11 :         };
    1568              : 
    1569           11 :         this.handleMoreData = this.handleMoreData.bind(this);
    1570           11 :         this.handleSelect = this.handleSelect.bind(this);
    1571           11 :         this.handleInstall = this.handleInstall.bind(this);
    1572              : 
    1573              :         /* supervise pmlogger.service, to diagnose missing history */
    1574           11 :         this.pmlogger_service = service.proxy("pmlogger.service");
    1575           10 :         this.pmlogger_service.addEventListener("changed", () => {
    1576           10 :             if (!invalidService(this.pmlogger_service) && this.pmlogger_service.state !== this.state.pmLoggerState) {
    1577              :                 // when it got enabled while the page is running (e.g. through Settings dialog), start data collection
    1578            7 :                 if (!this.state.metricsAvailable && runningService(this.pmlogger_service))
    1579            3 :                     this.initialLoadData();
    1580           10 :                 this.setState({ pmLoggerState: this.pmlogger_service.state });
    1581           10 :             }
    1582           10 :         });
    1583              : 
    1584              :         // FIXME: load less up-front, load more when scrolling
    1585           11 :         machine_info_promise.then(() => this.initialLoadData());
    1586              : 
    1587            8 :         cockpit.addEventListener("visibilitychange", () => {
    1588              :             // update history metrics when in auto-update mode
    1589            4 :             if (!cockpit.hidden && this.history_refresh_timer)
    1590            2 :                 this.load_data(this.most_recent);
    1591            8 :         });
    1592           11 :     }
    1593              : 
    1594              :     // load and render the last 24 hours (plus current one) initially; this needs numCpu initialized for correct scaling
    1595           11 :     initialLoadData() {
    1596           11 :         cockpit.spawn(["date", "+%s"])
    1597           11 :                 .then(out => {
    1598           11 :                     const now = parseInt(out.trim()) * 1000;
    1599           11 :                     const current_hour = Math.floor(now / MSEC_PER_H) * MSEC_PER_H;
    1600           11 :                     this.most_recent = current_hour;
    1601           11 :                     this.today_midnight = new Date(current_hour).setHours(0, 0, 0, 0);
    1602              : 
    1603           11 :                     const selectedDate = parseInt(cockpit.location.options.date) || this.today_midnight;
    1604              : 
    1605           11 :                     if (selectedDate !== this.today_midnight)
    1606            1 :                         this.load_data(selectedDate, 24 * SAMPLES_PER_H, true);
    1607              :                     else
    1608           11 :                         this.load_data(current_hour - LOAD_HOURS * MSEC_PER_H, undefined, true);
    1609              : 
    1610           11 :                     this.setState({
    1611           11 :                         metricsAvailable: true,
    1612           11 :                         selectedDate,
    1613           11 :                     });
    1614           11 :                 })
    1615            0 :                 .catch(ex => this.setState({ error: ex.toString() }));
    1616           11 :     }
    1617              : 
    1618           11 :     async componentDidMount() {
    1619           11 :         let packageManager = false;
    1620           11 :         try {
    1621           11 :             await getPackageManager();
    1622           11 :             packageManager = true;
    1623            1 :         } catch (err) {
    1624            1 :             packageManager = false;
    1625            1 :         }
    1626              :         // HACK: See https://github.com/cockpit-project/cockpit/issues/19143
    1627           11 :         let cmdline = "";
    1628           11 :         try {
    1629           11 :             cmdline = await cockpit.file("/proc/self/cmdline").read();
    1630            1 :         } catch (_ex) {}
    1631              : 
    1632           11 :         const isBeibootBridge = cmdline?.includes("ic# cockpit-bridge");
    1633           11 :         this.setState({ packageManager, isBeibootBridge });
    1634              : 
    1635           11 :         try {
    1636              :             // Only 14 days of metrics are shown
    1637              :             // Requires superuser on Debian/Ubuntu, on Fedora/Arch users in the wheel group can list without superuser.
    1638           11 :             const output = await cockpit.spawn(["journalctl", "--list-boots", "--since", "-15d", "--output", "json"], { superuser: "try" });
    1639           10 :             const list_boots = JSON.parse(output);
    1640           10 :             const boots = list_boots.map(boot => {
    1641           10 :                 return {
    1642           10 :                     started: new Date(boot?.first_entry / 1000),
    1643           10 :                     ended: new Date(boot?.last_entry / 1000),
    1644           10 :                     current_boot: boot?.index === 0,
    1645           10 :                 };
    1646           10 :             });
    1647           10 :             this.setState({ boots });
    1648            1 :         } catch (exc) {
    1649            1 :             console.warn("journalctl --list-boots failed", exc);
    1650            1 :         }
    1651           11 :     }
    1652              : 
    1653            1 :     handleMoreData() {
    1654            1 :         this.load_data(this.oldest_timestamp - (LOAD_HOURS * MSEC_PER_H), LOAD_HOURS * SAMPLES_PER_H, true);
    1655            1 :     }
    1656              : 
    1657            1 :     handleSelect(sel) {
    1658              :         // Stop fetching of new data
    1659            1 :         if (this.history_refresh_timer !== null) {
    1660            1 :             window.clearTimeout(this.history_refresh_timer);
    1661            1 :             this.history_refresh_timer = null;
    1662            1 :         }
    1663              : 
    1664            1 :         this.oldest_timestamp = 0;
    1665              : 
    1666            1 :         cockpit.location.go([], Object.assign(cockpit.location.options, { date: sel }));
    1667            1 :         this.setState({
    1668            1 :             selectedDate: sel,
    1669            1 :             hours: [],
    1670            0 :         }, () => this.load_data(sel, sel === this.today_midnight ? undefined : 24 * SAMPLES_PER_H, true));
    1671            1 :     }
    1672              : 
    1673            0 :     async handleInstall() {
    1674            0 :         install_dialog(await get_pcp_packages())
    1675            0 :                 .then(() => this.initialLoadData())
    1676            0 :                 .catch(() => null); // ignore cancel
    1677            0 :     }
    1678              : 
    1679           11 :     load_data(load_timestamp, limit, show_spinner) {
    1680           11 :         if (show_spinner)
    1681           11 :             this.setState({ loading: true });
    1682              : 
    1683            4 :         this.oldest_timestamp = this.oldest_timestamp > load_timestamp || this.oldest_timestamp === 0 ? load_timestamp : this.oldest_timestamp;
    1684           11 :         let current_hour; // hour of timestamp, from most recent meta message
    1685           11 :         let hour_index; // index within data[current_hour] array
    1686           11 :         const current_sample = []; // last valid value, for decompression
    1687           11 :         const new_hours = new Set(); // newly seen hours during this load
    1688           11 :         this.history_refresh_timer = null;
    1689              : 
    1690           11 :         const metrics = cockpit.channel({
    1691           11 :             payload: "metrics1",
    1692           11 :             interval: INTERVAL,
    1693           11 :             source: "pcp-archive",
    1694           11 :             timestamp: load_timestamp,
    1695           11 :             limit,
    1696           11 :             metrics: HISTORY_METRICS,
    1697           11 :             "omit-instances": ["lo"],
    1698           11 :         });
    1699              : 
    1700            5 :         metrics.addEventListener("message", (event, message) => {
    1701            5 :             debug("history metrics message", message);
    1702            5 :             message = JSON.parse(message);
    1703              : 
    1704            5 :             const init_current_hour = () => {
    1705            5 :                 if (!this.data[current_hour])
    1706            5 :                     this.data[current_hour] = [];
    1707              : 
    1708              :                 // When limit is considered only add hours in this time range
    1709            2 :                 if (!limit || load_timestamp + (limit * INTERVAL) >= current_hour)
    1710            5 :                     new_hours.add(current_hour);
    1711            5 :             };
    1712              : 
    1713              :             // meta message
    1714            5 :             if (!Array.isArray(message)) {
    1715            5 :                 current_hour = Math.floor(message.timestamp / MSEC_PER_H) * MSEC_PER_H;
    1716            5 :                 init_current_hour();
    1717            5 :                 hour_index = Math.floor((message.timestamp - current_hour) / INTERVAL);
    1718            5 :                 console.assert(hour_index < SAMPLES_PER_H);
    1719              : 
    1720            5 :                 debug("message is metadata; time stamp", message.timestamp, "=", timeformat.dateTime(message.timestamp), "for current_hour", current_hour, "=", timeformat.dateTime(current_hour), "hour_index", hour_index);
    1721            5 :                 return;
    1722            5 :             }
    1723              : 
    1724            5 :             debug("message is", message.length, "samples data for current hour", current_hour, "=", timeformat.dateTime(current_hour));
    1725              : 
    1726            5 :             message.forEach((samples, i) => {
    1727            5 :                 decompress_samples(samples, current_sample);
    1728              : 
    1729              :                 /* don't overwrite existing data with null data; this often happens at the first
    1730              :                  * data point when "rate" metrics cannot be calculated yet */
    1731            3 :                 if (typeof current_sample[0] !== 'number' && this.data[current_hour][hour_index]) {
    1732            3 :                     debug("load_data", load_timestamp, ": ignoring sample #", i, ":", JSON.stringify(current_sample), "current data sample", JSON.stringify(this.data[current_hour][hour_index]));
    1733            3 :                     return;
    1734            3 :                 }
    1735              : 
    1736              :                 // TODO: eventually track/display this by-interface?
    1737            4 :                 const use_network = current_sample[8].reduce((acc, cur) => acc + cur, 0);
    1738            4 :                 const sat_cpu = typeof current_sample[3][1] === 'number' ? current_sample[3][1] : null; // instances: (15min, 1min, 5min), pick 1min
    1739              : 
    1740            5 :                 this.data[current_hour][hour_index] = {
    1741            4 :                     use_cpu: typeof current_sample[2] === 'number' ? [current_sample[0], current_sample[1], current_sample[2]] : null,
    1742            5 :                     sat_cpu,
    1743            4 :                     use_memory: typeof current_sample[5] === 'number' ? [current_sample[4], current_sample[5]] : null,
    1744            5 :                     sat_memory: current_sample[6],
    1745            5 :                     use_disks: current_sample[7],
    1746            5 :                     use_network,
    1747            5 :                 };
    1748              : 
    1749              :                 // keep track of maximums of unbounded values, for dynamic scaling
    1750            5 :                 if (sat_cpu > scaleSatCPU)
    1751            1 :                     scaleSatCPU = scaleForValue(sat_cpu);
    1752            5 :                 if (current_sample[7] > scaleUseDisks)
    1753            1 :                     scaleUseDisks = scaleForValue(current_sample[7]);
    1754            5 :                 if (use_network > scaleUseNetwork)
    1755            3 :                     scaleUseNetwork = scaleForValue(use_network);
    1756              : 
    1757            1 :                 if (++hour_index === SAMPLES_PER_H) {
    1758            1 :                     current_hour += MSEC_PER_H;
    1759            1 :                     hour_index = 0;
    1760            1 :                     init_current_hour();
    1761            1 :                     debug("hour overflow, advancing to", current_hour, "=", timeformat.dateTime(current_hour));
    1762            1 :                 }
    1763            5 :             });
    1764              : 
    1765              :             // update most recent sample timestamp
    1766            5 :             this.most_recent = Math.max(this.most_recent, current_hour + (hour_index - 5) * INTERVAL);
    1767            5 :             debug("most recent timestamp is now", this.most_recent, "=", timeformat.dateTime(this.most_recent));
    1768            5 :         });
    1769              : 
    1770           10 :         metrics.addEventListener("close", (event, message) => {
    1771            7 :             if (message.problem) {
    1772            7 :                 debug("could not load metrics:", message.problem);
    1773            7 :                 this.setState({
    1774            7 :                     loading: false,
    1775            7 :                     metricsAvailable: false,
    1776            7 :                     isPythonPCPInstalled: message?.message !== "python3-pcp not installed",
    1777            7 :                 });
    1778            2 :             } else {
    1779            5 :                 this.setState({ isPythonPCPInstalled: true });
    1780            5 :                 debug("loaded metrics for timestamp", timeformat.dateTime(load_timestamp), "new hours", JSON.stringify(Array.from(new_hours)));
    1781            5 :                 new_hours.forEach(hour => debug("hour", hour, "data", JSON.stringify(this.data[hour])));
    1782              : 
    1783            5 :                 const hours = Array.from(new Set([...this.state.hours, ...new_hours]));
    1784              :                 // sort in descending order
    1785            0 :                 hours.sort((a, b) => b - a);
    1786              :                 // re-render
    1787            5 :                 this.setState({ hours, loading: false });
    1788              : 
    1789              :                 // trigger automatic update every minute when visible
    1790            5 :                 if (!limit) {
    1791            2 :                     this.history_refresh_timer = window.setTimeout(() => {
    1792            2 :                         if (!cockpit.hidden)
    1793            2 :                             this.load_data(this.most_recent);
    1794            2 :                     }, 60000);
    1795            5 :                 }
    1796            5 :             }
    1797              : 
    1798           10 :             metrics.close();
    1799           10 :         });
    1800           11 :     }
    1801              : 
    1802           11 :     render() {
    1803              :         // on a single machine, cockpit-pcp depends on pcp; but this may not be the case in the beiboot scenario,
    1804              :         // so additionally check if pcp is available on the logged in target machine
    1805           11 :         if (this.state.isPythonPCPInstalled === false || this.pmlogger_service.exists === false)
    1806            2 :             return <EmptyStatePanel
    1807            2 :                         icon={ExclamationCircleIcon}
    1808            2 :                         title={_("PCP is missing for metrics history")}
    1809            2 :                         action={this.state.packageManager && <Button onClick={this.handleInstall}>{_("Install PCP support")}</Button>}
    1810            2 :             />;
    1811              : 
    1812            7 :         if (!this.state.metricsAvailable) {
    1813            7 :             let action;
    1814            7 :             let paragraph;
    1815              : 
    1816            5 :             if (this.pmlogger_service.state === 'stopped') {
    1817            5 :                 paragraph = _("pmlogger.service is not running");
    1818            5 :                 action = <PCPConfig buttonVariant="primary"
    1819            5 :                                     firewalldRequest={this.props.firewalldRequest} />;
    1820            5 :             } else {
    1821            7 :                 if (this.pmlogger_service.state === 'failed')
    1822            2 :                     paragraph = _("pmlogger.service has failed");
    1823              :                 else /* running, or initialization hangs */
    1824            7 :                     paragraph = _("pmlogger.service is failing to collect data");
    1825            1 :                 action = <Button variant="link" onClick={() => cockpit.jump("/system/services#/pmlogger.service") }>{_("Troubleshoot")}</Button>;
    1826            7 :             }
    1827              : 
    1828            7 :             return <EmptyStatePanel
    1829            7 :                         icon={ExclamationCircleIcon}
    1830            7 :                         title={_("Metrics history could not be loaded")}
    1831            7 :                         paragraph={paragraph}
    1832            7 :                         action={action}
    1833            7 :             />;
    1834            7 :         }
    1835              : 
    1836           11 :         if (this.state.error)
    1837            1 :             return <EmptyStatePanel
    1838            1 :                         icon={ExclamationCircleIcon}
    1839            1 :                         title={_("Error has occurred")}
    1840            1 :                         paragraph={this.state.error}
    1841            1 :             />;
    1842              : 
    1843           11 :         let nodata_alert = null;
    1844           11 :         const lastHourIndex = this.state.hours.length - 1;
    1845            5 :         if (!this.state.loading && this.state.hours.length > 0 && this.oldest_timestamp < this.state.hours[lastHourIndex]) {
    1846            5 :             let t1;
    1847            5 :             let t2;
    1848            5 :             if (this.state.hours[lastHourIndex] - this.oldest_timestamp < 24 * MSEC_PER_H) {
    1849            5 :                 t1 = timeformat.time(this.oldest_timestamp);
    1850            5 :                 t2 = timeformat.time(this.state.hours[lastHourIndex]);
    1851            2 :             } else {
    1852            2 :                 t1 = timeformat.dateTime(this.oldest_timestamp);
    1853            2 :                 t2 = timeformat.dateTime(this.state.hours[lastHourIndex]);
    1854            2 :             }
    1855            5 :             nodata_alert = <Alert className="nodata" variant="info" isInline title={ cockpit.format(_("No data available between $0 and $1"), t1, t2) } />;
    1856            5 :         }
    1857              : 
    1858            5 :         if (!this.state.loading && this.state.hours.length === 0)
    1859            3 :             nodata_alert = <EmptyStatePanel icon={ExclamationCircleIcon} title={_("No data available")} />;
    1860              : 
    1861              :         // generate selection of last 14 days
    1862           11 :         const options = Array(15).fill()
    1863           11 :                 .map((_undef, i) => {
    1864           11 :                     const date = this.today_midnight - i * 86400000;
    1865           11 :                     const text = i == 0 ? _("Today") : timeformat.weekdayDate(date);
    1866           11 :                     return { value: date, content: text };
    1867           11 :                 });
    1868              : 
    1869           11 :         function Label(props) {
    1870           11 :             return (
    1871           11 :                 <div className={"metrics-label metrics-label-graph" + (props.items.length > 1 ? " have-saturation" : "")}>
    1872           11 :                     <span>{props.label}</span>
    1873           11 :                     <Content className="metrics-sublabels">
    1874           11 :                         { props.items.map(i => <Content component={ContentVariants.small} key={i}>{i}</Content>) }
    1875           11 :                     </Content>
    1876           11 :                 </div>
    1877              :             );
    1878           11 :         }
    1879              : 
    1880           11 :         const columnVisibilityMenuItems = this.columns.map(itm => {
    1881           11 :             return {
    1882           11 :                 value: itm[0],
    1883           11 :                 content: itm[1],
    1884           11 :                 "data-label": itm[0],
    1885           11 :             };
    1886           11 :         });
    1887           11 :         const selections = (
    1888           11 :             this.columns
    1889           11 :                     .filter(itm => this.state.selectedVisibility[itm[0]])
    1890           11 :                     .map(itm => itm[0])
    1891              :         );
    1892              : 
    1893           11 :         return (
    1894           11 :             <div className="metrics" style={{ "--graph-cnt": selections.length }}>
    1895           11 :                 <PageGroup stickyOnBreakpoint={{ default: 'top' }}>
    1896           11 :                     <section className="metrics-heading">
    1897           11 :                         <Flex className="metrics-selectors" spaceItems={{ default: 'spaceItemsSm' }}>
    1898           11 :                             <SimpleSelect
    1899           11 :                                 onSelect={this.handleSelect}
    1900           11 :                                 selected={this.state.selectedDate}
    1901           11 :                                 options={options}
    1902           11 :                                 isScrollable
    1903           11 :                                 toggleProps={{
    1904           11 :                                     id: "date-picker-select-toggle",
    1905           11 :                                     className: "select-min metrics-label",
    1906           11 :                                     "aria-label": _("Jump to")
    1907           11 :                                 }} />
    1908           11 :                             <CheckboxSelect
    1909           11 :                                 toggleProps={{
    1910           11 :                                     "aria-label": _("Graph visibility options menu"),
    1911           11 :                                     className: "select-min metrics-label",
    1912           11 :                                 }}
    1913           11 :                                 toggleContent={_("Graph visibility")}
    1914           11 :                                 noBadge
    1915            1 :                                 onSelect={(selection, checked) => {
    1916            1 :                                     this.setState(prevState => ({
    1917            1 :                                         selectedVisibility: { ...prevState.selectedVisibility, [selection]: checked }
    1918            1 :                                     }));
    1919            1 :                                 }}
    1920           11 :                                 selected={selections}
    1921           11 :                                 options={columnVisibilityMenuItems} />
    1922           11 :                         </Flex>
    1923           11 :                         <Stack className="metrics-label-graph-mobile">
    1924           11 :                             {[["cpu", _("CPU usage/load")], ["memory", _("Memory usage/swap")], ["disks", _("Disk I/O")], ["network", _("Network")]]
    1925           11 :                                     .filter(itm => this.state.selectedVisibility[itm[0]])
    1926           11 :                                     .map(itm => (
    1927           11 :                                         <Flex key={itm[0]} flexWrap={{ default: 'nowrap' }} spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsBaseline' }}>
    1928           11 :                                             <div className={"square label-" + itm[0]} />
    1929           11 :                                             <FlexItem>{itm[1]}</FlexItem>
    1930           11 :                                         </Flex>
    1931           11 :                                     ))}
    1932           11 :                         </Stack>
    1933           11 :                         <div className="metrics-graphs metrics-heading-graphs">
    1934           11 :                             {this.state.selectedVisibility.cpu && <Label label={_("CPU")} items={[_("Usage"), _("Load")]} />}
    1935           11 :                             {this.state.selectedVisibility.memory && <Label label={_("Memory")} items={[_("Usage"), ...swapTotal ? [_("Swap")] : []]} />}
    1936           11 :                             {this.state.selectedVisibility.disks && <Label label={_("Disk I/O")} items={[_("Usage")]} />}
    1937           11 :                             {this.state.selectedVisibility.network && <Label label={_("Network")} items={[_("Usage")]} />}
    1938           11 :                         </div>
    1939           11 :                     </section>
    1940           11 :                 </PageGroup>
    1941           11 :                 <PageSection hasBodyWrapper={false} className="metrics-history-section">
    1942           11 :                     <>
    1943           11 :                         { this.state.hours.length > 0 &&
    1944            5 :                         <Card isPlain>
    1945            5 :                             <CardBody className="metrics-history">
    1946            5 :                                 { this.state.hours.map((time, i) => {
    1947            5 :                                     const date_time = new Date(time);
    1948            5 :                                     const boot_minutes = this.state.boots.filter(reboot => reboot.started.getDay() === date_time.getDay() &&
    1949            3 :                                                                                            reboot.started.getYear() === date_time.getYear() &&
    1950            3 :                                                                                            reboot.started.getHours() === date_time.getHours())
    1951            3 :                                             .map(reboot => reboot.started.getMinutes());
    1952            1 :                                     const showHeader = i == 0 || timeformat.date(time) != timeformat.date(this.state.hours[i - 1]);
    1953              : 
    1954            5 :                                     return (
    1955            5 :                                         <React.Fragment key={timeformat.dateTime(time)}>
    1956            5 :                                             {showHeader && <Content><Content component={ContentVariants.h3} className="metrics-time"><time>{ timeformat.date(time) }</time></Content></Content>}
    1957            5 :                                             <MetricsHour key={time} startTime={parseInt(time)}
    1958            5 :                                                          selectedVisibility={this.state.selectedVisibility}
    1959            5 :                                                          data={this.data[time]} clipLeading={i == 0}
    1960            5 :                                                          boots={boot_minutes} />
    1961            5 :                                         </React.Fragment>
    1962              :                                     );
    1963            5 :                                 })}
    1964            5 :                             </CardBody>
    1965            5 :                         </Card> }
    1966           11 :                         {nodata_alert}
    1967           11 :                         <div className="bottom-panel">
    1968           11 :                             { this.state.loading
    1969           11 :                                 ? <EmptyStatePanel loading title={_("Loading...")} />
    1970            5 :                                 : <Button onClick={this.handleMoreData}>{_("Load earlier data")}</Button> }
    1971           11 :                         </div>
    1972           11 :                     </>
    1973           11 :                 </PageSection>
    1974           11 :             </div>
    1975              :         );
    1976           11 :     }
    1977           11 : }
    1978              : 
    1979           11 : export const Application = () => {
    1980           11 :     const [firewalldRequest, setFirewalldRequest] = useState(null);
    1981              : 
    1982           11 :     return (
    1983           11 :         <WithDialogs>
    1984           11 :             <Page className="pf-m-no-sidebar">
    1985           11 :                 <PageBreadcrumb
    1986           11 :                     id='metrics-header-section'
    1987           11 :                     hasBodyWrapper={false}
    1988           11 :                     stickyOnBreakpoint={{ default: "top" }}
    1989              :                 >
    1990           11 :                     <Flex>
    1991           11 :                         <FlexItem>
    1992           11 :                             <Breadcrumb>
    1993            1 :                                 <BreadcrumbItem onClick={() => cockpit.jump("/system")} className="pf-v6-c-breadcrumb__link">{_("Overview")}</BreadcrumbItem>
    1994           11 :                                 <BreadcrumbItem isActive>{_("Metrics and history")}</BreadcrumbItem>
    1995           11 :                             </Breadcrumb>
    1996           11 :                         </FlexItem>
    1997           11 :                         <FlexItem align={{ default: 'alignRight' }}>
    1998           11 :                             <PCPConfig buttonVariant="secondary"
    1999           11 :                                              firewalldRequest={setFirewalldRequest} />
    2000           11 :                         </FlexItem>
    2001           11 :                     </Flex>
    2002           11 :                 </PageBreadcrumb>
    2003           11 :                 { firewalldRequest &&
    2004            2 :                 <FirewalldRequest service={firewalldRequest.service} title={firewalldRequest.title} pageSection /> }
    2005           11 :                 <PageSection hasBodyWrapper={false}>
    2006           11 :                     <CurrentMetrics />
    2007           11 :                 </PageSection>
    2008           11 :                 <MetricsHistory firewalldRequest={setFirewalldRequest} />
    2009           11 :             </Page>
    2010           11 :         </WithDialogs>);
    2011           11 : };
        

Generated by: LCOV version 2.0-1