Repository: jupyterhub/jupyterhub

Issue:
Add "Last seen" column in admin page
### Proposed change
<!-- Use this section to describe the feature you'd like to be added. -->
I just updated jupyterhub. There used to be a Last seen column in the admin panel, and this was a feature I used a lot. Now there is a "Last activity" column instead, which show "Never" if no server is running. As I use the culling system anyway, this column is rarely of any use. 

I would like to have the "Last seen" column back. 

### (Optional): Suggest a solution
I'm not fluent enough with the jupyterhub project to test a solution. I understand that the file to modify is probably this one https://github.com/jupyterhub/jupyterhub/blob/main/jsx/src/components/ServerDashboard/ServerDashboard.jsx around the line 261. Maybe switching "server.last_activity" to "user.last_activity" would do ? I couldn't find out how to test this.



Discussion:

Resolving patch:
diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py
--- a/jupyterhub/apihandlers/base.py
+++ b/jupyterhub/apihandlers/base.py
@@ -187,22 +187,44 @@ def write_error(self, status_code, **kwargs):
             json.dumps({'status': status_code, 'message': message or status_message})
         )
 
-    def server_model(self, spawner):
+    def server_model(self, spawner, *, user=None):
         """Get the JSON model for a Spawner
-        Assume server permission already granted"""
+        Assume server permission already granted
+        """
+        if isinstance(spawner, orm.Spawner):
+            # if an orm.Spawner is passed,
+            # create a model for a stopped Spawner
+            # not all info is available without the higher-level Spawner wrapper
+            orm_spawner = spawner
+            pending = None
+            ready = False
+            stopped = True
+            user = user
+            if user is None:
+                raise RuntimeError("Must specify User with orm.Spawner")
+            state = orm_spawner.state
+        else:
+            orm_spawner = spawner.orm_spawner
+            pending = spawner.pending
+            ready = spawner.ready
+            user = spawner.user
+            stopped = not spawner.active
+            state = spawner.get_state()
+
         model = {
-            'name': spawner.name,
-            'last_activity': isoformat(spawner.orm_spawner.last_activity),
-            'started': isoformat(spawner.orm_spawner.started),
-            'pending': spawner.pending,
-            'ready': spawner.ready,
-            'url': url_path_join(spawner.user.url, url_escape_path(spawner.name), '/'),
+            'name': orm_spawner.name,
+            'last_activity': isoformat(orm_spawner.last_activity),
+            'started': isoformat(orm_spawner.started),
+            'pending': pending,
+            'ready': ready,
+            'stopped': stopped,
+            'url': url_path_join(user.url, url_escape_path(spawner.name), '/'),
             'user_options': spawner.user_options,
-            'progress_url': spawner._progress_url,
+            'progress_url': user.progress_url(spawner.name),
         }
         scope_filter = self.get_scope_filter('admin:server_state')
         if scope_filter(spawner, kind='server'):
-            model['state'] = spawner.get_state()
+            model['state'] = state
         return model
 
     def token_model(self, token):
@@ -248,10 +270,22 @@ def _filter_model(self, model, access_map, entity, kind, keys=None):
             keys.update(allowed_keys)
         return model
 
+    _include_stopped_servers = None
+
+    @property
+    def include_stopped_servers(self):
+        """Whether stopped servers should be included in user models"""
+        if self._include_stopped_servers is None:
+            self._include_stopped_servers = self.get_argument(
+                "include_stopped_servers", "0"
+            ).lower() not in {"0", "false"}
+        return self._include_stopped_servers
+
     def user_model(self, user):
         """Get the JSON model for a User object"""
         if isinstance(user, orm.User):
             user = self.users[user.id]
+        include_stopped_servers = self.include_stopped_servers
         model = {
             'kind': 'user',
             'name': user.name,
@@ -291,18 +325,29 @@ def user_model(self, user):
             if '' in user.spawners and 'pending' in allowed_keys:
                 model['pending'] = user.spawners[''].pending
 
-            servers = model['servers'] = {}
+            servers = {}
             scope_filter = self.get_scope_filter('read:servers')
             for name, spawner in user.spawners.items():
                 # include 'active' servers, not just ready
                 # (this includes pending events)
-                if spawner.active and scope_filter(spawner, kind='server'):
+                if (spawner.active or include_stopped_servers) and scope_filter(
+                    spawner, kind='server'
+                ):
                     servers[name] = self.server_model(spawner)
-            if not servers and 'servers' not in allowed_keys:
+
+            if include_stopped_servers:
+                # add any stopped servers in the db
+                seen = set(servers.keys())
+                for name, orm_spawner in user.orm_spawners.items():
+                    if name not in seen and scope_filter(orm_spawner, kind='server'):
+                        servers[name] = self.server_model(orm_spawner, user=user)
+
+            if "servers" in allowed_keys or servers:
                 # omit servers if no access
                 # leave present and empty
                 # if request has access to read servers in general
-                model.pop('servers')
+                model["servers"] = servers
+
         return model
 
     def group_model(self, group):


Test patch:
diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py
--- a/jupyterhub/tests/test_named_servers.py
+++ b/jupyterhub/tests/test_named_servers.py
@@ -1,6 +1,7 @@
 """Tests for named servers"""
 import asyncio
 import json
+import time
 from unittest import mock
 from urllib.parse import unquote, urlencode, urlparse
 
@@ -61,6 +62,7 @@ async def test_default_server(app, named_servers):
                     'url': user.url,
                     'pending': None,
                     'ready': True,
+                    'stopped': False,
                     'progress_url': 'PREFIX/hub/api/users/{}/server/progress'.format(
                         username
                     ),
@@ -148,6 +150,7 @@ async def test_create_named_server(
                     'url': url_path_join(user.url, escapedname, '/'),
                     'pending': None,
                     'ready': True,
+                    'stopped': False,
                     'progress_url': 'PREFIX/hub/api/users/{}/servers/{}/progress'.format(
                         username, escapedname
                     ),
@@ -433,3 +436,61 @@ async def test_named_server_stop_server(app, username, named_servers):
     assert user.spawners[server_name].server is None
     assert user.spawners[''].server
     assert user.running
+
+
+@pytest.mark.parametrize(
+    "include_stopped_servers",
+    [True, False],
+)
+async def test_stopped_servers(app, user, named_servers, include_stopped_servers):
+    r = await api_request(app, 'users', user.name, 'server', method='post')
+    r.raise_for_status()
+    r = await api_request(app, 'users', user.name, 'servers', "named", method='post')
+    r.raise_for_status()
+
+    # wait for starts
+    for i in range(60):
+        r = await api_request(app, 'users', user.name)
+        r.raise_for_status()
+        user_model = r.json()
+        if not all(s["ready"] for s in user_model["servers"].values()):
+            time.sleep(1)
+        else:
+            break
+    else:
+        raise TimeoutError(f"User never stopped: {user_model}")
+
+    r = await api_request(app, 'users', user.name, 'server', method='delete')
+    r.raise_for_status()
+    r = await api_request(app, 'users', user.name, 'servers', "named", method='delete')
+    r.raise_for_status()
+
+    # wait for stops
+    for i in range(60):
+        r = await api_request(app, 'users', user.name)
+        r.raise_for_status()
+        user_model = r.json()
+        if not all(s["stopped"] for s in user_model["servers"].values()):
+            time.sleep(1)
+        else:
+            break
+    else:
+        raise TimeoutError(f"User never stopped: {user_model}")
+
+    # we have two stopped servers
+    path = f"users/{user.name}"
+    if include_stopped_servers:
+        path = f"{path}?include_stopped_servers"
+    r = await api_request(app, path)
+    r.raise_for_status()
+    user_model = r.json()
+    servers = list(user_model["servers"].values())
+    if include_stopped_servers:
+        assert len(servers) == 2
+        assert all(s["last_activity"] for s in servers)
+        assert all(s["started"] is None for s in servers)
+        assert all(s["stopped"] for s in servers)
+        assert not any(s["ready"] for s in servers)
+        assert not any(s["pending"] for s in servers)
+    else:
+        assert user_model["servers"] == {}