Skip to content

Commit 66b4ec6

Browse files
committed
Add SMART status to overview page
1 parent 81af7d0 commit 66b4ec6

File tree

3 files changed

+136
-9
lines changed

3 files changed

+136
-9
lines changed

pkg/systemd/overview-cards/healthCard.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import LastLogin from "./lastLogin.jsx";
2828
import { CryptoPolicyStatus } from "./cryptoPolicies.jsx";
2929

3030
import "./healthCard.scss";
31+
import { SmartOverviewStatus } from './smart-status.jsx';
3132

3233
const _ = cockpit.gettext;
3334

@@ -40,6 +41,7 @@ export const HealthCard = () =>
4041
<InsightsStatus />
4142
<CryptoPolicyStatus />
4243
<ShutDownStatus />
44+
<SmartOverviewStatus />
4345
<LastLogin />
4446
</ul>
4547
</CardBody>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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 <https://www.gnu.org/licenses/>.
18+
*/
19+
20+
import React from 'react';
21+
22+
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
23+
import { ExclamationCircleIcon } from "@patternfly/react-icons";
24+
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
25+
import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
26+
27+
import cockpit from "cockpit";
28+
import { useEvent, useInit } from 'hooks';
29+
30+
const _ = cockpit.gettext;
31+
32+
function countFailingDisks(proxies) {
33+
const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
34+
const smart = proxies.drives_ata[drive];
35+
if (smart.SmartFailing) {
36+
return acc + 1;
37+
} else {
38+
return acc;
39+
}
40+
}, 0);
41+
42+
const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
43+
const smart = proxies.nvme_controller[drive];
44+
if (smart.SmartCriticalWarning.length > 0) {
45+
return acc + 1;
46+
} else {
47+
return acc;
48+
}
49+
}, 0);
50+
51+
return sataFail + nvmeFail;
52+
}
53+
54+
const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
55+
const proxies = {
56+
drives_ata: null,
57+
nvme_controller: null,
58+
};
59+
60+
export const SmartOverviewStatus = () => {
61+
useEvent(udisksdbus, "notify");
62+
63+
useInit(async () => {
64+
const addProxy = (iface) => {
65+
return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
66+
};
67+
68+
proxies.drives_ata = addProxy("Drive.Ata");
69+
proxies.nvme_controller = addProxy("NVMe.Controller");
70+
await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
71+
});
72+
73+
if (proxies.drives_ata === null || proxies.nvme_controller === null) {
74+
return;
75+
}
76+
77+
const failingDisks = countFailingDisks(proxies);
78+
if (failingDisks === 0) {
79+
return;
80+
}
81+
82+
return (
83+
<li id="smart-status">
84+
<Flex spaceItems={{ default: 'spaceItemsSm' }}>
85+
<Icon status="danger">
86+
<ExclamationCircleIcon />
87+
</Icon>
88+
<Button variant="link" component="a" isInline
89+
onClick={ ev => { ev.preventDefault(); cockpit.jump("/storage") } }
90+
>
91+
{cockpit.format(cockpit.ngettext("$0 disk is failing",
92+
"$0 disks are failing", failingDisks),
93+
failingDisks)}
94+
</Button>
95+
</Flex>
96+
</li>
97+
);
98+
};

test/verify/check-storage-smart

+36-9
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ class TestStorageSmart(storagelib.StorageSmartCase):
4242

4343
return version > (2, 10, 1)
4444

45-
def testSmart(self):
46-
def set_smart_dump(name: str, block: str):
47-
self.machine.execute(f"udisksctl smart-simulate -f /tmp/smart-dumps/{name} -b {block}")
45+
def set_smart_dump(self, name: str, block: str):
46+
self.machine.execute(f"udisksctl smart-simulate -f /tmp/smart-dumps/{name} -b {block}")
4847

48+
def testSmart(self):
4949
def check_smart_info(assessment: str, hours: str, status: str, bad_sectors: str | None = None,
5050
failing_attrs: str | None = None):
5151
self.assertIn(assessment, b.text(self.card_desc("Device health (SMART)", "Assessment")))
@@ -66,7 +66,7 @@ class TestStorageSmart(storagelib.StorageSmartCase):
6666

6767
m.upload(["verify/files/smart-dumps"], "/tmp")
6868
# new disk, no failing sectors
69-
set_smart_dump("MCCOE64GEMPP--2.9.09", "/dev/sda")
69+
self.set_smart_dump("MCCOE64GEMPP--2.9.09", "/dev/sda")
7070

7171
self.login_and_go("/storage")
7272
b.wait_visible(self.card("Storage"))
@@ -75,23 +75,23 @@ class TestStorageSmart(storagelib.StorageSmartCase):
7575
check_smart_info("Disk is OK", "1 hours", "Successful")
7676

7777
# Disk with running self test
78-
set_smart_dump("SAMSUNG_MMCQE28G8MUP--0VA_VAM08L1Q", "/dev/sda")
78+
self.set_smart_dump("SAMSUNG_MMCQE28G8MUP--0VA_VAM08L1Q", "/dev/sda")
7979
check_smart_info("Disk is OK", "2417 hours", "In progress, 30%")
8080

8181
# Interrupted self test, disk is OK
82-
set_smart_dump("INTEL_SSDSA2MH080G1GC--045C8820", "/dev/sda")
82+
self.set_smart_dump("INTEL_SSDSA2MH080G1GC--045C8820", "/dev/sda")
8383
check_smart_info("Disk is OK", "2309 hours", "Interrupted")
8484

8585
# Aborted self test and has known bad sector
86-
set_smart_dump("ST9160821AS--3.CLH", "/dev/sda")
86+
self.set_smart_dump("ST9160821AS--3.CLH", "/dev/sda")
8787
check_smart_info("Disk is failing", "556 hours", "Aborted", bad_sectors="1")
8888

8989
# Multiple bad sectors
90-
set_smart_dump("Maxtor_96147H8--BAC51KJ0", "/dev/sda")
90+
self.set_smart_dump("Maxtor_96147H8--BAC51KJ0", "/dev/sda")
9191
check_smart_info("Disk is failing", "2016 hours", "Successful", bad_sectors="71")
9292

9393
# Multiple bad sectors with failing attribute
94-
set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
94+
self.set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
9595
check_smart_info("Disk is failing", "2262 hours", "Successful", bad_sectors="71", failing_attrs="1")
9696

9797
# Check that SMART card is not visible on DVD drive
@@ -100,6 +100,33 @@ class TestStorageSmart(storagelib.StorageSmartCase):
100100
b.wait_visible(self.card("Media drive"))
101101
b.wait_not_present(self.card("Device health (SMART)"))
102102

103+
def testSmartOverview(self):
104+
m = self.machine
105+
b = self.browser
106+
107+
# udisks2 version > 2.10.1 is required to mock SMART data on virtual disks
108+
if not self.udisks_mock_smart_supported():
109+
stderr.write("Image has old udisks2 version, cannot run SMART test.\n")
110+
return
111+
112+
m.upload(["verify/files/smart-dumps"], "/tmp")
113+
# Failing disk, storage page is not loaded
114+
self.set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
115+
self.login_and_go("/system")
116+
b.wait_in_text("#smart-status", "1 disk is failing")
117+
118+
self.set_smart_dump("MCCOE64GEMPP--2.9.09", "/dev/sda")
119+
b.wait_not_present("#smart-status")
120+
121+
# Clicking the link navigates to storage page, failing disk has red icon
122+
self.set_smart_dump("Maxtor_96147H8--BAC51KJ0", "/dev/sda")
123+
b.wait_in_text("#smart-status", "1 disk is failing")
124+
b.click("#smart-status a")
125+
b.enter_page("/storage")
126+
b.wait_visible(self.card("Storage"))
127+
b.wait_visible(self.card_row("Storage", name="sda"))
128+
b.wait_visible(self.card_row("Storage", name="sda") + " .ct-icon-times-circle")
129+
103130

104131
if __name__ == '__main__':
105132
testlib.test_main()

0 commit comments

Comments
 (0)