🏡 index : quine-global-labs/mirror-cockpit-project-cockpit.git

/*
 * Copyright (C) 2017 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

import React, { useState, createRef } from 'react';

import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js";
import { Gallery } from "@patternfly/react-core/dist/esm/layouts/Gallery/index.js";
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { Grid, GridItem } from "@patternfly/react-core/dist/esm/layouts/Grid/index.js";
import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
import {
    Modal, ModalBody, ModalFooter, ModalHeader
} from '@patternfly/react-core/dist/esm/components/Modal/index.js';
import { Page, PageGroup, PageSection, PageBreadcrumb } from "@patternfly/react-core/dist/esm/components/Page/index.js";
import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
import { Progress, ProgressVariant } from "@patternfly/react-core/dist/esm/components/Progress/index.js";
import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
import { Table, Thead, Td, Th, Tr, Tbody, TableGridBreakpoint, TableVariant, TableText } from '@patternfly/react-table';
import {
    AngleRightIcon, AngleDownIcon, ExclamationTriangleIcon, ExclamationCircleIcon, CogIcon, ExternalLinkAltIcon,
    ResourcesFullIcon, ResourcesAlmostFullIcon, ResourcesAlmostEmptyIcon
} from '@patternfly/react-icons';

import cockpit from 'cockpit';
import * as machine_info from "../lib/machine-info.js";
import * as service from "service";
import * as timeformat from "timeformat";
import { superuser } from "superuser";
import { journal } from "journal";
import { read_os_release } from "os-release";
import { get_manifest_config_matchlist } from "utils";
import { useObject, useEvent, useInit } from "hooks.js";
import { WithDialogs, useDialogs } from "dialogs.jsx";

import { SimpleSelect } from "cockpit-components-simple-select.jsx";
import { CheckboxSelect } from "cockpit-components-checkbox-select.jsx";
import { EmptyStatePanel } from "../lib/cockpit-components-empty-state.jsx";
import { JournalOutput } from "cockpit-components-logs-panel.jsx";
import { install_dialog } from "cockpit-components-install-dialog.jsx";
import { ModalError } from "cockpit-components-inline-notification.jsx";
import { FirewalldRequest } from "cockpit-components-firewalld-request.jsx";

import "./metrics.scss";
import "journal.css";
import { getPackageManager } from 'packagemanager.js';

const MSEC_PER_H = 3600000;
const INTERVAL = 5000;
const SAMPLES_PER_H = MSEC_PER_H / INTERVAL;
const SAMPLES_PER_MIN = SAMPLES_PER_H / 60;
const SVG_YMAX = (SAMPLES_PER_MIN - 1);
const LOAD_HOURS = 12;
const _ = cockpit.gettext;

// format Date as YYYY-MM-DD HH:mm:ss UTC which is human friendly and systemd compatible
const formatUTC_ISO = t => `${t.getUTCFullYear()}-${t.getUTCMonth() + 1}-${t.getUTCDate()} ${t.getUTCHours()}:${t.getUTCMinutes()}:${t.getUTCSeconds()} UTC`;

// podman's containers cgroup
const podmanCgroupRe = /libpod-(?<containerid>[a-z|0-9]{64})\.scope$/;
// cgroup userid
const useridCgroupRe = /user-(?<userid>\d+).slice/;

// keep track of maximum values for unbounded data, so that we can normalize it properly
// pre-init them to avoid inflating noise
let scaleSatCPU = 4;
let scaleUseDisks = 10000; // KB/s
let scaleUseNetwork = 100000; // B/s

let numCpu = 1;
let memTotal; // bytes
let swapTotal; // bytes, can be undefined

const machine_info_promise = machine_info.cpu_ram_info();
machine_info_promise.then(info => {
    numCpu = info.cpus;
    memTotal = info.memory;
    swapTotal = info.swap;
});

// round up to the nearest number that has all zeroes except for the first digit
// avoids over-aggressive scaling, but needs scaling more often
const scaleForValue = x => {
    const scale = Math.pow(10, Math.floor(Math.log10(x)));
    // this can be tweaked towards "less rescaling" with an additional scalar, like "x * 1.5 / scale"
    return Math.ceil(x / scale) * scale;
};

const RESOURCES = {
    use_cpu: {
        name: _("CPU usage"),
        event_description: _("CPU"),
        // all in msec/s
        normalize: ([nice, user, sys]) => (nice + user + sys) / 1000 / numCpu,
        format: ([nice, user, sys]) => `${_("nice")}: ${Math.round(nice / 10)}%, ${_("user")}: ${Math.round(user / 10)}%, ${_("sys")}: ${Math.round(sys / 10)}%`,
    },
    sat_cpu: {
        name: _("Load"),
        event_description: _("Load"),
        // unitless, unbounded, dynamic scaling for normalization
        normalize: load => Math.min(load, scaleSatCPU) / scaleSatCPU,
        format: load => cockpit.format_number(load),
    },
    use_memory: {
        name: _("Memory usage"),
        event_description: _("Memory"),
        // assume used == total - available
        normalize: ([totalKiB, availKiB]) => 1 - (availKiB / totalKiB),
        format: ([totalKiB, availKiB]) => `${cockpit.format_bytes((totalKiB - availKiB) * 1024)} / ${cockpit.format_bytes(totalKiB * 1024)}`,
    },
    sat_memory: {
        name: _("Swap out"),
        event_description: _("Swap"),
        // page/s, unbounded, and mostly 0; just categorize into "nothing" (most of the time),
        // "a little" (< 1000 pages), and "a lot" (> 1000 pages)
        normalize: swapout => swapout > 1000 ? 1 : (swapout > 1 ? 0.3 : 0),
        format: swapout => cockpit.format(cockpit.ngettext("$0 page", "$0 pages", Math.floor(swapout)), Math.floor(swapout)),
    },
    use_disks: {
        name: _("Disk I/O"),
        event_description: _("Disk I/O"),
        // KiB/s, unbounded, dynamic scaling for normalization
        normalize: KiBps => KiBps / scaleUseDisks,
        format: KiBps => cockpit.format_bytes_per_sec(KiBps * 1024),
    },
    use_network: {
        name: _("Network I/O"),
        event_description: _("Network I/O"),
        // B/s, unbounded, dynamic scaling for normalization
        normalize: bps => bps / scaleUseNetwork,
        format: bps => cockpit.format_bytes_per_sec(bps),
    },
};

const CURRENT_METRICS = [
    { name: "cpu.basic.user", derive: "rate" },
    { name: "cpu.basic.system", derive: "rate" },
    { name: "cpu.basic.nice", derive: "rate" },
    { name: "memory.used" },
    { name: "memory.swap-used" },
    { name: "disk.all.read", units: "bytes", derive: "rate" },
    { name: "disk.all.written", units: "bytes", derive: "rate" },
    { name: "network.interface.rx", units: "bytes", derive: "rate" },
    { name: "network.interface.tx", units: "bytes", derive: "rate" },
    { name: "cgroup.cpu.usage", derive: "rate" },
    { name: "cgroup.memory.usage" },
    { name: "cpu.core.user", derive: "rate" },
    { name: "cpu.core.system", derive: "rate" },
    { name: "cpu.core.nice", derive: "rate" },
    { name: "disk.dev.read", units: "bytes", derive: "rate" },
    { name: "disk.dev.written", units: "bytes", derive: "rate" },
    { name: "mount.total", units: "bytes" },
    { name: "mount.used", units: "bytes" },
];

const CPU_TEMPERATURE_METRICS = [
    { name: "cpu.temperature" },
];

const PRIVILEGED_METRICS = [
    { name: "disk.cgroup.read", units: "bytes", derive: "rate" },
    { name: "disk.cgroup.written", units: "bytes", derive: "rate" },
];

const HISTORY_METRICS = [
    // CPU utilization
    { name: "kernel.all.cpu.nice", derive: "rate" },
    { name: "kernel.all.cpu.user", derive: "rate" },
    { name: "kernel.all.cpu.sys", derive: "rate" },

    // CPU saturation
    { name: "kernel.all.load" },

    // memory utilization (unit: KiB)
    { name: "mem.physmem" },
    // mem.util.used is useless, it includes cache (unit: KiB)
    { name: "mem.util.available" },

    // memory saturation
    { name: "swap.pagesout", derive: "rate" },

    // disk utilization; despite the name, the unit is in KiB! (pminfo -d -F disk.all.total_bytes)
    { name: "disk.all.total_bytes", derive: "rate" },

    // network utilization
    { name: "network.interface.total.bytes", derive: "rate" },
];

function debug() {
    if (window.debugging === "all" || window.debugging?.includes("metrics"))
        console.debug.apply(console, arguments);
}

// metrics channel samples are compressed, see
// https://github.com/cockpit-project/cockpit/blob/main/doc/protocol.md#payload-metrics1
// samples is the compressed metrics channel value, state the last valid values (initialize once to empty array)
function decompress_samples(samples, state) {
    samples.forEach((sample, i) => {
        if (sample instanceof Array) {
            if (!state[i]) // uninitialized, create empty array
                state[i] = [];
            sample.forEach((inst, k) => {
                if (typeof inst === 'number')
                    state[i][k] = inst;
            });
        } else if (typeof sample === 'number') {
            state[i] = sample;
        }
    });
}

function make_rows(rows, rowProps, columnLabels) {
    return rows.map((columns, rowIndex) =>
        <Tr key={"row-" + rowIndex} {...rowProps?.(columns)}>
            {columns.map((column, columnIndex) =>
                <Td data-label={columnLabels?.[columnIndex]} key={"column-" + columnIndex}>
                    {column}
                </Td>
            )}
        </Tr>
    );
}

async function get_pcp_packages() {
    const os_release = await read_os_release();
    const pcp_packages = ["pcp"];

    // PCP contains the Python module on Arch Linux, for all other distro's it is split up.
    if (os_release.ID !== "arch") {
        pcp_packages.push("python3-pcp");
    }

    return pcp_packages;
}

class CurrentMetrics extends React.Component {
    constructor(props) {
        super(props);

        this.metrics_channel = null;
        this.temperature_channel = null;
        this.privileged_channel = null;
        this.samples = [];
        this.temperatureSamples = [];
        this.privilegedSamples = [];
        this.netInterfacesNames = [];
        this.cgroupCPUNames = [];
        this.cgroupMemoryNames = [];
        this.cgroupDiskNames = [];
        this.disksNames = [];
        this.cpuTemperature = null;
        this.cpuTemperatureColors = {
            textColor: "",
            iconColor: "",
            icon: null,
        };

        this.state = {
            userid: null,
            memUsed: 0, // bytes
            swapUsed: null, // bytes
            cpuUsed: 0, // percentage
            cpuCoresUsed: [], // [ percentage ]
            loadAvg: null, // [ 1min, 5min, 15min ]
            disksRead: 0, // B/s
            disksWritten: 0, // B/s
            mounts: [], // [{ target (string), use (percent), avail (bytes) }]
            netInterfacesRx: [],
            netInterfacesTx: [],
            topServicesCPU: [], // [ { name, percent } ]
            topServicesMemory: [], // [ { name, bytes } ]
            topServicesDiskIO: [], // [ [ name, read, write ] ]
            podNameMapping: {}, // { uid -> containerid -> name }
        };

        this.onVisibilityChange = this.onVisibilityChange.bind(this);
        this.onMetricsUpdate = this.onMetricsUpdate.bind(this);
        this.onTemperatureUpdate = this.onTemperatureUpdate.bind(this);
        this.onPrivilegedMetricsUpdate = this.onPrivilegedMetricsUpdate.bind(this);
        this.updateLoad = this.updateLoad.bind(this);

        cockpit.addEventListener("visibilitychange", this.onVisibilityChange);
        this.onVisibilityChange();

        // there is no internal metrics channel for load yet; see https://github.com/cockpit-project/cockpit/pull/14510
        this.updateLoad();
    }

    componentDidMount() {
        superuser.addEventListener("changed", () => this.setState({ podNameMapping: {} }));
        cockpit.user().then(user => this.setState({ userid: user.id }));
    }

    onVisibilityChange() {
        if (cockpit.hidden && this.temperature_channel !== null) {
            this.temperature_channel.removeEventListener("message", this.onTemperatureUpdate);
            this.temperature_channel.close();
            this.temperature_channel = null;
        }

        if (cockpit.hidden && this.privileged_channel !== null) {
            this.privileged_channel.removeEventListener("message", this.onPrivilegedMetricsUpdate);
            this.privileged_channel.close();
            this.privileged_channel = null;
        }

        if (cockpit.hidden && this.metrics_channel !== null) {
            this.metrics_channel.removeEventListener("message", this.onMetricsUpdate);
            this.metrics_channel.close();
            this.metrics_channel = null;
            return;
        }

        if (!cockpit.hidden && (this.temperature_channel === null)) {
            this.temperature_channel = cockpit.channel({ payload: "metrics1", source: "internal", interval: INTERVAL, metrics: CPU_TEMPERATURE_METRICS });
            this.temperature_channel.addEventListener("close", (ev, error) => console.warn("CPU temperature metric closed:", error));
            this.temperature_channel.addEventListener("message", this.onTemperatureUpdate);
        }

        // requires sudo access in order to sample every cgroup
        // limited access only allows sampling of current user's cgroups
        if (!cockpit.hidden && (this.privileged_channel === null)) {
            this.privileged_channel = cockpit.channel({ superuser: "try", payload: "metrics1", source: "internal", interval: INTERVAL, metrics: PRIVILEGED_METRICS });
            this.privileged_channel.addEventListener("message", this.onPrivilegedMetricsUpdate);
        }

        if (!cockpit.hidden && this.metrics_channel === null) {
            this.metrics_channel = cockpit.channel({ payload: "metrics1", source: "internal", interval: INTERVAL, metrics: CURRENT_METRICS });
            this.metrics_channel.addEventListener("message", this.onMetricsUpdate);
        }
    }

    updateLoad() {
        cockpit.file("/proc/loadavg").read()
                .then(content => {
                    // format: three load averages, then process counters; e.g.: 0.67 1.00 0.78 2/725 87151
                    this.setState({ loadAvg: content.split(' ').slice(0, 3) });
                    // update it again regularly
                    window.setTimeout(this.updateLoad, 5000);
                })
                .catch(ex => {
                    console.warn("Failed to read /proc/loadavg:", ex.toString());
                    this.setState({ loadAvg: null });
                });
    }

    onTemperatureUpdate(event, message) {
        debug("current CPU temperature  message", message);
        const data = JSON.parse(message);

        if (!Array.isArray(data)) {
            return;
        }

        data.forEach(temperatureSamples => decompress_samples(temperatureSamples, this.temperatureSamples));

        if (this.temperatureSamples[0].length > 0) {
            this.cpuTemperature = Math.round(Math.max(...this.temperatureSamples[0]));
        } else {
            // close the channel when bridge couldn't sample temperature
            this.temperature_channel.close("No samples received");
            return;
        }

        if (this.cpuTemperature <= 80) {
            this.cpuTemperatureColors.textColor = "";
            this.cpuTemperatureColors.iconColor = "";
            this.cpuTemperatureColors.icon = null;
        } else if (this.cpuTemperature < 95) {
            this.cpuTemperatureColors.textColor = "text-color-warning";
            this.cpuTemperatureColors.iconColor = "icon-color-warning";
            this.cpuTemperatureColors.icon = <ExclamationTriangleIcon />;
        } else {
            this.cpuTemperatureColors.textColor = "text-color-critical";
            this.cpuTemperatureColors.iconColor = "icon-color-critical";
            this.cpuTemperatureColors.icon = <ExclamationCircleIcon />;
        }
    }

    onMetricsUpdate(event, message) {
        debug("current metrics message", message);
        const data = JSON.parse(message);

        // reset state on meta messages
        if (!Array.isArray(data)) {
            this.samples = [];
            console.assert(data.metrics[7].name === 'network.interface.rx');
            this.netInterfacesNames = data.metrics[7].instances.slice();
            console.assert(data.metrics[9].name === 'cgroup.cpu.usage');
            this.cgroupCPUNames = data.metrics[9].instances.slice();
            this.cgroupMemoryNames = data.metrics[10].instances.slice();
            console.assert(data.metrics[14].name === 'disk.dev.read');
            this.disksNames = data.metrics[14].instances.slice();
            console.assert(data.metrics[16].name === 'mount.total');
            this.mountPoints = data.metrics[16].instances.slice();
            debug("metrics message was meta, new net instance names", JSON.stringify(this.netInterfacesNames));
            return;
        }

        data.forEach(samples => decompress_samples(samples, this.samples));

        const newState = {};
        // CPU metrics are in ms/s; divide by 10 to get percentage
        if (typeof this.samples[0] === 'number') {
            const cpu = Math.round((this.samples[0] + this.samples[1] + this.samples[2]) / 10 / numCpu);
            newState.cpuUsed = cpu;
        }

        newState.memUsed = this.samples[3];
        newState.swapUsed = this.samples[4];

        if (typeof this.samples[5] === 'number')
            newState.disksRead = this.samples[5];
        if (typeof this.samples[6] === 'number')
            newState.disksWritten = this.samples[6];

        newState.netInterfacesRx = this.samples[7];
        newState.netInterfacesTx = this.samples[8];

        // Collect CPU cores
        newState.cpuCoresUsed = [];
        if (this.samples[11] && this.samples[11].length === this.samples[12].length && this.samples[12].length === this.samples[13].length) {
            for (let i = 0; i < this.samples[11].length; i++) {
                // CPU cores metrics are in ms/s; divide by 10 to get percentage
                newState.cpuCoresUsed[i] = Math.round((this.samples[11][i] + this.samples[12][i] + this.samples[13][i]) / 10);
            }
        }

        // return [ { [key, value, is_user, is_container, userid | cgroup] } ] list of the biggest n values
        const n_biggest = (names, values, n) => {
            const merged = [];
            names.forEach((k, i) => {
                const v = values[i];
                // filter out invalid values, the empty (root) cgroup, non-services
                if (k.endsWith('.service') && typeof v === 'number' && v !== 0) {
                    const is_user = k.match(/^user.*user@\d+\.service.+/);
                    const label = k.replace(/.*\//, '').replace(/\.service$/, '');
                    // only keep cgroup basenames, and drop redundant .service suffix
                    merged.push([label, v, is_user, false, k]);
                }
                // filter out podman containers, but only for the logged in
                // user or root user if the user is privileged. Other users
                // containers will show up under the user@uid cgroup
                const matches = k.match(podmanCgroupRe);
                if (matches && v) {
                    let is_user = false;
                    let uid = 0;
                    const containerid = matches.groups.containerid;
                    const umatches = k.match(useridCgroupRe);
                    if (umatches) {
                        is_user = true;
                        uid = parseInt(umatches.groups.userid);
                    }

                    if (uid === 0 || this.state.userid === uid) {
                        merged.push([containerid, v, is_user, true, uid]);
                    }
                }
            });
            merged.sort((a, b) => b[1] - a[1]);
            return merged.slice(0, n);
        };

        // top 5 CPU and memory consuming systemd units
        const topServicesCPU = n_biggest(this.cgroupCPUNames, this.samples[9], 5);
        newState.topServicesCPU = topServicesCPU.map(
            ([key, value, is_user, is_container, userid]) => this.cgroupRow(key, is_user, is_container, userid, Number(value / 10 / numCpu).toFixed(1)) // usec/s → percent
        );

        const topServicesMemory = n_biggest(this.cgroupMemoryNames, this.samples[10], 5);
        newState.topServicesMemory = topServicesMemory.map(
            ([key, value, is_user, is_container, userid]) => this.cgroupRow(key, is_user, is_container, userid, cockpit.format_bytes(value))
        );

        const notMappedContainers = topServicesMemory.concat(topServicesCPU).filter(([key, value, is_user, is_container, userid]) => is_container && this.getCachedPodName(userid, key) === undefined);
        if (notMappedContainers.length !== 0) {
            this.update_podman_name_mapping(notMappedContainers);
        }

        const mountsTotal = this.samples[16];
        const mountsUsed = this.samples[17];
        newState.mounts = mountsTotal.map((mountTotal, i) => {
            return {
                target: this.mountPoints[i],
                size: mountTotal,
                avail: mountTotal - mountsUsed[i],
                use: Math.round(mountsUsed[i] / mountTotal * 100),
            };
        });

        this.setState(newState);
    }

    onPrivilegedMetricsUpdate(event, message) {
        debug("process metrics message", message);
        const data = JSON.parse(message);

        if (!Array.isArray(data)) {
            this.cgroupDiskNames = data.metrics[0].instances.slice();
            return;
        }

        data.forEach(privilegedSamples => decompress_samples(privilegedSamples, this.privilegedSamples));

        // return [ name, read, write, isUser, isContainer, uid | cgroup ]
        const n_biggest = (n, names, valuesA, valuesB) => {
            const merged = [];
            const userSlices = {};
            names.forEach((name, i) => {
                // filter out invalid values, the empty (root) cgroup, non-services
                if (name.endsWith('.service')) {
                    // only keep cgroup basenames, and drop redundant .service suffix
                    const label = name.replace(/.*\//, '').replace(/\.service$/, '');
                    const isUser = name.match(useridCgroupRe);
                    merged.push([label, valuesA[i], valuesB[i], isUser, false, name]);
                    return;
                }

                // filter out podman containers, but only for the logged in
                // user or root user if the user is privileged. Other users
                // containers will show up under the user@uid cgroup
                const matches = name.match(/libpod-(?<containerid>[a-z|0-9]{64})\.scope/);
                if (matches && valuesA[i] !== undefined && valuesB[i] !== undefined) {
                    const containerid = matches.groups.containerid;
                    const umatches = name.match(useridCgroupRe);
                    const isUser = !!umatches;
                    const uid = parseInt(umatches?.groups.userid) || 0;

                    if (uid === 0 || this.state.userid === uid) {
                        merged.push([containerid, valuesA[i], valuesB[i], isUser, true, uid]);
                        return;
                    }
                }

                // combine user slices into user@ID
                // { name: [ read, write ] }
                const umatches = name.match(useridCgroupRe);
                if (umatches) {
                    if (userSlices[umatches.groups.userid] === undefined) {
                        userSlices[umatches.groups.userid] = [valuesA[i], valuesB[i]];
                    } else {
                        userSlices[umatches.groups.userid][0] += valuesA[i];
                        userSlices[umatches.groups.userid][1] += valuesB[i];
                    }
                }
            });

            Object.keys(userSlices).forEach((key) => {
                merged.push(["user@" + key, userSlices[key][0], userSlices[key][1], false, false]);
            });

            // sort by overall (read + write) disk usage
            merged.sort((a, b) => (b[1] + b[2]) - (a[1] + a[2]));
            return merged.slice(0, n);
        };

        const newState = {};

        const topServicesDiskIO = n_biggest(5, this.cgroupDiskNames, this.privilegedSamples[0], this.privilegedSamples[1]);
        newState.topServicesDiskIO = topServicesDiskIO.filter(([_, read, write, ..._rest]) => read !== 0 || write !== 0).map(([name, read, write, isUser, isContainer, uid]) => {
            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);
        });

        this.setState(newState);
    }

    getCachedPodName = (uid, containerid) => this.state.podNameMapping[uid] && this.state.podNameMapping[uid][containerid];

    cgroupRow = (name, is_user, is_container, uid, ...values) => {
        const podman_installed = cockpit.manifests?.podman;

        const cgroupClickHandler = (name, isUser, isContainer, uid) => {
            if (isContainer) {
                const containerName = this.getCachedPodName(uid, name);
                if (containerName) {
                    cockpit.jump("/podman#/?name=" + containerName);
                } else {
                    cockpit.jump("/podman");
                }
            } else {
                cockpit.jump("/system/services#/" + name + ".service" + (isUser ? "?owner=user" : ""));
            }
        };

        let name_text = (
            <Button variant="link" isInline isBlock component="a" key={name}
                    onClick={() => cgroupClickHandler(name, is_user, is_container, uid)}
                    isDisabled={is_container && !podman_installed}>
                <TableText wrapModifier="truncate">
                    {is_container ? _("pod") + " " + (this.getCachedPodName(uid, name) || name.substring(0, 12)) : name}
                </TableText>
            </Button>
        );
        if (is_container && !podman_installed) {
            name_text = (
                <Tooltip content={_("cockpit-podman is not installed")} key={name + "_tooltip"}>
                    <div>
                        {name_text}
                    </div>
                </Tooltip>);
        }

        const values_text = values.map((value, idx) => {
            return <TableText key={idx} wrapModifier="nowrap">{value}</TableText>;
        });

        return [name_text, ...values_text];
    };

    /**
     * Look up the container names using podman ps for the given cgroups.
     */
    update_podman_name_mapping = cgroups => {
        // New mapping state
        const podNameMapping = {};

        const promises = cgroups.map(([containerid, value, is_user, is_container, userid]) => {
            if (!(userid in podNameMapping)) {
                podNameMapping[userid] = {};
            }
            // Always initialize the cache for when we hit an error.
            podNameMapping[userid][containerid] = null;

            if ((userid === 0 && !superuser.allowed) && userid !== this.state.userid) {
                return null;
            }
            return cockpit.spawn(["podman", "ps", "--format", "json"], { superuser: userid === 0 ? "required" : null })
                    .then(result => [result, userid]);
        }).filter(prom => prom !== null);

        Promise.all(promises).then(results => {
            for (const [output, uid] of results) {
                try {
                    const containers = JSON.parse(output);
                    for (const container of containers) {
                        podNameMapping[uid][container.Id] = container.Names[0];
                    }
                } catch (err) {
                    console.error("podman ps outputs invalid JSON", err.toString());
                }
            }
        })
                .catch(err => console.error("could not obtain podman names:", err))
                .finally(() => this.setState(prevState => ({ podNameMapping: { ...prevState.podNameMapping, ...podNameMapping } })));
    };

    render() {
        const memUsedFraction = memTotal ? this.state.memUsed / memTotal : 0;
        const memAvail = memTotal ? (memTotal - this.state.memUsed) : 0;
        const num_cpu_str = cockpit.format(cockpit.ngettext("$0 CPU", "$0 CPUs", numCpu), numCpu);

        const netIO = this.netInterfacesNames.map((iface, i) => [
            <Button variant="link" isInline onClick={() => cockpit.jump(`/network#/${iface}`) } key={iface}>{iface}</Button>,
            this.state.netInterfacesRx[i] >= 1 ? cockpit.format_bytes_per_sec(this.state.netInterfacesRx[i]) : "0",
            this.state.netInterfacesTx[i] >= 1 ? cockpit.format_bytes_per_sec(this.state.netInterfacesTx[i]) : "0",
        ]);

        let swapProgress;

        if (swapTotal) {
            const swapUsedFraction = this.state.swapUsed / swapTotal;
            const swapAvail = swapTotal - this.state.swapUsed;
            swapProgress = (
                <Tooltip content={ cockpit.format(_("$0 total"), cockpit.format_bytes(swapTotal)) } position="bottom">
                    <Progress
                        id="current-swap-usage"
                        title={ _("Swap") }
                        value={this.state.swapUsed}
                        className="pf-m-sm"
                        min={0} max={swapTotal}
                        variant={swapUsedFraction > 0.9 ? ProgressVariant.danger : swapUsedFraction >= 0.8 ? ProgressVariant.warning : null}
                        label={ cockpit.format(_("$0 available"), cockpit.format_bytes(swapAvail)) } />
                </Tooltip>);
        }

        let cores = null;
        let topCore = null;
        let allCpus = null;
        let cpu_label = null;
        if (this.state.cpuCoresUsed.length > 1) {
            const top_cores = this.state.cpuCoresUsed.map((v, i) => [i, v]).sort((a, b) => b[1] - a[1])
                    .slice(0, 16);
            cores = (<Grid className='cpu-all' component='dl'>
                {top_cores.map(c =>
                    <React.Fragment key={c[0]}>
                        <GridItem component='dt'>{ cockpit.format(_("Core $0"), c[0]) }</GridItem>
                        <GridItem component='dd'>{c[1]}%</GridItem>
                    </React.Fragment>)
                }
            </Grid>);

            cpu_label = (
                <Flex spaceItems={{ default: 'spaceItemsNone' }} justifyContent={{ default: 'justifyContentFlexEnd' }}>
                    <FlexItem>&nbsp;{ cockpit.format(_("average: $0%"), this.state.cpuUsed) }</FlexItem>
                    <FlexItem>&nbsp;{ cockpit.format(_("max: $0%"), top_cores[0][1]) }</FlexItem>
                </Flex>);

            topCore = <Progress
                           aria-label={_("Current top CPU usage")}
                           id="current-top-cpu-usage"
                           value={top_cores[0][1]}
                           className="current-top-cpu-usage pf-m-sm"
                           min={0} max={100}
                           variant={ top_cores[0][1] > 90 ? ProgressVariant.danger : top_cores[0][1] >= 80 ? ProgressVariant.warning : ProgressVariant.info }
                           measureLocation="none" />;

            allCpus = (
                <Popover minWidth={0} aria-label={ _("View all CPUs") } bodyContent={cores}>
                    <Button variant="link" className='pf-v6-u-font-size-sm'>{ _("View all CPUs") }</Button>
                </Popover>);
        } else {
            cpu_label = this.state.cpuUsed + '%';
        }

        const disksUsage = (this.disksNames.length > 0 && this.samples[14] && this.samples[15])
            ? (
                this.disksNames.map((name, i) => [
                    name,
                    this.samples[14][i] >= 1 ? cockpit.format_bytes_per_sec(this.samples[14][i]) : "0",
                    this.samples[15][i] >= 1 ? cockpit.format_bytes_per_sec(this.samples[15][i]) : "0",
                ])
            )
            : [];

        let allDisks = null;
        const rowPropsDisks = row => ({ 'device-name': row[0] });
        const diskColumns = [_("Device"), _("Read"), _("Write")];
        if (disksUsage.length > 1) {
            const disksTableContent = (
                <Table
                    variant={TableVariant.compact}
                    gridBreakPoint={TableGridBreakpoint.gridLg}
                    borders={false}
                    aria-label={ _("Disks usage") }>
                    <Thead>
                        <Tr>{diskColumns.map(col => <Th key={col}>{col}</Th>)}</Tr>
                    </Thead>
                    <Tbody className="pf-v6-m-tabular-nums disks-nowrap">
                        {make_rows(disksUsage, rowPropsDisks, diskColumns)}
                    </Tbody>
                </Table>
            );

            allDisks = (
                <Popover minWidth={0} aria-label={ _("View all disks") } bodyContent={disksTableContent}>
                    <Button variant="link" className='pf-v6-u-font-size-sm'>{ _("View per-disk throughput") }</Button>
                </Popover>
            );
        }

        // first element is the jump button, key is interface name
        const rowPropsIface = row => ({ 'data-interface': row[0].key });
        const rowPropsDiskIO = row => ({ 'cgroup-name': row[0] });
        const topServicesCPUColumns = [_("Service"), "%"];
        const topServicesMemoryColumns = [_("Service"), _("Used")];
        const ifaceColumns = [_("Interface"), _("In"), _("Out")];

        return (
            <Gallery className="current-metrics" hasGutter>
                <Card id="current-metrics-card-cpu">
                    <CardHeader className='align-baseline'>
                        <CardTitle>{ _("CPU") }</CardTitle>
                        { this.cpuTemperature !== null &&
                        <span className="temperature">
                            <span className={this.cpuTemperatureColors.iconColor}>
                                {this.cpuTemperatureColors.icon}
                            </span>
                            &nbsp;
                            <span className={this.cpuTemperatureColors.textColor}>
                                { cockpit.format("$0 °C", this.cpuTemperature) }
                            </span>
                        </span> }
                    </CardHeader>
                    <CardBody>
                        <div className="progress-stack-no-space">
                            <Progress
                                id="current-cpu-usage"
                                value={this.state.cpuUsed}
                                className="current-cpu-usage pf-m-sm"
                                min={0} max={100}
                                variant={ this.state.cpuUsed > 90 ? ProgressVariant.danger : this.state.cpuUsed >= 80 ? ProgressVariant.warning : null }
                                title={ num_cpu_str }
                                label={ cpu_label } />
                            {topCore}
                            {allCpus}
                        </div>

                        { this.state.loadAvg &&
                            <DescriptionList className="pf-m-horizontal-on-sm">
                                <DescriptionListGroup>
                                    <DescriptionListTerm>{ _("Load") }</DescriptionListTerm>
                                    <DescriptionListDescription id="load-avg">
                                        <Flex spaceItems={{ default: 'spaceItemsXs' }}>
                                            <FlexItem>{ _("1 min") }:&nbsp;{ this.state.loadAvg[0] },</FlexItem>
                                            <FlexItem>{ _("5 min") }:&nbsp;{ this.state.loadAvg[1] },</FlexItem>
                                            <FlexItem>{ _("15 min") }:&nbsp;{ this.state.loadAvg[2] }</FlexItem>
                                        </Flex>
                                    </DescriptionListDescription>
                                </DescriptionListGroup>
                            </DescriptionList> }

                        { this.state.topServicesCPU.length > 0 &&
                            <Table
                                variant={TableVariant.compact}
                                gridBreakPoint={TableGridBreakpoint.none}
                                borders={false}
                                aria-label={ _("Top 5 CPU services") }>
                                <Thead>
                                    <Tr>
                                        <Th width={80}>{_("Service")}</Th>
                                        <Th>%</Th>
                                    </Tr>
                                </Thead>
                                <Tbody>
                                    {make_rows(this.state.topServicesCPU, undefined, topServicesCPUColumns)}
                                </Tbody>
                            </Table> }
                    </CardBody>
                </Card>

                <Card>
                    <CardTitle>{ _("Memory") }</CardTitle>
                    <CardBody>
                        <div className="progress-stack">
                            <Tooltip
                                content={ cockpit.format(_("$0 total"), cockpit.format_bytes(memTotal)) }
                                position="bottom">
                                <Progress
                                    id="current-memory-usage"
                                    title={ _("RAM") }
                                    value={memTotal ? this.state.memUsed : undefined}
                                    className="pf-m-sm"
                                    min={0} max={memTotal}
                                    variant={memUsedFraction > 0.9 ? ProgressVariant.danger : memUsedFraction >= 0.8 ? ProgressVariant.warning : null}
                                    label={ memAvail ? cockpit.format(_("$0 available"), cockpit.format_bytes(memAvail)) : "" } />
                            </Tooltip>
                            {swapProgress}
                        </div>

                        { this.state.topServicesMemory.length > 0 &&
                            <Table
                                variant={TableVariant.compact}
                                gridBreakPoint={TableGridBreakpoint.none}
                                borders={false}
                                aria-label={ _("Top 5 memory services") }>
                                <Thead>
                                    <Tr>
                                        <Th width={80}>{_("Service")}</Th>
                                        <Th>{_("Used")}</Th>
                                    </Tr>
                                </Thead>
                                <Tbody>
                                    {make_rows(this.state.topServicesMemory, undefined, topServicesMemoryColumns)}
                                </Tbody>
                            </Table> }
                    </CardBody>
                </Card>

                <Card id="current-metrics-card-disks">
                    <CardTitle>{ _("Disks") }</CardTitle>
                    <CardBody>
                        <DescriptionList isHorizontal columnModifier={{ default: '2Col' }}>
                            <DescriptionListGroup>
                                <DescriptionListTerm>{ _("Read") }</DescriptionListTerm>
                                <DescriptionListDescription id="current-disks-read">{ this.state.disksRead >= 1 ? cockpit.format_bytes_per_sec(this.state.disksRead) : "0" }</DescriptionListDescription>
                            </DescriptionListGroup>
                            <DescriptionListGroup>
                                <DescriptionListTerm>{ _("Write") }</DescriptionListTerm>
                                <DescriptionListDescription id="current-disks-write">{ this.state.disksWritten >= 1 ? cockpit.format_bytes_per_sec(this.state.disksWritten) : "0" }</DescriptionListDescription>
                            </DescriptionListGroup>
                        </DescriptionList>
                        <div className="all-disks-no-gap">
                            {allDisks}
                        </div>
                        <div id="current-disks-usage" className="progress-stack"> {
                            this.state.mounts.map(info => {
                                let progress = (
                                    <Progress
                                        data-disk-usage-target={info.target}
                                        value={info.use} min={0} max={100}
                                        className="pf-m-sm"
                                        variant={info.use > 90 ? ProgressVariant.danger : info.use >= 80 ? ProgressVariant.warning : null}
                                        title={info.target}
                                        label={ cockpit.format(_("$0 free"), cockpit.format_bytes(info.avail)) } />
                                );
                                if (cockpit.manifests?.storage)
                                    progress = <Button variant="link" isInline onClick={() => cockpit.jump("/storage") }>{progress}</Button>;

                                return (
                                    <Tooltip
                                        key={info.target}
                                        content={ cockpit.format(_("$0 total"), cockpit.format_bytes(info.size)) }
                                        position="bottom">
                                        {progress}
                                    </Tooltip>
                                );
                            })
                        }
                        </div>
                        { this.state.topServicesDiskIO.length > 0 &&
                            <Table
                                variant={TableVariant.compact}
                                gridBreakPoint={TableGridBreakpoint.none}
                                borders={false}
                                aria-label={ _("Top 5 disk usage services") }>
                                <Thead>
                                    <Tr>
                                        <Th width={50}>{_("Service")}</Th>
                                        <Th>{_("Read")}</Th>
                                        <Th>{_("Write")}</Th>
                                    </Tr>
                                </Thead>
                                <Tbody className="pf-v6-m-tabular-nums">
                                    {make_rows(this.state.topServicesDiskIO, rowPropsDiskIO, [_("Service"), _("Read"), _("Write")])}
                                </Tbody>
                            </Table> }
                    </CardBody>
                </Card>

                <Card className="current-metrics-network">
                    <CardTitle>{ _("Network") }</CardTitle>
                    <CardBody>
                        <Table
                            variant={TableVariant.compact}
                            // FIXME: If we can make the table less wide, then we can switch from gridLg to none
                            // and (possibly) dropping (at least some of) the font size overrides
                            // this would require breaking out the units/s into its own row
                            gridBreakPoint={TableGridBreakpoint.gridLg}
                            borders={false}
                            aria-label={ _("Network usage") }>
                            <Thead>
                                <Tr>{ifaceColumns.map(col => <Th key={col}>{col}</Th>)}</Tr>
                            </Thead>
                            <Tbody className="network-nowrap-shrink">
                                {make_rows(netIO, rowPropsIface, ifaceColumns)}
                            </Tbody>
                        </Table>
                    </CardBody>
                </Card>
            </Gallery>
        );
    }
}

const SvgGraph = ({ data, resource, have_sat }) => {
    const dataPoints = key => (
        "0 0, " + // start polygon at (0, 0)
        data.map((samples, index) => (samples && typeof samples[key] === 'number') ? parseInt(samples[key] * 100).toString() + "% " + (((index / SVG_YMAX) * 100).toString() + "%") : "").join(", ") +
        ", 0 " + ((data.length - 1) / SVG_YMAX) * 100 + "%" // close polygon
    );

    return (
        <>
            <div
                 className="polygon polygon-use"
                 style={{ "--points": dataPoints("use_" + resource) }}
                 points={dataPoints("use_" + resource)}
            />
            { have_sat && <div
                className="polygon polygon-sat"
                style={{ "--points": dataPoints("sat_" + resource) }}
                points={dataPoints("sat_" + resource)}
            /> }
        </>
    );
};

class MetricsMinute extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            logs: null,
            logsUrl: null,
        };
        this.onHover = this.onHover.bind(this);
        this.findLogs = this.findLogs.bind(this);

        this.spikesButtonRef = createRef();
    }

    componentDidMount() {
        if (this.props.isExpanded && this.props.events)
            this.findLogs(this.props.events.start - 4, this.props.events.end + 4); // +- 20s
    }

    onHover(ev) {
        // FIXME - throttle debounce this
        const bounds = ev.target.getBoundingClientRect();
        const offsetY = (ev.clientY - bounds.y) / bounds.height;
        const indexOffset = Math.floor((1 - offsetY) * SAMPLES_PER_MIN);
        const sample = this.props.rawData[indexOffset];
        if (!sample)
            return;

        const time = this.props.startTime + this.props.minute * 60000 + indexOffset * INTERVAL;
        let tooltip = timeformat.timeSeconds(time) + "\n\n";
        Object.entries(sample).forEach(([t, v]) => {
            if (v !== null && v !== undefined)
                tooltip += `${RESOURCES[t].name}: ${RESOURCES[t].format(v)}\n`;
        });
        ev.target.setAttribute("title", tooltip);
    }

    findLogs(start, end) {
        const timestamp = this.props.startTime + (this.props.minute * 60000);
        const start_minute = Math.floor(start / SAMPLES_PER_MIN);
        const start_second = (start - (start_minute * SAMPLES_PER_MIN)) * (60 / SAMPLES_PER_MIN);
        const end_minute = Math.floor(end / SAMPLES_PER_MIN);
        const end_second = (end - (end_minute * SAMPLES_PER_MIN)) * (60 / SAMPLES_PER_MIN);

        const time = new Date(timestamp);
        time.setUTCMinutes(start_minute);
        time.setUTCSeconds(start_second);
        const since = formatUTC_ISO(time);

        time.setUTCMinutes(end_minute);
        time.setUTCSeconds(end_second);
        const until = formatUTC_ISO(time);

        const match = { priority: "info", since, until, follow: false, count: 10 };
        const journalctl = journal.journalctl(match);

        const out = new JournalOutput(match);
        out.render_day_header = () => { return null };
        const render = journal.renderer(out);

        journalctl.stream(entries => {
            entries.forEach(entry => render.prepend(entry));
            render.prepend_flush();
        })
                .then(() => {
                    let logsUrl;
                    if (out.logs.length === 0) {
                        // without logs, increase verbosity and time range (-15 mins to + 1 min)
                        const since = formatUTC_ISO(new Date(timestamp - 15 * 60000));
                        const until = formatUTC_ISO(new Date(timestamp + 60000));
                        logsUrl = `/system/logs/#/?priority=debug&since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}&follow=false`;
                    } else {
                        // with logs, show the exact minute and same log level as on the metrics page
                        logsUrl = `/system/logs/#/?priority=info&since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}&follow=false`;
                    }

                    this.setState({ logs: out.logs, logsUrl });
                });
    }

    render() {
        const first = this.props.data.find(i => i !== null);

        const graphs = Object.keys(this.props.selectedVisibility).filter(itm => this.props.selectedVisibility[itm]).map(resource => {
            // not all resources have a saturation metric
            let have_sat = !!RESOURCES["sat_" + resource];

            // If there is no swap, don't render it
            if (resource === "memory" && !swapTotal)
                have_sat = false;

            let graph = null;
            if (this.props.events) {
                // render full SVG graphs for "expanded" minutes with events
                graph = <SvgGraph key={resource} data={this.props.data} resource={resource} have_sat={have_sat} />;
            } else if (first) {
                // render simple bars for "compressed" minutes without events
                graph = <>
                    <div className="polygon-use compressed" style={{ "--utilization": first["use_" + resource] || 0 }} />
                    { have_sat && <div className="polygon-sat compressed" style={{ "--saturation": first["sat_" + resource] || 0 }} /> }
                </>;
            }

            return (
                <div
                    key={ resource + this.props.startTime + this.props.minute }
                    className={ ("metrics-data metrics-data-" + resource) + (first ? " valid-data" : " empty-data") + (have_sat ? " have-saturation" : "") }
                    aria-hidden="true"
                    { ...(this.props.isExpanded && { onMouseMove: this.onHover }) }
                >
                    {graph}
                </div>
            );
        });

        let desc;
        if (this.props.isExpanded && this.props.booted) {
            const timestamp = this.props.startTime + (this.props.minute * 60000);
            desc = (
                <div className="metrics-events">
                    <time>{ timeformat.time(timestamp) }</time>
                    <span className="spikes_count" />
                    <span className="spikes_info">
                        <span className="type">
                            {_("Boot")}
                        </span>
                    </span>
                </div>
            );
        } else if (this.props.isExpanded && this.props.events) {
            const timestamp = this.props.startTime + (this.props.minute * 60000);

            const logsPanel = (
                <>
                    {(this.state.logs?.length && this.state.logsUrl) && <Button variant="secondary" onClick={e => cockpit.jump(this.state.logsUrl)}>{_("View detailed logs")}</Button>}
                    <div className="cockpit-log-panel">
                        {this.state.logs?.length ? this.state.logs : _("No log entries")}
                    </div>
                </>
            );

            const resourceDesc = (
                <span className="type">
                    {this.props.events.events.map(t => RESOURCES[t].event_description).join(", ")}
                </span>
            );
            desc = (
                <>
                    <div className="metrics-events">
                        <time>{ timeformat.time(timestamp) }</time>
                        <span className="spikes_count" />
                        {this.state.logs?.length > 0
                            ? <Button
                                    ref={this.spikesButtonRef}
                                    variant="link" isInline
                                    className="spikes_info">
                                {resourceDesc}
                            </Button>
                            : <span className="spikes_info">{resourceDesc}</span>}
                    </div>
                    {this.state.logs?.length > 0 && <Popover position="right" hasAutoWidth className="metrics-events-popover" bodyContent={logsPanel} triggerRef={this.spikesButtonRef} />}
                </>
            );
        }

        return (
            <div className="metrics-minute" data-minute={this.props.minute}>
                { this.props.isExpanded && desc }
                <div className="metrics-graphs">
                    { graphs }
                </div>
            </div>
        );
    }
}

class MetricsHour extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            minuteGraphs: [],
            minute_events: {},
            isHourExpanded: false,
            dataItems: 0,
        };

        this.updateGraphs = this.updateGraphs.bind(this);
    }

    componentDidMount() {
        this.updateGraphs(this.props.data, this.props.startTime, this.props.selectedVisibility);
    }

    shouldComponentUpdate(nextProps, nextState) {
        if (this.state.dataItems !== nextProps.data.length ||
            this.state.isHourExpanded !== nextState.isHourExpanded ||
            this.props.startTime !== nextProps.startTime ||
            this.props.boots !== nextProps.boots ||
            Object.keys(this.props.selectedVisibility).some(itm => this.props.selectedVisibility[itm] !== nextProps.selectedVisibility[itm])) {
            this.updateGraphs(nextProps.data, nextProps.startTime, nextProps.selectedVisibility, nextState.isHourExpanded);
            return false;
        }

        return true;
    }

    // data: type → SAMPLES_PER_H objects from startTime
    updateGraphs(data, startTime, selectedVisibility, isHourExpanded) {
        const filteredData = data.map(sample => Object.keys(sample)
                .filter(key => selectedVisibility[key.split("_")[1]])
                .reduce((cur, key) => Object.assign(cur, { [key]: sample[key] }), {}));
        // Normalize data
        const normData = filteredData.map(sample => {
            if (sample === null)
                return null;
            const n = {};
            for (const type in sample)
                n[type] = (sample[type] !== null && sample[type] !== undefined) ? RESOURCES[type].normalize(sample[type]) : null;
            return n;
        });

        // Count minutes to render
        let minutes = 60;
        if (this.props.clipLeading) {
            // When clipping of empty leading minutes is allowed, find the highest 5 minute interval with valid data
            let m = 55;
            for (; m >= 0; m = m - 5) {
                const dataOffset = m * SAMPLES_PER_MIN;
                const dataSlice = normData.slice(dataOffset, dataOffset + SAMPLES_PER_MIN * 5);
                if (dataSlice.some(i => i !== null && i !== undefined))
                    break;
            }
            minutes = m + 5;
        }

        // Compute spike events
        const minute_events = {};
        for (const type in RESOURCES) {
            let prev_val = data[0] ? data[0][type] : null;
            normData.forEach((samples, i) => {
                if (samples === null)
                    return;
                const value = samples[type];
                // either high enough slope, or crossing the 80% threshold
                if (prev_val !== null && (value - prev_val > 0.25 || (prev_val < 0.75 && value >= 0.8))) {
                    const minute = Math.floor(i / SAMPLES_PER_MIN);
                    if (minute_events[minute] === undefined)
                        minute_events[minute] = { events: [], start: i - 1 };

                    minute_events[minute].end = i;

                    // For every minute show each type of event max once
                    if (minute_events[minute].events.indexOf(type) === -1)
                        minute_events[minute].events.push(type);
                }
                prev_val = value;
            });
        }

        const minuteGraphs = [];

        for (let minute = minutes - 1; minute >= 0; --minute) {
            const dataOffset = minute * SAMPLES_PER_MIN;
            const dataSlice = normData.slice(dataOffset, dataOffset + SAMPLES_PER_MIN);
            const rawSlice = this.props.data.slice(dataOffset, dataOffset + SAMPLES_PER_MIN);
            const is_boot = this.props.boots.includes(minute);

            minuteGraphs.push(
                <MetricsMinute
                    isExpanded={isHourExpanded}
                    key={minute}
                    minute={minute}
                    data={dataSlice}
                    rawData={rawSlice}
                    events={minute_events[minute]}
                    startTime={this.props.startTime}
                    selectedVisibility={selectedVisibility}
                    booted={is_boot} />
            );
        }

        this.setState((_, prevProps) => ({
            isHourExpanded,
            minute_events,
            minuteGraphs,
            dataItems: prevProps.data.length
        }));
    }

    render() {
        const hourDesc = (
            <HourDescription
               minute_events={this.state.minute_events}
               onToggleHourExpanded={isHourExpanded => this.setState({ isHourExpanded })}
               startTime={this.props.startTime}
               isHourExpanded={this.state.isHourExpanded} />
        );

        return (
            <div id={ "metrics-hour-" + this.props.startTime.toString() }
                 className={"metrics-hour" + (!this.state.isHourExpanded ? " metrics-hour-compressed" : "")}>
                {hourDesc}
                {!this.state.isHourExpanded ? <div className="metrics-minutes">{this.state.minuteGraphs}</div> : this.state.minuteGraphs}
            </div>
        );
    }
}

const HourDescription = ({ minute_events, isHourExpanded, onToggleHourExpanded, startTime }) => {
    const event_types = {};
    Object.keys(RESOURCES).forEach(t => { event_types[t] = 0 });
    Object.values(minute_events).forEach(event => { event.events.forEach(t => { event_types[t] += 1 }) });
    const spikes = Object.values(event_types).reduce((acc, event_type_count) => acc + event_type_count, 0);
    return (
        <span className={"metrics-events" + (isHourExpanded ? " metrics-events-hour-header-expanded" : "")}>
            {spikes > 0 &&
                <Button variant="plain" className="metrics-events-expander" onClick={() => onToggleHourExpanded(!isHourExpanded)} icon={isHourExpanded ? <AngleDownIcon /> : <Icon shouldMirrorRTL><AngleRightIcon /></Icon>} />}
            <time>{ timeformat.time(startTime) }</time>
            <Flex flexWrap={{ default: 'nowrap' }} spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsBaseline' }} className="spikes_count">
                {spikes >= 10 && <ResourcesFullIcon color="var(--resource-icon-color-full)" />}
                {spikes >= 5 && spikes < 10 && <ResourcesAlmostFullIcon color="var(--resource-icon-color-middle)" />}
                {spikes < 5 && spikes > 0 && <ResourcesAlmostEmptyIcon color="var(--resource-icon-color-empty)" />}
                <FlexItem>
                    {spikes ? cockpit.format(cockpit.ngettext("$0 spike", "$0 spikes", spikes), spikes) : _("No events")}
                </FlexItem>
            </Flex>
            <span className="spikes_info">
                {spikes > 0 && Object.entries(event_types)
                        .filter(([_, count]) => count > 0)
                        .map(([event_type, count]) => cockpit.format("$0 $1", count, RESOURCES[event_type].event_description))
                        .join(", ")}
            </span>
        </span>
    );
};

// null means "not initialized yet"
const invalidService = proxy => proxy.state === null;
const runningService = proxy => ['running', 'starting'].indexOf(proxy.state) >= 0;

const wait_cond = (cond, objects) => {
    return new Promise((resolve, reject) => {
        const check = () => {
            if (cond()) {
                objects.forEach(o => o.removeEventListener("changed", check));
                resolve();
            }
        };
        objects.forEach(o => o.addEventListener("changed", check));
        check();
    });
};

const PCPConfigDialog = ({
    firewalldRequest,
    s_pmlogger, s_pmproxy, s_redis, s_redis_server, s_valkey,
    packageInstallCallback,
}) => {
    const Dialogs = useDialogs();
    const dialogInitialProxyValue = runningService(s_pmproxy) && (
        runningService(s_redis) || runningService(s_redis_server) || runningService(s_valkey));
    const [dialogError, setDialogError] = useState(null);
    const [dialogLoggerValue, setDialogLoggerValue] = useState(runningService(s_pmlogger));
    const [dialogProxyValue, setDialogProxyValue] = useState(dialogInitialProxyValue);
    const [pending, setPending] = useState(false);
    const [packageManager, setPackageManager] = useState(null);

    useInit(() => getPackageManager().then(setPackageManager));

    const handleInstall = async () => {
    // when enabling services, install missing packages on demand
        const missing = [];
        if (dialogLoggerValue && !s_pmlogger.exists) {
            missing.push(...await get_pcp_packages());
        }
        const redisExists = () => s_redis.exists || s_redis_server.exists || s_valkey.exists;
        if (dialogProxyValue && !redisExists()) {
            const os_release = await read_os_release();
            missing.push(get_manifest_config_matchlist("metrics", "redis_package", "redis",
                                                       [os_release.PLATFORM_ID, os_release.ID]));
        }

        if (missing.length > 0) {
            debug("PCPConfig: missing packages", JSON.stringify(missing), ", offering install");
            Dialogs.close();
            await install_dialog(missing);
            debug("PCPConfig: package installation successful");
            await wait_cond(() => (s_pmlogger.exists &&
                                   (!dialogProxyValue || (s_pmproxy.exists && redisExists()))),
                            [s_pmlogger, s_pmproxy, s_redis, s_redis_server, s_valkey]);
        }
    };

    const handleSave = () => {
        debug("PCPConfig handleSave(): dialogLoggerValue", dialogLoggerValue, "dialogInitialProxyValue", dialogInitialProxyValue, "dialogProxyValue", dialogProxyValue);

        handleInstall()
                .then(() => {
                    setPending(true);

                    let real_redis;
                    let redis_name;
                    if (s_valkey.exists && s_valkey.unit?.UnitFileState !== 'masked') {
                        real_redis = s_valkey;
                        redis_name = "valkey.service";
                    } else if (s_redis_server.exists && s_redis_server.unit?.UnitFileState !== 'masked') {
                        real_redis = s_redis_server;
                        redis_name = "redis-server.service";
                    } else {
                        real_redis = s_redis;
                        redis_name = "redis.service";
                    }

                    const redis_enable_cmd = `mkdir -p /etc/systemd/system/pmproxy.service.wants; ln -sf ../${redis_name} /etc/systemd/system/pmproxy.service.wants/${redis_name}`;
                    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`;
                    let action;

                    // enable/disable does a daemon-reload, which interferes with start on some distros; so don't run them in parallel
                    if (dialogLoggerValue)
                        action = s_pmlogger.start().then(() => s_pmlogger.enable());
                    else
                        action = s_pmlogger.stop().finally(() => s_pmlogger.disable());

                    if (dialogProxyValue !== null && dialogInitialProxyValue !== dialogProxyValue) {
                        if (dialogProxyValue === true) {
                        // pmproxy.service needs to (re)start *after* redis to recognize it
                            action = action
                                    .then(() => real_redis.start())
                                    .then(() => s_pmproxy.restart())
                            // turn redis into a dependency, as the metrics API requires it
                                    .then(() => cockpit.script(redis_enable_cmd, { superuser: "require", err: "message" }))
                                    .then(() => s_pmproxy.enable());
                        } else {
                        // don't stop redis here -- it's a shared service, other things may be using it
                            action = action
                                    .then(() => s_pmproxy.stop())
                                    .then(() => cockpit.script(redis_disable_cmd, { superuser: "require", err: "message" }))
                                    .then(() => s_pmproxy.disable());
                        }
                    }

                    action
                            .then(() => {
                                Dialogs.close();
                                if (dialogProxyValue && !dialogInitialProxyValue && firewalldRequest)
                                    firewalldRequest({ service: "pmproxy", title: _("Open the pmproxy service in the firewall to share metrics.") });
                                else
                                    firewalldRequest(null);
                                packageInstallCallback();
                            })
                            .catch(err => { packageInstallCallback(); setPending(false); setDialogError(err.toString()) });
                })
                .catch(() => null); // ignore cancel in install dialog
    };

    return (
        <Modal position="top" variant="small" isOpen
            id="pcp-settings-modal"
            onClose={Dialogs.close}
        >
            <ModalHeader title={ _("Metrics settings") }
                description={
                    <div className="pcp-settings-modal-text">
                        { _("Performance Co-Pilot collects and analyzes performance metrics from your system.") }

                        <Button component="a" variant="link" href="https://docs.cockpit-project.org/cockpit-guide/latest/guide/feature-pcp.html"
                                      isInline
                                      target="_blank" rel="noopener noreferrer"
                                      icon={<ExternalLinkAltIcon />}>
                            { _("Read more...") }
                        </Button>
                    </div>}
            />
            <ModalBody>
                <Stack hasGutter>
                    { dialogError && <ModalError dialogError={ _("Failed to configure PCP") } dialogErrorDetail={dialogError} /> }
                    <StackItem>
                        <Switch id="switch-pmlogger"
                                    isChecked={dialogLoggerValue}
                                    isDisabled={!s_pmlogger.exists && !packageManager}
                                    label={
                                        <Flex>
                                            <FlexItem>{ _("Collect metrics") }</FlexItem>
                                            <Content>
                                                <Content component={ContentVariants.small}>(pmlogger.service)</Content>
                                            </Content>
                                        </Flex>
                                    }
                                    onChange={(_event, enable) => {
                                        // pmproxy needs pmlogger, auto-disable it
                                        setDialogLoggerValue(enable);
                                        if (!enable)
                                            setDialogProxyValue(false);
                                    }} />

                        <Switch id="switch-pmproxy"
                                    isChecked={dialogProxyValue}
                                    label={
                                        <Flex>
                                            <FlexItem>{ _("Export to network") }</FlexItem>
                                            <Content component={ContentVariants.small}>(pmproxy.service)</Content>
                                        </Flex>
                                    }
                                    isDisabled={ !dialogLoggerValue }
                                onChange={(_event, enable) => setDialogProxyValue(enable)} />
                    </StackItem>
                </Stack>
            </ModalBody>
            <ModalFooter>
                <Button variant='primary' onClick={handleSave} isDisabled={pending} isLoading={pending}>
                    { _("Save") }
                </Button>
                <Button variant='link' className='btn-cancel' onClick={Dialogs.close}>
                    {_("Cancel")}
                </Button>
            </ModalFooter>
        </Modal>);
};

const PCPConfig = ({ buttonVariant, firewalldRequest }) => {
    const Dialogs = useDialogs();
    const [packageInstallStatus, setPackageInstallStatus] = useState(null);

    const s_pmlogger = useObject(() => service.proxy("pmlogger.service"), null, []);
    const s_pmproxy = useObject(() => service.proxy("pmproxy.service"), null, []);
    // redis.service on Fedora/RHEL, redis-server.service on Debian/Ubuntu with an Alias=redis
    const s_redis = useObject(() => service.proxy("redis.service"), null, []);
    const s_redis_server = useObject(() => service.proxy("redis-server.service"), null, []);
    const s_valkey = useObject(() => service.proxy("valkey.service"), null, []);

    useEvent(superuser, "changed");
    useEvent(s_pmlogger, "changed");
    useEvent(s_pmproxy, "changed");
    useEvent(s_redis, "changed");
    useEvent(s_redis_server, "changed");
    useEvent(s_valkey, "changed");

    debug("PCPConfig s_pmlogger.state", s_pmlogger.state);
    debug("PCPConfig s_pmproxy state", s_pmproxy.state,
          "redis exists", s_redis.exists, "state", s_redis.state,
          "redis-server exists", s_redis_server.exists, "state", s_redis_server.state,
          "valkey exists", s_valkey.exists, "state", s_valkey.state);

    if (!superuser.allowed)
        return null;

    function show_dialog() {
        setPackageInstallStatus(null);
        Dialogs.show(<PCPConfigDialog firewalldRequest={firewalldRequest}
                                      s_pmlogger={s_pmlogger}
                                      s_pmproxy={s_pmproxy}
                                      s_redis={s_redis} s_redis_server={s_redis_server} s_valkey={s_valkey}
                                      packageInstallCallback={() => setPackageInstallStatus("done")} />);
    }

    return (
        <Button variant={buttonVariant} icon={<CogIcon />}
                isDisabled={ invalidService(s_pmlogger) || invalidService(s_pmproxy) ||
                             invalidService(s_redis) || invalidService(s_redis_server) || invalidService(s_valkey) }
                onClick={show_dialog}
                data-test-install-finished={packageInstallStatus}>
            { _("Metrics settings") }
        </Button>);
};

class MetricsHistory extends React.Component {
    constructor(props) {
        super(props);
        // metrics data: hour timestamp → array of SAMPLES_PER_H objects of { type → value } or null
        this.data = {};
        // timestamp of the most recent sample that we got (for auto-refresh)
        this.most_recent = 0;
        // Oldest read data
        this.oldest_timestamp = 0;
        // Timestamp representing today midnight to calculate other days for date select
        this.today_midnight = null;
        this.columns = [["cpu", _("CPU")], ["memory", _("Memory")], ["disks", _("Disk I/O")], ["network", _("Network")]];

        this.state = {
            hours: [], // available hours for rendering in descending order
            loading: true, // show loading indicator
            metricsAvailable: true,
            pmLoggerState: null,
            error: null,
            selectedDate: null,
            packageManager: null,
            isBeibootBridge: false,
            isPythonPCPInstalled: null,
            selectedVisibility: this.columns.reduce((a, v) => ({ ...a, [v[0]]: true }), {}),
            boots: [], // journalctl --list-boots as [{started: Date, ended: Date}]
        };

        this.handleMoreData = this.handleMoreData.bind(this);
        this.handleSelect = this.handleSelect.bind(this);
        this.handleInstall = this.handleInstall.bind(this);

        /* supervise pmlogger.service, to diagnose missing history */
        this.pmlogger_service = service.proxy("pmlogger.service");
        this.pmlogger_service.addEventListener("changed", () => {
            if (!invalidService(this.pmlogger_service) && this.pmlogger_service.state !== this.state.pmLoggerState) {
                // when it got enabled while the page is running (e.g. through Settings dialog), start data collection
                if (!this.state.metricsAvailable && runningService(this.pmlogger_service))
                    this.initialLoadData();
                this.setState({ pmLoggerState: this.pmlogger_service.state });
            }
        });

        // FIXME: load less up-front, load more when scrolling
        machine_info_promise.then(() => this.initialLoadData());

        cockpit.addEventListener("visibilitychange", () => {
            // update history metrics when in auto-update mode
            if (!cockpit.hidden && this.history_refresh_timer)
                this.load_data(this.most_recent);
        });
    }

    // load and render the last 24 hours (plus current one) initially; this needs numCpu initialized for correct scaling
    initialLoadData() {
        cockpit.spawn(["date", "+%s"])
                .then(out => {
                    const now = parseInt(out.trim()) * 1000;
                    const current_hour = Math.floor(now / MSEC_PER_H) * MSEC_PER_H;
                    this.most_recent = current_hour;
                    this.today_midnight = new Date(current_hour).setHours(0, 0, 0, 0);

                    const selectedDate = parseInt(cockpit.location.options.date) || this.today_midnight;

                    if (selectedDate !== this.today_midnight)
                        this.load_data(selectedDate, 24 * SAMPLES_PER_H, true);
                    else
                        this.load_data(current_hour - LOAD_HOURS * MSEC_PER_H, undefined, true);

                    this.setState({
                        metricsAvailable: true,
                        selectedDate,
                    });
                })
                .catch(ex => this.setState({ error: ex.toString() }));
    }

    async componentDidMount() {
        let packageManager = false;
        try {
            await getPackageManager();
            packageManager = true;
        } catch (err) {
            packageManager = false;
        }
        // HACK: See https://github.com/cockpit-project/cockpit/issues/19143
        let cmdline = "";
        try {
            cmdline = await cockpit.file("/proc/self/cmdline").read();
        } catch (_ex) {}

        const isBeibootBridge = cmdline?.includes("ic# cockpit-bridge");
        this.setState({ packageManager, isBeibootBridge });

        try {
            // Only 14 days of metrics are shown
            // Requires superuser on Debian/Ubuntu, on Fedora/Arch users in the wheel group can list without superuser.
            const output = await cockpit.spawn(["journalctl", "--list-boots", "--since", "-15d", "--output", "json"], { superuser: "try" });
            const list_boots = JSON.parse(output);
            const boots = list_boots.map(boot => {
                return {
                    started: new Date(boot?.first_entry / 1000),
                    ended: new Date(boot?.last_entry / 1000),
                    current_boot: boot?.index === 0,
                };
            });
            this.setState({ boots });
        } catch (exc) {
            console.warn("journalctl --list-boots failed", exc);
        }
    }

    handleMoreData() {
        this.load_data(this.oldest_timestamp - (LOAD_HOURS * MSEC_PER_H), LOAD_HOURS * SAMPLES_PER_H, true);
    }

    handleSelect(sel) {
        // Stop fetching of new data
        if (this.history_refresh_timer !== null) {
            window.clearTimeout(this.history_refresh_timer);
            this.history_refresh_timer = null;
        }

        this.oldest_timestamp = 0;

        cockpit.location.go([], Object.assign(cockpit.location.options, { date: sel }));
        this.setState({
            selectedDate: sel,
            hours: [],
        }, () => this.load_data(sel, sel === this.today_midnight ? undefined : 24 * SAMPLES_PER_H, true));
    }

    async handleInstall() {
        install_dialog(await get_pcp_packages())
                .then(() => this.initialLoadData())
                .catch(() => null); // ignore cancel
    }

    load_data(load_timestamp, limit, show_spinner) {
        if (show_spinner)
            this.setState({ loading: true });

        this.oldest_timestamp = this.oldest_timestamp > load_timestamp || this.oldest_timestamp === 0 ? load_timestamp : this.oldest_timestamp;
        let current_hour; // hour of timestamp, from most recent meta message
        let hour_index; // index within data[current_hour] array
        const current_sample = []; // last valid value, for decompression
        const new_hours = new Set(); // newly seen hours during this load
        this.history_refresh_timer = null;

        const metrics = cockpit.channel({
            payload: "metrics1",
            interval: INTERVAL,
            source: "pcp-archive",
            timestamp: load_timestamp,
            limit,
            metrics: HISTORY_METRICS,
            "omit-instances": ["lo"],
        });

        metrics.addEventListener("message", (event, message) => {
            debug("history metrics message", message);
            message = JSON.parse(message);

            const init_current_hour = () => {
                if (!this.data[current_hour])
                    this.data[current_hour] = [];

                // When limit is considered only add hours in this time range
                if (!limit || load_timestamp + (limit * INTERVAL) >= current_hour)
                    new_hours.add(current_hour);
            };

            // meta message
            if (!Array.isArray(message)) {
                current_hour = Math.floor(message.timestamp / MSEC_PER_H) * MSEC_PER_H;
                init_current_hour();
                hour_index = Math.floor((message.timestamp - current_hour) / INTERVAL);
                console.assert(hour_index < SAMPLES_PER_H);

                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);
                return;
            }

            debug("message is", message.length, "samples data for current hour", current_hour, "=", timeformat.dateTime(current_hour));

            message.forEach((samples, i) => {
                decompress_samples(samples, current_sample);

                /* don't overwrite existing data with null data; this often happens at the first
                 * data point when "rate" metrics cannot be calculated yet */
                if (typeof current_sample[0] !== 'number' && this.data[current_hour][hour_index]) {
                    debug("load_data", load_timestamp, ": ignoring sample #", i, ":", JSON.stringify(current_sample), "current data sample", JSON.stringify(this.data[current_hour][hour_index]));
                    return;
                }

                // TODO: eventually track/display this by-interface?
                const use_network = current_sample[8].reduce((acc, cur) => acc + cur, 0);
                const sat_cpu = typeof current_sample[3][1] === 'number' ? current_sample[3][1] : null; // instances: (15min, 1min, 5min), pick 1min

                this.data[current_hour][hour_index] = {
                    use_cpu: typeof current_sample[2] === 'number' ? [current_sample[0], current_sample[1], current_sample[2]] : null,
                    sat_cpu,
                    use_memory: typeof current_sample[5] === 'number' ? [current_sample[4], current_sample[5]] : null,
                    sat_memory: current_sample[6],
                    use_disks: current_sample[7],
                    use_network,
                };

                // keep track of maximums of unbounded values, for dynamic scaling
                if (sat_cpu > scaleSatCPU)
                    scaleSatCPU = scaleForValue(sat_cpu);
                if (current_sample[7] > scaleUseDisks)
                    scaleUseDisks = scaleForValue(current_sample[7]);
                if (use_network > scaleUseNetwork)
                    scaleUseNetwork = scaleForValue(use_network);

                if (++hour_index === SAMPLES_PER_H) {
                    current_hour += MSEC_PER_H;
                    hour_index = 0;
                    init_current_hour();
                    debug("hour overflow, advancing to", current_hour, "=", timeformat.dateTime(current_hour));
                }
            });

            // update most recent sample timestamp
            this.most_recent = Math.max(this.most_recent, current_hour + (hour_index - 5) * INTERVAL);
            debug("most recent timestamp is now", this.most_recent, "=", timeformat.dateTime(this.most_recent));
        });

        metrics.addEventListener("close", (event, message) => {
            if (message.problem) {
                debug("could not load metrics:", message.problem);
                this.setState({
                    loading: false,
                    metricsAvailable: false,
                    isPythonPCPInstalled: message?.message !== "python3-pcp not installed",
                });
            } else {
                this.setState({ isPythonPCPInstalled: true });
                debug("loaded metrics for timestamp", timeformat.dateTime(load_timestamp), "new hours", JSON.stringify(Array.from(new_hours)));
                new_hours.forEach(hour => debug("hour", hour, "data", JSON.stringify(this.data[hour])));

                const hours = Array.from(new Set([...this.state.hours, ...new_hours]));
                // sort in descending order
                hours.sort((a, b) => b - a);
                // re-render
                this.setState({ hours, loading: false });

                // trigger automatic update every minute when visible
                if (!limit) {
                    this.history_refresh_timer = window.setTimeout(() => {
                        if (!cockpit.hidden)
                            this.load_data(this.most_recent);
                    }, 60000);
                }
            }

            metrics.close();
        });
    }

    render() {
        // on a single machine, cockpit-pcp depends on pcp; but this may not be the case in the beiboot scenario,
        // so additionally check if pcp is available on the logged in target machine
        if (this.state.isPythonPCPInstalled === false || this.pmlogger_service.exists === false)
            return <EmptyStatePanel
                        icon={ExclamationCircleIcon}
                        title={_("PCP is missing for metrics history")}
                        action={this.state.packageManager && <Button onClick={this.handleInstall}>{_("Install PCP support")}</Button>}
            />;

        if (!this.state.metricsAvailable) {
            let action;
            let paragraph;

            if (this.pmlogger_service.state === 'stopped') {
                paragraph = _("pmlogger.service is not running");
                action = <PCPConfig buttonVariant="primary"
                                    firewalldRequest={this.props.firewalldRequest} />;
            } else {
                if (this.pmlogger_service.state === 'failed')
                    paragraph = _("pmlogger.service has failed");
                else /* running, or initialization hangs */
                    paragraph = _("pmlogger.service is failing to collect data");
                action = <Button variant="link" onClick={() => cockpit.jump("/system/services#/pmlogger.service") }>{_("Troubleshoot")}</Button>;
            }

            return <EmptyStatePanel
                        icon={ExclamationCircleIcon}
                        title={_("Metrics history could not be loaded")}
                        paragraph={paragraph}
                        action={action}
            />;
        }

        if (this.state.error)
            return <EmptyStatePanel
                        icon={ExclamationCircleIcon}
                        title={_("Error has occurred")}
                        paragraph={this.state.error}
            />;

        let nodata_alert = null;
        const lastHourIndex = this.state.hours.length - 1;
        if (!this.state.loading && this.state.hours.length > 0 && this.oldest_timestamp < this.state.hours[lastHourIndex]) {
            let t1;
            let t2;
            if (this.state.hours[lastHourIndex] - this.oldest_timestamp < 24 * MSEC_PER_H) {
                t1 = timeformat.time(this.oldest_timestamp);
                t2 = timeformat.time(this.state.hours[lastHourIndex]);
            } else {
                t1 = timeformat.dateTime(this.oldest_timestamp);
                t2 = timeformat.dateTime(this.state.hours[lastHourIndex]);
            }
            nodata_alert = <Alert className="nodata" variant="info" isInline title={ cockpit.format(_("No data available between $0 and $1"), t1, t2) } />;
        }

        if (!this.state.loading && this.state.hours.length === 0)
            nodata_alert = <EmptyStatePanel icon={ExclamationCircleIcon} title={_("No data available")} />;

        // generate selection of last 14 days
        const options = Array(15).fill()
                .map((_undef, i) => {
                    const date = this.today_midnight - i * 86400000;
                    const text = i === 0 ? _("Today") : timeformat.weekdayDate(date);
                    return { value: date, content: text };
                });

        function Label(props) {
            return (
                <div className={"metrics-label metrics-label-graph" + (props.items.length > 1 ? " have-saturation" : "")}>
                    <span>{props.label}</span>
                    <Content className="metrics-sublabels">
                        { props.items.map(i => <Content component={ContentVariants.small} key={i}>{i}</Content>) }
                    </Content>
                </div>
            );
        }

        const columnVisibilityMenuItems = this.columns.map(itm => {
            return {
                value: itm[0],
                content: itm[1],
                "data-label": itm[0],
            };
        });
        const selections = (
            this.columns
                    .filter(itm => this.state.selectedVisibility[itm[0]])
                    .map(itm => itm[0])
        );

        return (
            <div className="metrics" style={{ "--graph-cnt": selections.length }}>
                <PageGroup stickyOnBreakpoint={{ default: 'top' }}>
                    <section className="metrics-heading">
                        <Flex className="metrics-selectors" spaceItems={{ default: 'spaceItemsSm' }}>
                            <SimpleSelect
                                onSelect={this.handleSelect}
                                selected={this.state.selectedDate}
                                options={options}
                                isScrollable
                                toggleProps={{
                                    id: "date-picker-select-toggle",
                                    className: "select-min metrics-label",
                                    "aria-label": _("Jump to")
                                }} />
                            <CheckboxSelect
                                toggleProps={{
                                    "aria-label": _("Graph visibility options menu"),
                                    className: "select-min metrics-label",
                                }}
                                toggleContent={_("Graph visibility")}
                                noBadge
                                onSelect={(selection, checked) => {
                                    this.setState(prevState => ({
                                        selectedVisibility: { ...prevState.selectedVisibility, [selection]: checked }
                                    }));
                                }}
                                selected={selections}
                                options={columnVisibilityMenuItems} />
                        </Flex>
                        <Stack className="metrics-label-graph-mobile">
                            {[["cpu", _("CPU usage/load")], ["memory", _("Memory usage/swap")], ["disks", _("Disk I/O")], ["network", _("Network")]]
                                    .filter(itm => this.state.selectedVisibility[itm[0]])
                                    .map(itm => (
                                        <Flex key={itm[0]} flexWrap={{ default: 'nowrap' }} spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsBaseline' }}>
                                            <div className={"square label-" + itm[0]} />
                                            <FlexItem>{itm[1]}</FlexItem>
                                        </Flex>
                                    ))}
                        </Stack>
                        <div className="metrics-graphs metrics-heading-graphs">
                            {this.state.selectedVisibility.cpu && <Label label={_("CPU")} items={[_("Usage"), _("Load")]} />}
                            {this.state.selectedVisibility.memory && <Label label={_("Memory")} items={[_("Usage"), ...swapTotal ? [_("Swap")] : []]} />}
                            {this.state.selectedVisibility.disks && <Label label={_("Disk I/O")} items={[_("Usage")]} />}
                            {this.state.selectedVisibility.network && <Label label={_("Network")} items={[_("Usage")]} />}
                        </div>
                    </section>
                </PageGroup>
                <PageSection hasBodyWrapper={false} className="metrics-history-section">
                    <>
                        { this.state.hours.length > 0 &&
                        <Card isPlain>
                            <CardBody className="metrics-history">
                                { this.state.hours.map((time, i) => {
                                    const date_time = new Date(time);
                                    const boot_minutes = this.state.boots.filter(reboot => reboot.started.getDay() === date_time.getDay() &&
                                                                                           reboot.started.getYear() === date_time.getYear() &&
                                                                                           reboot.started.getHours() === date_time.getHours())
                                            .map(reboot => reboot.started.getMinutes());
                                    const showHeader = i === 0 || timeformat.date(time) !== timeformat.date(this.state.hours[i - 1]);

                                    return (
                                        <React.Fragment key={timeformat.dateTime(time)}>
                                            {showHeader && <Content><Content component={ContentVariants.h3} className="metrics-time"><time>{ timeformat.date(time) }</time></Content></Content>}
                                            <MetricsHour key={time} startTime={parseInt(time)}
                                                         selectedVisibility={this.state.selectedVisibility}
                                                         data={this.data[time]} clipLeading={i === 0}
                                                         boots={boot_minutes} />
                                        </React.Fragment>
                                    );
                                })}
                            </CardBody>
                        </Card> }
                        {nodata_alert}
                        <div className="bottom-panel">
                            { this.state.loading
                                ? <EmptyStatePanel loading title={_("Loading...")} />
                                : <Button onClick={this.handleMoreData}>{_("Load earlier data")}</Button> }
                        </div>
                    </>
                </PageSection>
            </div>
        );
    }
}

export const Application = () => {
    const [firewalldRequest, setFirewalldRequest] = useState(null);

    return (
        <WithDialogs>
            <Page className="pf-m-no-sidebar">
                <PageBreadcrumb
                    id='metrics-header-section'
                    hasBodyWrapper={false}
                    stickyOnBreakpoint={{ default: "top" }}
                >
                    <Flex>
                        <FlexItem>
                            <Breadcrumb>
                                <BreadcrumbItem onClick={() => cockpit.jump("/system")} className="pf-v6-c-breadcrumb__link">{_("Overview")}</BreadcrumbItem>
                                <BreadcrumbItem isActive>{_("Metrics and history")}</BreadcrumbItem>
                            </Breadcrumb>
                        </FlexItem>
                        <FlexItem align={{ default: 'alignRight' }}>
                            <PCPConfig buttonVariant="secondary"
                                             firewalldRequest={setFirewalldRequest} />
                        </FlexItem>
                    </Flex>
                </PageBreadcrumb>
                { firewalldRequest &&
                <FirewalldRequest service={firewalldRequest.service} title={firewalldRequest.title} pageSection /> }
                <PageSection hasBodyWrapper={false}>
                    <CurrentMetrics />
                </PageSection>
                <MetricsHistory firewalldRequest={setFirewalldRequest} />
            </Page>
        </WithDialogs>);
};