Skip to content

Commit aa80a0f

Browse files
committed
storage: SMART support
Initial SMART self test support
1 parent 8e97951 commit aa80a0f

11 files changed

+337
-24
lines changed

pkg/storaged/client.js

+1
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ function init_proxies () {
188188
client.vdo_vols = proxies("VDOVolume");
189189
client.blocks_fsys_btrfs = proxies("Filesystem.BTRFS");
190190
client.jobs = proxies("Job");
191+
client.nvme_controller = proxies("NVMe.Controller");
191192

192193
return client.storaged_client.watch({ path_namespace: "/org/freedesktop/UDisks2" });
193194
}

pkg/storaged/drive/drive.jsx

+25-24
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@ import client from "../client";
2323

2424
import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
2525
import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
26-
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
2726

2827
import { HDDIcon, SSDIcon, MediaDriveIcon } from "../icons/gnome-icons.jsx";
2928
import { StorageCard, StorageDescription, new_card, new_page } from "../pages.jsx";
30-
import { block_name, drive_name, format_temperature, fmt_size_long, should_ignore } from "../utils.js";
29+
import { block_name, drive_name, fmt_size_long, should_ignore } from "../utils.js";
3130
import { make_block_page } from "../block/create-pages.jsx";
3231
import { partitionable_block_actions } from "../partitions/actions.jsx";
32+
import { isSmartOK, SmartCard } from "./smart-details.jsx";
3333

3434
const _ = cockpit.gettext;
3535

@@ -71,7 +71,7 @@ export function make_drive_page(parent, drive) {
7171
hdd: HDDIcon,
7272
};
7373

74-
const drive_card = new_card({
74+
let card = new_card({
7575
title: drive_title[cls] || _("Drive"),
7676
next: null,
7777
page_block: block,
@@ -84,35 +84,37 @@ export function make_drive_page(parent, drive) {
8484
actions: block.Size > 0 ? partitionable_block_actions(block) : [],
8585
});
8686

87+
let smart_info, drive_type;
88+
if (client.drives_ata[drive.path]) {
89+
smart_info = client.drives_ata[drive.path];
90+
drive_type = "ata";
91+
} else if (client.nvme_controller[drive.path]) {
92+
smart_info = client.nvme_controller[drive.path];
93+
drive_type = "nvme";
94+
}
95+
96+
if (smart_info !== undefined && (cls === "hdd" || cls === "ssd")) {
97+
card = new_card({
98+
title: _("Device health (SMART)"),
99+
next: card,
100+
has_danger: !isSmartOK(drive_type, smart_info),
101+
has_warning: (smart_info.SmartNumBadSectors > 0 || smart_info.SmartNumAttributesFailing > 0),
102+
component: SmartCard,
103+
props: { smart_info, drive_type },
104+
});
105+
}
106+
87107
if (block.Size > 0) {
88-
make_block_page(parent, block, drive_card);
108+
make_block_page(parent, block, card);
89109
} else {
90-
new_page(parent, drive_card);
110+
new_page(parent, card);
91111
}
92112
}
93113

94114
const DriveCard = ({ card, page, drive }) => {
95115
const block = client.drives_block[drive.path];
96-
const drive_ata = client.drives_ata[drive.path];
97116
const multipath_blocks = client.drives_multipath_blocks[drive.path];
98117

99-
let assessment = null;
100-
if (drive_ata) {
101-
assessment = (
102-
<StorageDescription title={_("Assessment")}>
103-
<Flex spaceItems={{ default: 'spaceItemsXs' }}>
104-
{ drive_ata.SmartFailing
105-
? <span className="cockpit-disk-failing">{_("Disk is failing")}</span>
106-
: <span>{_("Disk is OK")}</span>
107-
}
108-
{ drive_ata.SmartTemperature > 0
109-
? <span>({format_temperature(drive_ata.SmartTemperature)})</span>
110-
: null
111-
}
112-
</Flex>
113-
</StorageDescription>);
114-
}
115-
116118
return (
117119
<StorageCard card={card}>
118120
<CardBody>
@@ -128,7 +130,6 @@ const DriveCard = ({ card, page, drive }) => {
128130
: _("No media inserted")
129131
}
130132
</StorageDescription>
131-
{ assessment }
132133
<StorageDescription title={_("Device file")}
133134
value={block ? block_name(block) : "-"} />
134135
{ multipath_blocks.length > 0 &&

pkg/storaged/drive/smart-details.jsx

+197
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/*
2+
* This file is part of Cockpit.
3+
*
4+
* Copyright (C) 2025 Red Hat, Inc.
5+
*
6+
* Cockpit is free software; you can redistribute it and/or modify it
7+
* under the terms of the GNU Lesser General Public License as published by
8+
* the Free Software Foundation; either version 2.1 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* Cockpit is distributed in the hope that it will be useful, but
12+
* WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
import cockpit from "cockpit";
21+
import React, { useState } from "react";
22+
23+
import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
24+
import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
25+
import { Dropdown, DropdownItem, KebabToggle } from '@patternfly/react-core/dist/esm/deprecated/components/Dropdown/index.js';
26+
import { ExclamationCircleIcon, ExclamationTriangleIcon } from "@patternfly/react-icons";
27+
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
28+
import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
29+
30+
import { format_temperature } from "../utils.js";
31+
import { superuser } from "superuser.js";
32+
import { StorageCard, StorageDescription } from "../pages.jsx";
33+
import { useEvent } from "hooks.js";
34+
35+
const _ = cockpit.gettext;
36+
37+
const selftestStatusDescription = {
38+
// Shared values
39+
success: _("Successful"),
40+
aborted: _("Aborted"),
41+
inprogress: _("In progress"),
42+
43+
// SATA special values
44+
interrupted: _("Interrupted"),
45+
fatal: _("Did not complete"),
46+
error_unknown: _("Failed (Unknown)"),
47+
error_electrical: _("Failed (Electrical)"),
48+
error_servo: _("Failed (Servo)"),
49+
error_read: _("Failed (Read)"),
50+
error_handling: _("Failed (Damaged)"),
51+
52+
// NVMe special values
53+
ctrl_reset: _("Aborted by a Controller Level Reset"),
54+
ns_removed: _("Aborted due to a removal of a namespace from the namespace inventory"),
55+
aborted_format: _("Aborted due to the processing of a Format NVM command"),
56+
fatal_error: _("A fatal error occurred during the self-test operation"),
57+
unknown_seg_fail: _("Completed with a segment that failed and the segment that failed is not known"),
58+
known_seg_fail: _("Completed with one or more failed segments"),
59+
aborted_unknown: _("Aborted for unknown reason"),
60+
aborted_sanitize: _("Aborted due to a sanitize operation"),
61+
};
62+
63+
// NVMe reports reasons why selftest failed
64+
const nvmeCriticalWarning = {
65+
spare: _("Spare capacity is below the threshold"),
66+
temperature: _("Temperature outside of recommended thresholds"),
67+
degraded: _("Degraded"),
68+
readonly: _("All media is in read-only mode"),
69+
volatile_mem: _("Volatile memory backup failed"),
70+
pmr_readonly: _("Persistent memory has become read-only")
71+
};
72+
73+
const SmartActions = ({ smart_info }) => {
74+
const [isKebabOpen, setKebabOpen] = useState(false);
75+
const smartSelftestStatus = smart_info.SmartSelftestStatus;
76+
77+
const runSelfTest = (type) => {
78+
smart_info.SmartSelftestStart(type, {});
79+
};
80+
81+
const abortSelfTest = () => {
82+
smart_info.SmartSelftestAbort({});
83+
};
84+
85+
const testDisabled = !superuser.allowed || smartSelftestStatus === "inprogress";
86+
87+
const actions = [
88+
<DropdownItem key="smart-short-test"
89+
isDisabled={testDisabled}
90+
onClick={() => { setKebabOpen(false); runSelfTest('short') }}>
91+
{_("Run short test")}
92+
</DropdownItem>,
93+
<DropdownItem key="smart-extended-test"
94+
isDisabled={testDisabled}
95+
onClick={() => { setKebabOpen(false); runSelfTest('extended') }}>
96+
{_("Run extended test")}
97+
</DropdownItem>,
98+
<DropdownItem key="abort-smart-test"
99+
isDisabled={testDisabled}
100+
onClick={() => { setKebabOpen(false); abortSelfTest() }}>
101+
{_("Abort test")}
102+
</DropdownItem>,
103+
];
104+
105+
return (
106+
<Dropdown toggle={<KebabToggle onToggle={(_, isOpen) => setKebabOpen(isOpen)} />}
107+
isPlain
108+
isOpen={isKebabOpen}
109+
position="right"
110+
id="smart-actions"
111+
dropdownItems={actions}
112+
/>
113+
);
114+
};
115+
116+
export const isSmartOK = (drive_type, smart_info) => {
117+
return (drive_type === "ata" && !smart_info.SmartFailing) ||
118+
(drive_type === "nvme" && smart_info.SmartCriticalWarning.length === 0);
119+
};
120+
121+
export const SmartCard = ({ card, smart_info, drive_type }) => {
122+
useEvent(superuser, "changed");
123+
124+
const powerOnHours = (drive_type === "ata")
125+
? Math.floor(smart_info.SmartPowerOnSeconds / 3600)
126+
: smart_info.SmartPowerOnHours;
127+
128+
const smartOK = isSmartOK(drive_type, smart_info);
129+
130+
const status = selftestStatusDescription[smart_info.SmartSelftestStatus] +
131+
((smart_info.SmartSelftestStatus === "inprogress" && smart_info.SmartSelftestPercentRemaining !== -1)
132+
? `, ${100 - smart_info.SmartSelftestPercentRemaining}%`
133+
: "");
134+
135+
const assesment = (
136+
<Flex spaceItems={{ default: 'spaceItemsXs' }}>
137+
{ !smartOK &&
138+
<Icon status="danger">
139+
<ExclamationCircleIcon />
140+
</Icon>
141+
}
142+
{ drive_type === "ata" && !smartOK &&
143+
<span className="cockpit-disk-failing">{_("Disk is failing")}</span>
144+
}
145+
{ drive_type === "nvme" && !smartOK &&
146+
(<span className="cockpit-disk-failing">
147+
{_("Disk is failing") + ": " + smart_info.SmartCriticalWarning.map(reason => nvmeCriticalWarning[reason]).join(", ")}
148+
</span>)
149+
}
150+
{ smartOK &&
151+
<span>{_("Disk is OK")}</span>
152+
}
153+
{ smart_info.SmartTemperature > 0
154+
? <span>({format_temperature(smart_info.SmartTemperature)})</span>
155+
: null
156+
}
157+
</Flex>
158+
);
159+
160+
return (
161+
<StorageCard card={card} actions={<SmartActions smart_info={smart_info} />}>
162+
<CardBody>
163+
<DescriptionList isHorizontal horizontalTermWidthModifier={{ default: '20ch' }}>
164+
<StorageDescription title={_("Assessment")}>
165+
{assesment}
166+
</StorageDescription>
167+
<StorageDescription title={_("Power on hours")}
168+
value={cockpit.format(_("$0 hours"), powerOnHours)}
169+
/>
170+
<StorageDescription title={_("Self-test status")}
171+
value={status}
172+
/>
173+
{drive_type === "ata" && smart_info.SmartNumBadSectors > 0 &&
174+
<StorageDescription title={_("Number of bad sectors")}>
175+
<Flex flexWrap={{ default: "nowrap" }} spaceItems={{ default: "spaceItemsXs" }}>
176+
<Icon status="warning">
177+
<ExclamationTriangleIcon />
178+
</Icon>
179+
{smart_info.SmartNumBadSectors}
180+
</Flex>
181+
</StorageDescription>
182+
}
183+
{drive_type === "ata" && smart_info.SmartNumAttributesFailing > 0 &&
184+
<StorageDescription title={_("Attributes failing")}>
185+
<Flex flexWrap={{ default: "nowrap" }} spaceItems={{ default: "spaceItemsXs" }}>
186+
<Icon status="warning">
187+
<ExclamationTriangleIcon />
188+
</Icon>
189+
{smart_info.SmartNumAttributesFailing}
190+
</Flex>
191+
</StorageDescription>
192+
}
193+
</DescriptionList>
194+
</CardBody>
195+
</StorageCard>
196+
);
197+
};

test/common/storagelib.py

+9
Original file line numberDiff line numberDiff line change
@@ -686,3 +686,12 @@ def setUp(self):
686686
# gets it immediately. But sometimes the interface is already
687687
# gone.
688688
self.allow_journal_messages("org.freedesktop.UDisks2: couldn't get property org.freedesktop.UDisks2.Filesystem Size .* No such interface.*")
689+
690+
691+
class StorageSmartCase(StorageCase):
692+
provision = {
693+
"0": {
694+
"disk_bus": "sata",
695+
"disk_dev": "sda",
696+
}
697+
}

0 commit comments

Comments
 (0)