Repository: cockpit-project/cockpit
Pull request #21697: Add SMART status to overview page
Author: login: tomasmatus
Created: 2025-03-11T16:52:18Z | Merged: 2025-05-15T12:23:14Z

Shows entry in Health card on the overview page about failing disks. Clicking on the warning redirects to the storage page where failing disk is highlighted with the same danger icon.

![image](https://github.com/user-attachments/assets/479acb8e-4fb1-4ea3-b8f8-b4a36b8d982e)
![image](https://github.com/user-attachments/assets/9be03468-7bba-4de8-815c-525e07dabb37)

---

## Storage: Disk Self-Test error warnings

The health card on the overview page now provides proactive warnings when a disk reports errors from its SMART self-test.

![339-overview-smart-warning](https://github.com/user-attachments/assets/adb8972d-f4bc-4640-83a8-cdec72eaa0c2)



Diff:
diff --git a/pkg/systemd/overview-cards/healthCard.tsx b/pkg/systemd/overview-cards/healthCard.tsx
index 6b224a9ef0ee..38c745bb983f 100644
--- a/pkg/systemd/overview-cards/healthCard.tsx
+++ b/pkg/systemd/overview-cards/healthCard.tsx
@@ -28,6 +28,7 @@ import LastLogin from "./lastLogin.jsx";
 import { CryptoPolicyStatus } from "./cryptoPolicies.jsx";
 
 import "./healthCard.scss";
+import { SmartOverviewStatus } from './smart-status.jsx';
 
 const _ = cockpit.gettext;
 
@@ -40,6 +41,7 @@ export const HealthCard = () =>
                 <InsightsStatus />
                 <CryptoPolicyStatus />
                 <ShutDownStatus />
+                <SmartOverviewStatus />
                 <LastLogin />
             </ul>
         </CardBody>
diff --git a/pkg/systemd/overview-cards/smart-status.jsx b/pkg/systemd/overview-cards/smart-status.jsx
new file mode 100644
index 000000000000..6c73f1e0e8d8
--- /dev/null
+++ b/pkg/systemd/overview-cards/smart-status.jsx
@@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(proxies.drives_ata, "changed");
+    useEvent(proxies.nvme_controller, "changed");
+    const [initDone, setInitDone] = useState(false);
+
+    useInit(async () => {
+        const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2");
+
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
+
+        setInitDone(true);
+    });
+
+    if (initDone === false || proxies.drives_ata === null || proxies.nvme_controller === null) {
+        return;
+    }
+
+    const failingDisks = countFailingDisks(proxies);
+    if (failingDisks === 0) {
+        return;
+    }
+
+    return (
+        <li id="smart-status">
+            <Flex spaceItems={{ default: 'spaceItemsSm' }}>
+                <Icon status="danger">
+                    <ExclamationCircleIcon />
+                </Icon>
+                <Button variant="link" component="a" isInline
+                    onClick={() => cockpit.jump("/storage")}
+                >
+                    {cockpit.format(cockpit.ngettext("$0 disk is failing",
+                                                     "$0 disks are failing", failingDisks),
+                                    failingDisks)}
+                </Button>
+            </Flex>
+        </li>
+    );
+};
diff --git a/test/verify/check-storage-smart b/test/verify/check-storage-smart
index 45a1e76a30dc..788b22ffd71f 100755
--- a/test/verify/check-storage-smart
+++ b/test/verify/check-storage-smart
@@ -66,10 +66,10 @@ class TestStorageSmart(storagelib.StorageSmartCase):
         else:
             return False
 
-    def testSmart(self):
-        def set_smart_dump(name: str, block: str):
-            self.machine.execute(f"udisksctl smart-simulate -f /tmp/smart-dumps/{name} -b {block}")
+    def set_smart_dump(self, name: str, block: str):
+        self.machine.execute(f"udisksctl smart-simulate -f /tmp/smart-dumps/{name} -b {block}")
 
+    def testSmart(self):
         def check_smart_info(assessment: str, hours: str, status: str, bad_sectors: str | None = None,
                              failing_attrs: str | None = None):
             self.assertIn(assessment, b.text(self.card_desc("Device health (SMART)", "Assessment")))
@@ -91,42 +91,58 @@ class TestStorageSmart(storagelib.StorageSmartCase):
             return
 
         m.upload(["verify/files/smart-dumps"], "/tmp")
+        # Failing disk, storage page is not loaded
+        self.set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
+        self.login_and_go("/system")
+        b.wait_in_text("#smart-status", "1 disk is failing")
+
+        self.set_smart_dump("MCCOE64GEMPP--2.9.09", "/dev/sda")
+        b.wait_not_present("#smart-status")
+
+        # Clicking the link navigates to storage page, failing disk has red icon
+        self.set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
+        b.wait_in_text("#smart-status", "1 disk is failing")
+        b.click("#smart-status a")
+        b.enter_page("/storage")
+        b.wait_visible(self.card("Storage"))
+        b.wait_visible(self.card_row("Storage", name="sda"))
+        b.wait_visible(self.card_row("Storage", name="sda") + " .ct-icon-times-circle")
+
         # new disk, no failing sectors
-        set_smart_dump("MCCOE64GEMPP--2.9.09", "/dev/sda")
+        self.set_smart_dump("MCCOE64GEMPP--2.9.09", "/dev/sda")
 
-        self.login_and_go("/storage")
         b.wait_visible(self.card("Storage"))
         self.click_card_row("Storage", name="sda")
         b.wait_visible(self.card("Device health (SMART)"))
         check_smart_info("Disk is OK", "1 hours", "Successful")
 
         # Disk with running self test
-        set_smart_dump("SAMSUNG_MMCQE28G8MUP--0VA_VAM08L1Q", "/dev/sda")
+        self.set_smart_dump("SAMSUNG_MMCQE28G8MUP--0VA_VAM08L1Q", "/dev/sda")
         check_smart_info("Disk is OK", "2417 hours", "In progress, 30%")
 
         # Interrupted self test, disk is OK
-        set_smart_dump("INTEL_SSDSA2MH080G1GC--045C8820", "/dev/sda")
+        self.set_smart_dump("INTEL_SSDSA2MH080G1GC--045C8820", "/dev/sda")
         check_smart_info("Disk is OK", "2309 hours", "Interrupted")
 
         # latest libblockdev builds (since 3.3.0-99) from copr have different behavior when assessing disk as failed
         has_new_libblockdev = self.has_new_libblockdev()
 
         # Aborted self test and has known bad sector (overall assessment is still OK)
-        set_smart_dump("ST9160821AS--3.CLH", "/dev/sda")
+        self.set_smart_dump("ST9160821AS--3.CLH", "/dev/sda")
         if has_new_libblockdev:
             check_smart_info("Disk is OK", "556 hours", "Aborted", bad_sectors="1")
         else:
             check_smart_info("Disk is failing", "556 hours", "Aborted", bad_sectors="1")
 
         # Multiple bad sectors
-        set_smart_dump("Maxtor_96147H8--BAC51KJ0", "/dev/sda")
+        self.set_smart_dump("Maxtor_96147H8--BAC51KJ0", "/dev/sda")
         if has_new_libblockdev:
             check_smart_info("Disk is OK", "2016 hours", "Successful", bad_sectors="71")
         else:
             check_smart_info("Disk is failing", "2016 hours", "Successful", bad_sectors="71")
 
         # Multiple bad sectors with failing attribute
-        set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
+        self.set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
         check_smart_info("Disk is failing", "2262 hours", "Successful", bad_sectors="71", failing_attrs="1")
         b.wait_visible(self.card_desc("Device health (SMART)", "Assessment") + " .pf-m-danger")
 


Reviews:
user: login: martinpitt
body: Nice! I know you didn't yet ask for review, but as it's my last day I thought I'd do an initial one anyway.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-31T07:31:26Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T12:09:29Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T12:09:39Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T12:10:01Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T12:19:28Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T12:26:20Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T07:18:39Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T07:20:49Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T07:25:07Z
user: login: jelly
body: Overall looks good, didn't test it but gave a few small comments
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T14:22:41Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T15:12:40Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T15:13:40Z
user: login: tomasmatus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T15:19:27Z
user: login: cockpituous
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T16:19:13Z
user: login: jelly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T11:27:28Z
user: login: jelly
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T11:27:35Z

Review comments:
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: martinpitt
body: So ATA disks look at the `.SmartFailing` property, while NVME disks look at `.SmartCriticalWarning`. pkg/storaged/drive/smart-details.jsx works in a similar way. Is that some weird API inconsistency in udisks?
created_at: 2025-03-31T07:19:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(udisksdbus, "notify");
+
+    useInit(async () => {
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: martinpitt
body: Establishing proxies for this is rather expensive -- these will receive *all* property changes for the whole lifetime of the Cockpit session. Do you have some `console.log()` measurements how often this gets re-rendered? There may be a lot of notifications which have nothing to do with smart updates, like mounting/unmounting partitions. I hope/guess it's not too bad, but please ensure this doesn't get re-rendered unnecessarily too much.
created_at: 2025-03-31T07:26:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(udisksdbus, "notify");
+
+    useInit(async () => {
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: martinpitt
body: Wouldn't it make more sense to wait for the proxies *first* before assigning them to `proxies.*`? Then the rendering wouldn't run during the initialization time.
created_at: 2025-03-31T07:29:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(udisksdbus, "notify");
+
+    useInit(async () => {
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
+    });
+
+    if (proxies.drives_ata === null || proxies.nvme_controller === null) {
+        return;
+    }
+
+    const failingDisks = countFailingDisks(proxies);
+    if (failingDisks === 0) {
+        return;
+    }
+
+    return (
+        <li id="smart-status">
+            <Flex spaceItems={{ default: 'spaceItemsSm' }}>
+                <Icon status="danger">
+                    <ExclamationCircleIcon />
+                </Icon>
+                <Button variant="link" component="a" isInline
+                    onClick={ ev => { ev.preventDefault(); cockpit.jump("/storage") } }
+                >
+                    {cockpit.format(cockpit.ngettext("$0 disk is failing",
+                                                     "$0 disks are failing", failingDisks),
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: martinpitt
body: Strings need i18n
created_at: 2025-03-31T07:29:46Z
author_association: MEMBER
diff_hunk: @@ -136,6 +136,33 @@ class TestStorageSmart(storagelib.StorageSmartCase):
         b.wait_visible(self.card("Media drive"))
         b.wait_not_present(self.card("Device health (SMART)"))
 
+    def testSmartOverview(self):
path: test/verify/check-storage-smart
user: login: martinpitt
body: These are expensive destructive tests -- can you please fold these into the existing testSmart() and just switch back and forth between Overview and Storage pages?
created_at: 2025-03-31T07:31:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: tomasmatus
body: I wouldnt call it inconsisntency as technically these are two different APIs - for ata disk it only gives a boolean value, for nvme it goes more in depth than just a single bit
created_at: 2025-04-02T12:09:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(udisksdbus, "notify");
+
+    useInit(async () => {
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: tomasmatus
body: Is there a way to make it even cheaper? This is already subscribing to the lowest number of interfaces which are very specific for smart info., I dont think I can only ask for the properties that I need.
created_at: 2025-04-02T12:09:39Z
author_association: MEMBER
diff_hunk: @@ -136,6 +136,33 @@ class TestStorageSmart(storagelib.StorageSmartCase):
         b.wait_visible(self.card("Media drive"))
         b.wait_not_present(self.card("Device health (SMART)"))
 
+    def testSmartOverview(self):
path: test/verify/check-storage-smart
user: login: tomasmatus
body: ack!, will do
created_at: 2025-04-02T12:10:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(udisksdbus, "notify");
+
+    useInit(async () => {
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: martinpitt
body: I don't know the API, I'm asking you :grin: I just think it's worth adding a console.log and playing around with storage, to make sure that this doesn't get re-rendered each time something completely unrelated changes (like a mount)
created_at: 2025-04-02T12:19:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(udisksdbus, "notify");
+
+    useInit(async () => {
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: tomasmatus
body: I'll try that then 😄 I am not a dbus mastermind but since I'm only using the smart interfaces maybe that already filters out a lot of the updates
created_at: 2025-04-02T12:26:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(udisksdbus, "notify");
+
+    useInit(async () => {
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: tomasmatus
body: Yup, so it used to render on basically any storage event so I changed it to only listen to the `changed` event on both interfaces. I misunderstood how the whole dbus machinery works when it comes to notifications.
created_at: 2025-04-23T07:18:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(udisksdbus, "notify");
+
+    useInit(async () => {
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: tomasmatus
body: I had some issues here with the first render so I solved it differently but at least now the render doesn't run during init.
created_at: 2025-04-23T07:20:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(udisksdbus, "notify");
+
+    useInit(async () => {
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
+    });
+
+    if (proxies.drives_ata === null || proxies.nvme_controller === null) {
+        return;
+    }
+
+    const failingDisks = countFailingDisks(proxies);
+    if (failingDisks === 0) {
+        return;
+    }
+
+    return (
+        <li id="smart-status">
+            <Flex spaceItems={{ default: 'spaceItemsSm' }}>
+                <Icon status="danger">
+                    <ExclamationCircleIcon />
+                </Icon>
+                <Button variant="link" component="a" isInline
+                    onClick={ ev => { ev.preventDefault(); cockpit.jump("/storage") } }
+                >
+                    {cockpit.format(cockpit.ngettext("$0 disk is failing",
+                                                     "$0 disks are failing", failingDisks),
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: tomasmatus
body: not sure I follow? If you mean `_("str")` then the `cockpit.ngettext` handles it
created_at: 2025-04-23T07:25:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(proxies.drives_ata, "changed");
+    useEvent(proxies.nvme_controller, "changed");
+    const [initDone, setInitDone] = useState(false);
+
+    useInit(async () => {
+        const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: jelly
body: Do we need superuser for smart data? Does it not work without it?
created_at: 2025-05-12T14:01:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(proxies.drives_ata, "changed");
+    useEvent(proxies.nvme_controller, "changed");
+    const [initDone, setInitDone] = useState(false);
+
+    useInit(async () => {
+        const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
+
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
+
+        setInitDone(true);
+    });
+
+    if (initDone === false || proxies.drives_ata === null || proxies.nvme_controller === null) {
+        return;
+    }
+
+    const failingDisks = countFailingDisks(proxies);
+    if (failingDisks === 0) {
+        return;
+    }
+
+    return (
+        <li id="smart-status">
+            <Flex spaceItems={{ default: 'spaceItemsSm' }}>
+                <Icon status="danger">
+                    <ExclamationCircleIcon />
+                </Icon>
+                <Button variant="link" component="a" isInline
+                    onClick={ ev => { ev.preventDefault(); cockpit.jump("/storage") } }
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: jelly
body: I don't see the need for preventdefault? Other examples in our codebase don't use it
created_at: 2025-05-12T14:03:41Z
author_association: MEMBER
diff_hunk: @@ -91,42 +91,58 @@ class TestStorageSmart(storagelib.StorageSmartCase):
             return
 
         m.upload(["verify/files/smart-dumps"], "/tmp")
+        # Failing disk, storage page is not loaded
+        self.set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
+        self.login_and_go("/system")
+        b.wait_in_text("#smart-status", "1 disk is failing")
+
+        self.set_smart_dump("MCCOE64GEMPP--2.9.09", "/dev/sda")
+        b.wait_not_present("#smart-status")
path: test/verify/check-storage-smart
user: login: jelly
body: Shouldn't we first navigate to the storage overview?  To see the failing disk? Otherwise the test fails a bit weird. Maybe reverse it, first failing, then non-failing, then failing if you don't want to b.go() too often
created_at: 2025-05-12T14:07:00Z
author_association: MEMBER
diff_hunk: @@ -66,10 +66,10 @@ class TestStorageSmart(storagelib.StorageSmartCase):
         else:
             return False
 
-    def testSmart(self):
-        def set_smart_dump(name: str, block: str):
-            self.machine.execute(f"udisksctl smart-simulate -f /tmp/smart-dumps/{name} -b {block}")
+    def set_smart_dump(self, name: str, block: str):
+        self.machine.execute(f"udisksctl smart-simulate -f /tmp/smart-dumps/{name} -b {block}")
path: test/verify/check-storage-smart
user: login: jelly
body: Still only used in `testSmart` right?
created_at: 2025-05-12T14:08:08Z
author_association: MEMBER
diff_hunk: @@ -91,42 +91,58 @@ class TestStorageSmart(storagelib.StorageSmartCase):
             return
 
         m.upload(["verify/files/smart-dumps"], "/tmp")
+        # Failing disk, storage page is not loaded
+        self.set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
+        self.login_and_go("/system")
+        b.wait_in_text("#smart-status", "1 disk is failing")
+
+        self.set_smart_dump("MCCOE64GEMPP--2.9.09", "/dev/sda")
+        b.wait_not_present("#smart-status")
path: test/verify/check-storage-smart
user: login: tomasmatus
body: Not sure I follow?

>  first failing, then non-failing, then failing

this is exactly what the test does, first the disk is failing, check that it's visible on the overview page, then non-failing so nothing should be on the overview page, then failing again and it navigates to the storage page to see the failing disk
created_at: 2025-05-13T15:12:40Z
author_association: MEMBER
diff_hunk: @@ -66,10 +66,10 @@ class TestStorageSmart(storagelib.StorageSmartCase):
         else:
             return False
 
-    def testSmart(self):
-        def set_smart_dump(name: str, block: str):
-            self.machine.execute(f"udisksctl smart-simulate -f /tmp/smart-dumps/{name} -b {block}")
+    def set_smart_dump(self, name: str, block: str):
+        self.machine.execute(f"udisksctl smart-simulate -f /tmp/smart-dumps/{name} -b {block}")
path: test/verify/check-storage-smart
user: login: tomasmatus
body: yah, at first I had this in two tests but I moved it back to one so it doesn't need to be here but in the end it shouldn't matter all that much
created_at: 2025-05-13T15:13:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(proxies.drives_ata, "changed");
+    useEvent(proxies.nvme_controller, "changed");
+    const [initDone, setInitDone] = useState(false);
+
+    useInit(async () => {
+        const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2", { superuser: "try" });
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: tomasmatus
body: works without it, my fault. I was thinking that a system bus means it needs superuser
created_at: 2025-05-13T15:19:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: cockpituous
body: These 2 added lines are not executed by any test.
created_at: 2025-05-13T16:19:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: cockpituous
body: These 4 added lines are not executed by any test.
created_at: 2025-05-13T16:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: cockpituous
body: This added line is not executed by any test.
created_at: 2025-05-13T16:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: cockpituous
body: This added line is not executed by any test.
created_at: 2025-05-13T16:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(proxies.drives_ata, "changed");
+    useEvent(proxies.nvme_controller, "changed");
+    const [initDone, setInitDone] = useState(false);
+
+    useInit(async () => {
+        const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2");
+
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
+
+        setInitDone(true);
+    });
+
+    if (initDone === false || proxies.drives_ata === null || proxies.nvme_controller === null) {
+        return;
+    }
+
+    const failingDisks = countFailingDisks(proxies);
+    if (failingDisks === 0) {
+        return;
+    }
+
+    return (
+        <li id="smart-status">
+            <Flex spaceItems={{ default: 'spaceItemsSm' }}>
+                <Icon status="danger">
+                    <ExclamationCircleIcon />
+                </Icon>
+                <Button variant="link" component="a" isInline
+                    onClick={() => cockpit.jump("/storage")}
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: cockpituous
body: These 8 added lines are not executed by any test.
created_at: 2025-05-13T16:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,101 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import React, { useState } from 'react';
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { ExclamationCircleIcon } from "@patternfly/react-icons";
+import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
+
+import cockpit from "cockpit";
+import { useEvent, useInit } from 'hooks';
+
+function countFailingDisks(proxies) {
+    const sataFail = Object.keys(proxies.drives_ata).reduce((acc, drive) => {
+        const smart = proxies.drives_ata[drive];
+        if (smart.SmartFailing) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    const nvmeFail = Object.keys(proxies.nvme_controller).reduce((acc, drive) => {
+        const smart = proxies.nvme_controller[drive];
+        if (smart.SmartCriticalWarning.length > 0) {
+            return acc + 1;
+        } else {
+            return acc;
+        }
+    }, 0);
+
+    return sataFail + nvmeFail;
+}
+
+const proxies = {
+    drives_ata: null,
+    nvme_controller: null,
+};
+
+export const SmartOverviewStatus = () => {
+    useEvent(proxies.drives_ata, "changed");
+    useEvent(proxies.nvme_controller, "changed");
+    const [initDone, setInitDone] = useState(false);
+
+    useInit(async () => {
+        const udisksdbus = cockpit.dbus("org.freedesktop.UDisks2");
+
+        const addProxy = (iface) => {
+            return udisksdbus.proxies("org.freedesktop.UDisks2." + iface, "/org/freedesktop/UDisks2");
+        };
+
+        proxies.drives_ata = addProxy("Drive.Ata");
+        proxies.nvme_controller = addProxy("NVMe.Controller");
+        await Promise.all(Object.keys(proxies).map(proxy => proxies[proxy].wait()));
+
+        setInitDone(true);
+    });
+
+    if (initDone === false || proxies.drives_ata === null || proxies.nvme_controller === null) {
+        return;
+    }
+
+    const failingDisks = countFailingDisks(proxies);
+    if (failingDisks === 0) {
+        return;
+    }
+
+    return (
+        <li id="smart-status">
+            <Flex spaceItems={{ default: 'spaceItemsSm' }}>
+                <Icon status="danger">
+                    <ExclamationCircleIcon />
+                </Icon>
+                <Button variant="link" component="a" isInline
+                    onClick={() => cockpit.jump("/storage")}
+                >
+                    {cockpit.format(cockpit.ngettext("$0 disk is failing",
+                                                     "$0 disks are failing", failingDisks),
+                                    failingDisks)}
+                </Button>
+            </Flex>
+        </li>
path: pkg/systemd/overview-cards/smart-status.jsx
user: login: cockpituous
body: These 6 added lines are not executed by any test.
created_at: 2025-05-13T16:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -91,42 +91,58 @@ class TestStorageSmart(storagelib.StorageSmartCase):
             return
 
         m.upload(["verify/files/smart-dumps"], "/tmp")
+        # Failing disk, storage page is not loaded
+        self.set_smart_dump("Maxtor_96147H8--BAC51KJ0--2", "/dev/sda")
+        self.login_and_go("/system")
+        b.wait_in_text("#smart-status", "1 disk is failing")
+
+        self.set_smart_dump("MCCOE64GEMPP--2.9.09", "/dev/sda")
+        b.wait_not_present("#smart-status")
path: test/verify/check-storage-smart
user: login: jelly
body: It is... not sure how I missed that.
created_at: 2025-05-15T11:27:28Z
author_association: MEMBER

PR comments:
user: login: tomasmatus
created_at: 2025-03-26T16:09:14Z
body: rebased to pf6, fogot to add no-test in order to not hog queue, sorry :(
author_association: MEMBER
user: login: jelly
created_at: 2025-05-15T11:27:58Z
body: @tomasmatus please write a release note before the release next week
author_association: MEMBER
user: login: jelly
created_at: 2025-05-15T11:45:10Z
body: @martinpitt is this review still blocked from your side?
author_association: MEMBER
user: login: martinpitt
created_at: 2025-05-15T12:00:56Z
body: @jelly No, it's not.  My initial review got addressed, and I don't want to block  this. Thanks @tomasmatus !
author_association: MEMBER