mirror of
https://github.com/cockpit-project/cockpit.git
synced 2026-11-03 21:49:50 -09:00
2011 lines
94 KiB
JavaScript
2011 lines
94 KiB
JavaScript
/*
|
|
* 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> { cockpit.format(_("average: $0%"), this.state.cpuUsed) }</FlexItem>
|
|
<FlexItem> { 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>
|
|
|
|
<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") }: { this.state.loadAvg[0] },</FlexItem>
|
|
<FlexItem>{ _("5 min") }: { this.state.loadAvg[1] },</FlexItem>
|
|
<FlexItem>{ _("15 min") }: { 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>);
|
|
};
|