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