Repository: NVIDIA/NVFlare

Issue:
Admin API get_connected_client_list() includes the table heading "CLIENT" in results
When using the python admin api to `get_connected_client_list()`, the reply details includes the table header "CLIENT" with the actual results:
```
>>> adm.api.get_connected_client_list()
{'status': <APIStatus.SUCCESS: 'SUCCESS'>, 'details': {<FLDetailKey.CONNECTED_CLIENTS: 'connected_clients'>: ['CLIENT', 'site2', 'site1']}}
```

Straightforward fix --

There's a small mismatch in the column headers hardcoded in two files. The column header is defined as "Client" in the following (.upper gets called elsewhere): https://github.com/NVIDIA/NVFlare/blob/65848ff5ecdac29b26ca1a448f2106e06cda1d23/nvflare/private/fed/server/training_cmds.py#L589
however the admin api function is trying to filter out "CLIENT NAME" instead: https://github.com/NVIDIA/NVFlare/blob/99c199e2ed2b737a939114e23d4c4b765e71e0c3/nvflare/fuel/hci/client/fl_admin_api.py#L769-L776

Hope this helps!


Discussion:
Thank you for pointing this out, we will take a look and fix this.

Resolving patch:
diff --git a/nvflare/fuel/hci/client/fl_admin_api.py b/nvflare/fuel/hci/client/fl_admin_api.py
--- a/nvflare/fuel/hci/client/fl_admin_api.py
+++ b/nvflare/fuel/hci/client/fl_admin_api.py
@@ -11,6 +11,7 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+
 import os
 import re
 import time
@@ -771,9 +772,9 @@ def get_connected_client_list(self) -> FLAdminAPIResponse:
         if reply["status"] == APIStatus.SUCCESS:
             status_table = reply["details"][FLDetailKey.STATUS_TABLE]
             list_of_connected_clients = []
-            for row in status_table:
-                if row[0] != "CLIENT NAME":
-                    list_of_connected_clients.append(row[0])
+            # first line is the header of table
+            for row in status_table[1:]:
+                list_of_connected_clients.append(row[0])
             return FLAdminAPIResponse(APIStatus.SUCCESS, {FLDetailKey.CONNECTED_CLIENTS: list_of_connected_clients})
         else:
             return FLAdminAPIResponse(APIStatus.ERROR_RUNTIME, {"message": "runtime error"}, reply)
diff --git a/nvflare/fuel/hci/table.py b/nvflare/fuel/hci/table.py
--- a/nvflare/fuel/hci/table.py
+++ b/nvflare/fuel/hci/table.py
@@ -28,15 +28,15 @@ def repeat_to_length(string_to_expand, length):
 
 
 class Table(object):
-    """
-    Table structure to write to. The headers are set in the init and the data is input to rows before write
-    writes the table.
+    def __init__(self, headers: Optional[List[str]] = None):
+        """A structure with header and rows of records.
 
-    Args:
-            headers: headers of the table
-    """
+        Note:
+            The header will be converted to capital letters.
 
-    def __init__(self, headers: Optional[List[str]] = None):
+        Args:
+            headers: headers of the table
+        """
         self.rows = []
         if headers and len(headers) > 0:
             new_headers = []
@@ -45,9 +45,11 @@ def __init__(self, headers: Optional[List[str]] = None):
             self.rows.append(new_headers)
 
     def set_rows(self, rows):
+        """Sets the rows of records."""
         self.rows = rows
 
     def add_row(self, row: List[str]):
+        """Adds a record."""
         self.rows.append(row)
 
     def write(self, writer):
diff --git a/nvflare/private/fed/server/training_cmds.py b/nvflare/private/fed/server/training_cmds.py
--- a/nvflare/private/fed/server/training_cmds.py
+++ b/nvflare/private/fed/server/training_cmds.py
@@ -578,7 +578,7 @@ def check_status(self, conn: Connection, args: List[str]):
         if dst == self.TARGET_TYPE_SERVER:
             engine_info = engine.get_engine_info()
             conn.append_string(f"Engine status: {engine_info.status.value}")
-            table = conn.append_table(["Job_id", "App Name"])
+            table = conn.append_table(["job_id", "app name"])
             for job_id, app_name in engine_info.app_names.items():
                 table.add_row([job_id, app_name])
 
@@ -586,7 +586,7 @@ def check_status(self, conn: Connection, args: List[str]):
             conn.append_string("Registered clients: {} ".format(len(clients)))
 
             if clients:
-                table = conn.append_table(["Client", "Token", "Last Connect Time"])
+                table = conn.append_table(["client", "token", "last connect time"])
                 for c in clients:
                     if not isinstance(c, Client):
                         raise TypeError("c must be Client but got {}".format(type(c)))
@@ -594,11 +594,11 @@ def check_status(self, conn: Connection, args: List[str]):
         elif dst == self.TARGET_TYPE_CLIENT:
             message = new_message(conn, topic=TrainingTopic.CHECK_STATUS, body="")
             replies = self.send_request_to_clients(conn, message)
-            self._process_status_replies(conn, replies)
+            self._process_client_status_replies(conn, replies)
         else:
             conn.append_error("invalid target type {}. Usage: check_status server|client ...".format(dst))
 
-    def _process_status_replies(self, conn, replies):
+    def _process_client_status_replies(self, conn, replies):
         if not replies:
             conn.append_error("no responses from clients")
             return


Test patch: