diff --git a/include/crm/common/nodes.h b/include/crm/common/nodes.h
index 150b29f1e7..e1a994b47a 100644
--- a/include/crm/common/nodes.h
+++ b/include/crm/common/nodes.h
@@ -1,190 +1,192 @@
 /*
  * Copyright 2004-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__CRM_COMMON_NODES__H
 #  define PCMK__CRM_COMMON_NODES__H
 
 #include <stdbool.h>                    // bool
 #include <glib.h>                       // gboolean, GList, GHashTable
 
 #include <crm/common/scheduler_types.h> // pcmk_resource_t, pcmk_scheduler_t
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 /*!
  * \file
  * \brief Scheduler API for nodes
  * \ingroup core
  */
 
 // Special node attributes
 
 #define PCMK_NODE_ATTR_MAINTENANCE          "maintenance"
 #define PCMK_NODE_ATTR_STANDBY              "standby"
 #define PCMK_NODE_ATTR_TERMINATE            "terminate"
 
 
 //! Possible node types
 enum node_type {
     pcmk_node_variant_cluster  = 1,     //!< Cluster layer node
     pcmk_node_variant_remote   = 2,     //!< Pacemaker Remote node
 
     node_ping   = 0,      //!< \deprecated Do not use
 #if !defined(PCMK_ALLOW_DEPRECATED) || (PCMK_ALLOW_DEPRECATED == 1)
     //! \deprecated Use pcmk_node_variant_cluster instead
     node_member = pcmk_node_variant_cluster,
 
     //! \deprecated Use pcmk_node_variant_remote instead
     node_remote = pcmk_node_variant_remote,
 #endif
 };
 
 //! When to probe a resource on a node (as specified in location constraints)
 enum pe_discover_e {
     pcmk_probe_always       = 0,    //! Always probe resource on node
     pcmk_probe_never        = 1,    //! Never probe resource on node
     pcmk_probe_exclusive    = 2,    //! Probe only on designated nodes
 
 #if !defined(PCMK_ALLOW_DEPRECATED) || (PCMK_ALLOW_DEPRECATED == 1)
     //! \deprecated Use pcmk_probe_always instead
     pe_discover_always      = pcmk_probe_always,
 
     //! \deprecated Use pcmk_probe_never instead
     pe_discover_never       = pcmk_probe_never,
 
     //! \deprecated Use pcmk_probe_exclusive instead
     pe_discover_exclusive   = pcmk_probe_exclusive,
 #endif
 };
 
 //! Basic node information (all node objects for the same node share this)
 struct pe_node_shared_s {
     const char *id;             //!< Node ID at the cluster layer
     const char *uname;          //!< Node name in cluster
     enum node_type type;        //!< Node variant
 
     // @TODO Convert these into a flag group
     gboolean online;            //!< Whether online
     gboolean standby;           //!< Whether in standby mode
     gboolean standby_onfail;    //!< Whether in standby mode due to on-fail
     gboolean pending;           //!< Whether controller membership is pending
     gboolean unclean;           //!< Whether node requires fencing
     gboolean unseen;            //!< Whether node has never joined cluster
     gboolean shutdown;          //!< Whether shutting down
     gboolean expected_up;       //!< Whether expected join state is member
     gboolean is_dc;             //!< Whether node is cluster's DC
     gboolean maintenance;       //!< Whether in maintenance mode
     gboolean rsc_discovery_enabled; //!< Whether probes are allowed on node
 
     /*!
      * Whether this is a guest node whose guest resource must be recovered or a
      * remote node that must be fenced
      */
     gboolean remote_requires_reset;
 
     /*!
      * Whether this is a Pacemaker Remote node that was fenced since it was last
      * connected by the cluster
      */
     gboolean remote_was_fenced;
 
     /*!
      * Whether this is a Pacemaker Remote node previously marked in its
      * node state as being in maintenance mode
      */
     gboolean remote_maintenance;
 
     gboolean unpacked;              //!< Whether node history has been unpacked
 
     /*!
      * Number of resources active on this node (valid after CIB status section
      * has been unpacked, as long as pcmk_sched_no_counts was not set)
      */
     int num_resources;
 
     //! Remote connection resource for node, if it is a Pacemaker Remote node
     pcmk_resource_t *remote_rsc;
 
     GList *running_rsc;             //!< List of resources active on node
     GList *allocated_rsc;           //!< List of resources assigned to node
     GHashTable *attrs;              //!< Node attributes
     GHashTable *utilization;        //!< Node utilization attributes
     GHashTable *digest_cache;       //!< Cache of calculated resource digests
 
     /*!
      * Sum of priorities of all resources active on node and on any guest nodes
      * connected to this node, with +1 for promoted instances (used to compare
      * nodes for PCMK_OPT_PRIORITY_FENCING_DELAY)
      */
     int priority;
 
     pcmk_scheduler_t *data_set;     //!< Cluster that node is part of
 };
 
 //! Implementation of pcmk_node_t
 struct pe_node_s {
     int weight;         //!< Node score for a given resource
     gboolean fixed;     //!< \deprecated Do not use
     int count;          //!< Counter reused by assignment and promotion code
     struct pe_node_shared_s *details;   //!< Basic node information
 
     // @COMPAT This should be enum pe_discover_e
     int rsc_discover_mode;              //!< Probe mode (enum pe_discover_e)
 };
 
+bool pcmk_node_is_online(const pcmk_node_t *node);
+
 /*!
  * \internal
  * \brief Return a string suitable for logging as a node name
  *
  * \param[in] node  Node to return a node name string for
  *
  * \return Node name if available, otherwise node ID if available,
  *         otherwise "unspecified node" if node is NULL or "unidentified node"
  *         if node has neither a name nor ID.
  */
 static inline const char *
 pcmk__node_name(const pcmk_node_t *node)
 {
     if (node == NULL) {
         return "unspecified node";
 
     } else if (node->details->uname != NULL) {
         return node->details->uname;
 
     } else if (node->details->id != NULL) {
         return node->details->id;
 
     } else {
         return "unidentified node";
     }
 }
 
 /*!
  * \internal
  * \brief Check whether two node objects refer to the same node
  *
  * \param[in] node1  First node object to compare
  * \param[in] node2  Second node object to compare
  *
  * \return true if \p node1 and \p node2 refer to the same node
  */
 static inline bool
 pcmk__same_node(const pcmk_node_t *node1, const pcmk_node_t *node2)
 {
     return (node1 != NULL) && (node2 != NULL)
            && (node1->details == node2->details);
 }
 
 #ifdef __cplusplus
 }
 #endif
 
 #endif // PCMK__CRM_COMMON_NODES__H
diff --git a/lib/common/nodes.c b/lib/common/nodes.c
index b79a52e270..bc6c2f6514 100644
--- a/lib/common/nodes.c
+++ b/lib/common/nodes.c
@@ -1,26 +1,41 @@
 /*
  * Copyright 2022-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
+#include <libxml/tree.h>        // xmlNode
 #include <crm/common/nvpair.h>
 
+/*!
+ * \internal
+ * \brief Check whether a node is online
+ *
+ * \param[in] node  Node to check
+ *
+ * \return true if \p node is online, otherwise false
+ */
+bool
+pcmk_node_is_online(const pcmk_node_t *node)
+{
+    return (node != NULL) && node->details->online;
+}
+
 void
 pcmk__xe_add_node(xmlNode *xml, const char *node, int nodeid)
 {
     CRM_ASSERT(xml != NULL);
 
     if (node != NULL) {
         crm_xml_add(xml, PCMK__XA_ATTR_HOST, node);
     }
 
     if (nodeid > 0) {
         crm_xml_add_int(xml, PCMK__XA_ATTR_HOST_ID, nodeid);
     }
 }
diff --git a/lib/common/tests/nodes/Makefile.am b/lib/common/tests/nodes/Makefile.am
index fc19925dd6..3a1e40849f 100644
--- a/lib/common/tests/nodes/Makefile.am
+++ b/lib/common/tests/nodes/Makefile.am
@@ -1,16 +1,17 @@
 #
 # Copyright 2024 the Pacemaker project contributors
 #
 # The version control history for this file may have further details.
 #
 # This source code is licensed under the GNU General Public License version 2
 # or later (GPLv2+) WITHOUT ANY WARRANTY.
 #
 
 include $(top_srcdir)/mk/tap.mk
 include $(top_srcdir)/mk/unittest.mk
 
 # Add "_test" to the end of all test program names to simplify .gitignore.
-check_PROGRAMS = pcmk__xe_add_node_test
+check_PROGRAMS = pcmk_node_is_online_test		\
+		 pcmk__xe_add_node_test
 
 TESTS = $(check_PROGRAMS)
diff --git a/lib/common/tests/nodes/pcmk_node_is_online_test.c b/lib/common/tests/nodes/pcmk_node_is_online_test.c
new file mode 100644
index 0000000000..d22e3b4dd3
--- /dev/null
+++ b/lib/common/tests/nodes/pcmk_node_is_online_test.c
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>      // NULL
+#include <glib.h>       // TRUE, FALSE
+
+#include <crm/common/nodes.h>
+#include <crm/common/unittest_internal.h>
+
+static void
+null_is_offline(void **state)
+{
+    assert_false(pcmk_node_is_online(NULL));
+}
+
+static void
+node_is_online(void **state)
+{
+    struct pe_node_shared_s shared = {
+        .online = TRUE,
+    };
+
+    pcmk_node_t node = {
+        .details = &shared,
+    };
+
+    assert_true(pcmk_node_is_online(&node));
+}
+
+static void
+node_is_offline(void **state)
+{
+    struct pe_node_shared_s shared = {
+        .online = FALSE,
+    };
+    pcmk_node_t node = {
+        .details = &shared,
+    };
+
+    assert_false(pcmk_node_is_online(&node));
+}
+
+PCMK__UNIT_TEST(NULL, NULL,
+                cmocka_unit_test(null_is_offline),
+                cmocka_unit_test(node_is_online),
+                cmocka_unit_test(node_is_offline))