Repository: jupyterhub/jupyterhub

Issue:
Cannot perform a global sort by 'Running' status in the admin panel
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Bug description

The /hub/admin#/ endpoint shows a table of users and their servers. However, in jupyterhub-3.x the sorting seems to be broken. We're upgrading from 1.x where sorting by the 'Running' column in the ascending order would list all running servers. However, in jhub-3.x only running servers present on the current 'page' are shown. Since pagination only shows 50 entries per page other running servers are shown at the top of the pages they were paginated to. With a large list of users this sorting option becomes less than useful.

#### Expected behaviour

Sorting /hub/admin#/ by 'Running' in the ascending order should show all running servers on the first page(s) i.e. all running servers must be at the top of the list.

#### Actual behaviour

only running servers present on the current 'page' are shown. Since pagination only shows 50 entries per page other running servers are shown at the top of the pages they were paginated to.

### How to reproduce

<!-- Use this section to describe the steps that a user would take to experience this bug. -->

1. Go to  /hub/admin#/
2. Click on 'Running' column header
3. Expect to see all running servers at the top of the table

### Your personal set up

<!--
Tell us a little about the system you're using.
Please include information about how you installed,
e.g. are you using a distribution such as zero-to-jupyterhub or the-littlest-jupyterhub.
 -->

 - OS:
 <!-- [e.g. ubuntu 20.04, macOS 11.0] -->
 - Version(s): Conda environment on RHEL7
 <!-- e.g. jupyterhub --version, python --version --->

<details><summary>Full environment</summary>
<!-- For reproduction, it's useful to have the full environment. For example, the output of `pip freeze` or `conda list` --->

```
# Name                    Version                   Build  Channel
_libgcc_mutex             0.1                 conda_forge    conda-forge
_openmp_mutex             4.5                       2_gnu    conda-forge
aiofiles                  22.1.0             pyhd8ed1ab_0    conda-forge
aiosqlite                 0.18.0             pyhd8ed1ab_0    conda-forge
alembic                   1.10.3             pyhd8ed1ab_0    conda-forge
anyio                     3.6.2              pyhd8ed1ab_0    conda-forge
argon2-cffi               21.3.0             pyhd8ed1ab_0    conda-forge
argon2-cffi-bindings      21.2.0          py311hd4cff14_3    conda-forge
asttokens                 2.2.1              pyhd8ed1ab_0    conda-forge
async_generator           1.10                       py_0    conda-forge
attrs                     22.2.0             pyh71513ae_0    conda-forge
babel                     2.12.1             pyhd8ed1ab_1    conda-forge
backcall                  0.2.0              pyh9f0ad1d_0    conda-forge
backports                 1.0                pyhd8ed1ab_3    conda-forge
backports.functools_lru_cache 1.6.4              pyhd8ed1ab_0    conda-forge
batchspawner              1.2.0              pyhd8ed1ab_0    conda-forge
beautifulsoup4            4.12.2             pyha770c72_0    conda-forge
bleach                    6.0.0              pyhd8ed1ab_0    conda-forge
blinker                   1.6.2              pyhd8ed1ab_0    conda-forge
brotlipy                  0.7.0           py311hd4cff14_1005    conda-forge
bzip2                     1.0.8                h7f98852_4    conda-forge
c-ares                    1.18.1               h7f98852_0    conda-forge
ca-certificates           2023.5.7             hbcca054_0    conda-forge
certifi                   2023.5.7           pyhd8ed1ab_0    conda-forge
certipy                   0.1.3                      py_0    conda-forge
cffi                      1.15.1          py311h409f033_3    conda-forge
charset-normalizer        3.1.0              pyhd8ed1ab_0    conda-forge
colorama                  0.4.6              pyhd8ed1ab_0    conda-forge
comm                      0.1.3              pyhd8ed1ab_0    conda-forge
configurable-http-proxy   4.5.4                he2f69ee_2    conda-forge
cryptography              40.0.2          py311h9b4c7bb_0    conda-forge
debugpy                   1.6.7           py311hcafe171_0    conda-forge
decorator                 5.1.1              pyhd8ed1ab_0    conda-forge
defusedxml                0.7.1              pyhd8ed1ab_0    conda-forge
entrypoints               0.4                pyhd8ed1ab_0    conda-forge
executing                 1.2.0              pyhd8ed1ab_0    conda-forge
flit-core                 3.8.0              pyhd8ed1ab_0    conda-forge
gitdb                     4.0.10             pyhd8ed1ab_0    conda-forge
gitpython                 3.1.31             pyhd8ed1ab_0    conda-forge
greenlet                  2.0.2           py311hcafe171_0    conda-forge
icu                       70.1                 h27087fc_0    conda-forge
idna                      3.4                pyhd8ed1ab_0    conda-forge
importlib-metadata        6.5.0              pyha770c72_0    conda-forge
importlib_metadata        6.5.0                hd8ed1ab_0    conda-forge
importlib_resources       5.12.0             pyhd8ed1ab_0    conda-forge
ipykernel                 6.22.0             pyh210e3f2_0    conda-forge
ipython                   8.12.0             pyh41d4057_0    conda-forge
ipython_genutils          0.2.0                      py_1    conda-forge
jedi                      0.18.2             pyhd8ed1ab_0    conda-forge
jhub-remote-user-authenticator 0.1.0                    pypi_0    pypi
jinja2                    3.1.2              pyhd8ed1ab_1    conda-forge
json5                     0.9.5              pyh9f0ad1d_0    conda-forge
jsonschema                4.17.3             pyhd8ed1ab_0    conda-forge
jupyter-server-mathjax    0.2.6              pyh5bfe37b_1    conda-forge
jupyter_client            8.2.0              pyhd8ed1ab_0    conda-forge
jupyter_core              5.3.0           py311h38be061_0    conda-forge
jupyter_events            0.6.3              pyhd8ed1ab_0    conda-forge
jupyter_server            2.5.0              pyhd8ed1ab_0    conda-forge
jupyter_server_fileid     0.9.0              pyhd8ed1ab_0    conda-forge
jupyter_server_terminals  0.4.4              pyhd8ed1ab_1    conda-forge
jupyter_server_ydoc       0.8.0              pyhd8ed1ab_0    conda-forge
jupyter_telemetry         0.1.0              pyhd8ed1ab_1    conda-forge
jupyter_ydoc              0.2.3              pyhd8ed1ab_0    conda-forge
jupyterhub                3.1.1              pyh2a2186d_0    conda-forge
jupyterhub-base           3.1.1              pyh2a2186d_0    conda-forge
jupyterhub-moss           6.0.0                    pypi_0    pypi
jupyterlab                3.6.3              pyhd8ed1ab_0    conda-forge
jupyterlab-git            0.41.0             pyhd8ed1ab_1    conda-forge
jupyterlab_pygments       0.2.2              pyhd8ed1ab_0    conda-forge
jupyterlab_server         2.22.1             pyhd8ed1ab_0    conda-forge
keyutils                  1.6.1                h166bdaf_0    conda-forge
krb5                      1.20.1               h81ceb04_0    conda-forge
ld_impl_linux-64          2.40                 h41732ed_0    conda-forge
libcurl                   8.0.1                h588be90_0    conda-forge
libedit                   3.1.20191231         he28a2e2_2    conda-forge
libev                     4.33                 h516909a_1    conda-forge
libexpat                  2.5.0                hcb278e6_1    conda-forge
libffi                    3.4.2                h7f98852_5    conda-forge
libgcc-ng                 12.2.0              h65d4601_19    conda-forge
libgomp                   12.2.0              h65d4601_19    conda-forge
libnghttp2                1.52.0               h61bc06f_0    conda-forge
libnsl                    2.0.0                h7f98852_0    conda-forge
libpq                     15.2                 hb675445_0    conda-forge
libsodium                 1.0.18               h36c2ea0_1    conda-forge
libsqlite                 3.40.0               h753d276_0    conda-forge
libssh2                   1.10.0               hf14f497_3    conda-forge
libstdcxx-ng              12.2.0              h46fd767_19    conda-forge
libuuid                   2.38.1               h0b41bf4_0    conda-forge
libuv                     1.44.2               h166bdaf_0    conda-forge
libzlib                   1.2.13               h166bdaf_4    conda-forge
mako                      1.2.4              pyhd8ed1ab_0    conda-forge
markupsafe                2.1.2           py311h2582759_0    conda-forge
matplotlib-inline         0.1.6              pyhd8ed1ab_0    conda-forge
mistune                   2.0.5              pyhd8ed1ab_0    conda-forge
nbclassic                 0.5.5              pyhb4ecaf3_1    conda-forge
nbclient                  0.7.3              pyhd8ed1ab_0    conda-forge
nbconvert                 7.3.1              pyhd8ed1ab_0    conda-forge
nbconvert-core            7.3.1              pyhd8ed1ab_0    conda-forge
nbconvert-pandoc          7.3.1              pyhd8ed1ab_0    conda-forge
nbdime                    3.2.1              pyhd8ed1ab_0    conda-forge
nbformat                  5.8.0              pyhd8ed1ab_0    conda-forge
ncurses                   6.3                  h27087fc_1    conda-forge
nest-asyncio              1.5.6              pyhd8ed1ab_0    conda-forge
nodejs                    18.15.0              h8d033a5_0    conda-forge
notebook                  6.5.4              pyha770c72_0    conda-forge
notebook-shim             0.2.2              pyhd8ed1ab_0    conda-forge
oauthlib                  3.2.2              pyhd8ed1ab_0    conda-forge
openssl                   3.1.1                hd590300_1    conda-forge
packaging                 23.1               pyhd8ed1ab_0    conda-forge
pamela                    1.0.0                      py_0    conda-forge
pandoc                    2.19.2               h32600fe_2    conda-forge
pandocfilters             1.5.0              pyhd8ed1ab_0    conda-forge
parso                     0.8.3              pyhd8ed1ab_0    conda-forge
pexpect                   4.8.0              pyh1a96a4e_2    conda-forge
pickleshare               0.7.5                   py_1003    conda-forge
pip                       23.1               pyhd8ed1ab_0    conda-forge
pkgutil-resolve-name      1.3.10             pyhd8ed1ab_0    conda-forge
platformdirs              3.2.0              pyhd8ed1ab_0    conda-forge
prometheus_client         0.16.0             pyhd8ed1ab_0    conda-forge
prompt-toolkit            3.0.38             pyha770c72_0    conda-forge
prompt_toolkit            3.0.38               hd8ed1ab_0    conda-forge
psutil                    5.9.5           py311h2582759_0    conda-forge
psycopg2                  2.9.3           py311h968e94b_2    conda-forge
ptyprocess                0.7.0              pyhd3deb0d_0    conda-forge
pure_eval                 0.2.2              pyhd8ed1ab_0    conda-forge
pycparser                 2.21               pyhd8ed1ab_0    conda-forge
pycurl                    7.45.1          py311hae980a4_3    conda-forge
pydantic                  1.10.7                   pypi_0    pypi
pygments                  2.15.0             pyhd8ed1ab_0    conda-forge
pyjwt                     2.6.0              pyhd8ed1ab_0    conda-forge
pyopenssl                 23.1.1             pyhd8ed1ab_0    conda-forge
pyrsistent                0.19.3          py311h2582759_0    conda-forge
pysocks                   1.7.1              pyha2e5f31_6    conda-forge
python                    3.11.3          h2755cc3_0_cpython    conda-forge
python-dateutil           2.8.2              pyhd8ed1ab_0    conda-forge
python-fastjsonschema     2.16.3             pyhd8ed1ab_0    conda-forge
python-json-logger        2.0.7              pyhd8ed1ab_0    conda-forge
python_abi                3.11                    3_cp311    conda-forge
pytz                      2023.3             pyhd8ed1ab_0    conda-forge
pyyaml                    6.0             py311hd4cff14_5    conda-forge
pyzmq                     25.0.2          py311hd6ccaeb_0    conda-forge
readline                  8.2                  h8228510_1    conda-forge
requests                  2.28.2             pyhd8ed1ab_1    conda-forge
rfc3339-validator         0.1.4              pyhd8ed1ab_0    conda-forge
rfc3986-validator         0.1.1              pyh9f0ad1d_0    conda-forge
ruamel.yaml               0.17.21         py311h2582759_3    conda-forge
ruamel.yaml.clib          0.2.7           py311h2582759_1    conda-forge
send2trash                1.8.0              pyhd8ed1ab_0    conda-forge
setuptools                67.6.1             pyhd8ed1ab_0    conda-forge
six                       1.16.0             pyh6c4a22f_0    conda-forge
smmap                     3.0.5              pyh44b312d_0    conda-forge
sniffio                   1.3.0              pyhd8ed1ab_0    conda-forge
soupsieve                 2.3.2.post1        pyhd8ed1ab_0    conda-forge
sqlalchemy                2.0.9           py311h2582759_0    conda-forge
stack_data                0.6.2              pyhd8ed1ab_0    conda-forge
terminado                 0.17.1             pyh41d4057_0    conda-forge
tinycss2                  1.2.1              pyhd8ed1ab_0    conda-forge
tk                        8.6.12               h27826a3_0    conda-forge
tomli                     2.0.1              pyhd8ed1ab_0    conda-forge
tornado                   6.3             py311h2582759_0    conda-forge
traitlets                 5.9.0              pyhd8ed1ab_0    conda-forge
typing-extensions         4.5.0                hd8ed1ab_0    conda-forge
typing_extensions         4.5.0              pyha770c72_0    conda-forge
tzdata                    2023c                h71feb2d_0    conda-forge
urllib3                   1.26.15            pyhd8ed1ab_0    conda-forge
wcwidth                   0.2.6              pyhd8ed1ab_0    conda-forge
webencodings              0.5.1                      py_1    conda-forge
websocket-client          1.5.1              pyhd8ed1ab_0    conda-forge
wheel                     0.40.0             pyhd8ed1ab_0    conda-forge
wrapspawner               1.0.1                    pypi_0    pypi
xz                        5.2.6                h166bdaf_0    conda-forge
y-py                      0.5.9           py311hfe55011_0    conda-forge
yaml                      0.2.5                h7f98852_2    conda-forge
ypy-websocket             0.8.2              pyhd8ed1ab_0    conda-forge
zeromq                    4.3.4                h9c3ff4c_1    conda-forge
zipp                      3.15.0             pyhd8ed1ab_0    conda-forge
zlib                      1.2.13               h166bdaf_4    conda-forge
zstd                      1.5.2                h3eb15da_6    conda-forge
```
</details>

<details><summary>Configuration</summary>
<!--
For JupyterHub, especially include information such as what Spawner and Authenticator are being used.
Be careful not to share any sensitive information.
You can paste jupyterhub_config.py below.
To exclude lots of comments and empty lines from auto-generated jupyterhub_config.py, you can do:
    grep -v '\(^#\|^[[:space:]]*$\)' jupyterhub_config.py
-->

```python
# jupyterhub_config.py
c.Application.log_datefmt = '%Y-%m-%d %H:%M:%S'
c.JupyterHub.active_server_limit = 200
c.JupyterHub.authenticator_class = 'jhub_remote_user_authenticator.remote_user_auth.RemoteUserAuthenticator'
c.JupyterHub.bind_url = 'REDACTED'
c.JupyterHub.cleanup_servers = False
c.JupyterHub.concurrent_spawn_limit = 100
c.JupyterHub.cookie_max_age_days = 1
c.JupyterHub.db_url = 'REDACTED'
c.JupyterHub.extra_handlers = [(r"/api/batchspawner", 'batchspawner.api.BatchSpawnerAPIHandler')]
c.JupyterHub.hub_connect_url = 'REDACTED'
c.JupyterHub.logo_file = '/opt/jupyterhub/rc_jhub_logo.png'
c = get_config()
c.JupyterHub.spawner_class = 'wrapspawner.ProfilesSpawner'
c.Spawner.http_timeout = 300
c.ProfilesSpawner.profiles = [
      ( "Teaching - 1 CPU core, 2GB RAM, 2h", "1x2x2", "batchspawner.SlurmSpawner",
        dict(req_partition = "hpg-dev", req_nprocs = "1", req_runtime = "02:00:00", req_memory = "2gb" )),
      ("Interactive - 1 CPU core, 2GB RAM, 8h", "1x2x8", "batchspawner.SlurmSpawner",
        dict(req_partition = "hpg-dev", req_nprocs = "1", req_runtime = "8:00:00", req_memory = "2gb" )),
      ("Interactive - 1 CPU core, 4GB RAM, 12h", "1x4x8", "batchspawner.SlurmSpawner",
        dict(req_partition = "hpg-dev", req_nprocs = "1", req_memory = "1gb", req_runtime = "12:00:00")),
      ("Interactive - 1 CPU core, 8GB RAM, 12h", "1x8x8", "batchspawner.SlurmSpawner",
        dict(req_partition = "hpg-dev", req_nprocs = "1", req_memory = "8gb", req_runtime = "12:00:00")),
      ("Compute - 1 CPU core, 32GB RAM, 144h", "1x32x144", "batchspawner.SlurmSpawner",
        dict(req_nprocs = "1", req_memory = "32gb", req_runtime = "144:00:00")),
      ("Compute - 4 CPU cores, 32GB RAM, 144h", "4x32x144", "batchspawner.SlurmSpawner",
        dict(req_nprocs = "4", req_memory = "8gb", req_runtime = "48:00:00")),
      ("Compute - 4 CPU cores, 32GB RAM, 144h", "4x32x144", "batchspawner.SlurmSpawner",
        dict(req_nprocs = "4", req_memory = "32gb", req_runtime = "144:00:00")),
      ("Compute - 8 CPU cores, 32GB RAM, 144h", "8x24x144", "batchspawner.SlurmSpawner",
        dict(req_nprocs = "8", req_memory = "32gb", req_runtime = "144:00:00")),
      ("Compute - 16 CPU cores, 32GB RAM, 144h", "16x24x144", "batchspawner.SlurmSpawner",
        dict(req_nprocs = "16", req_memory = "32gb", req_runtime = "144:00:00")),
      ("Compute - 24 CPU cores, 32GB RAM, 144h", "24x32x144", "batchspawner.SlurmSpawner",
        dict(req_nprocs = "24", req_memory = "32gb", req_runtime = "144:00:00")),
      ("Compute - 4 CPU cores, 48GB RAM, 24h", "4x48x24", "batchspawner.SlurmSpawner",
        dict(req_nprocs = "4", req_memory = "40gb", req_runtime = "24:00:00")),
      ("Compute - 32 CPU cores, 120GB RAM, 72h", "32x120x72", "batchspawner.SlurmSpawner",
        dict(req_nprocs = "32", req_memory = "120gb", req_runtime = "72:00:00")),
      ("GPU Teaching - 1 GPU, 1 CPU core, 4GB RAM, 2h", "1x1x4x2", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "1", req_memory = "4gb", req_runtime = "2:00:00", req_options="--gpus=1")),
      ("GPU Interactive - 1 GPU, 1 CPU core, 6GB RAM, 6h", "1x1x6x6", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "1", req_memory = "6gb", req_runtime = "6:00:00", req_options="--gpus=1")),
      ("GPU Interactive - 1 GPU, 4 CPU cores, 24GB RAM, 8h", "1x4x24x8", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "4", req_memory = "24gb", req_runtime = "8:00:00", req_options="--gpus=1")),
      ("GPU Interactive - 2 GPUs, 2 CPU cores, 12GB RAM, 6h", "2x2x12x6", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "2", req_memory = "12gb", req_runtime = "6:00:00", req_options="--gpus=2")),
      ("GPU Compute - 2 GPUs, 2 CPU cores, 12GB RAM, 24h", "2x2x12x24", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "2", req_memory = "12gb", req_runtime = "24:00:00", req_options="--gpus=2")),
      ("GPU Compute - 3 GPUs, 3 CPU cores, 18GB RAM, 24h", "2x2x18x24", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "3", req_memory = "18gb", req_runtime = "24:00:00", req_options="--gpus=3")),
      ("GPU ML - 4 GPUs, 4 CPU cores, 24GB RAM, 24h", "4x4x24x24", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "4", req_memory = "24gb", req_runtime = "24:00:00", req_options="--gpus=4")),
      ("GPU Large ML - 4 GPUs, 4 CPU cores, 64GB RAM, 72h", "4x4x64x72", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "4", req_memory = "64gb", req_runtime = "72:00:00", req_options="--gpus=4")),
      ("GPU Large ML - 8 GPUs, 8 CPU cores, 64GB RAM, 72h", "8x8x64x72", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "8", req_memory = "64gb", req_runtime = "72:00:00", req_options="--gpus=8")),
      ("GPU A100 SM - 1 GPUs, 2 CPU cores, 24GB RAM, 24h", "1x2x24x24", "batchspawner.SlurmSpawner",
        dict(req_partition = "gpu", req_nprocs = "2", req_memory = "24gb", req_runtime = "24:00:00", req_options="--gpus=1 --constraint=a100")),
]
c.BatchSpawnerBase.batch_submit_cmd = '/opt/slurm/bin/sbatch --parsable'
c.BatchSpawnerBase.batch_query_cmd = "/opt/slurm/bin/squeue -h -j {job_id} -o '%T %B'"
c.BatchSpawnerBase.batch_cancel_cmd = "/opt/slurm/bin/scancel {job_id}"
c.BatchSpawnerBase.req_srun = 'source /etc/profile.d/modules.sh; unset XDG_RUNTIME_DIR; export PATH=/apps/jupyterhub/1.1.0/bin:$PATH; /opt/slurm/bin/srun'
c.Spawner.cmd = ['jupyter-labhub']
c.Spawner.cpu_guarantee = 1
c.Spawner.cpu_limit = 1
c.Spawner.env_keep = ['PATH', 'PYTHONPATH', 'CONDA_ROOT', 'CONDA_DEFAULT_ENV', 'VIRTUAL_ENV', 'LANG', 'LC_ALL']
c.Spawner.start_timeout = 300
c.Authenticator.admin_users = 'http://10.13.143.123:8001'
c.PAMAuthenticator.check_account = True
c.PAMAuthenticator.encoding = 'utf8'
c.PAMAuthenticator.open_sessions = True
c.PAMAuthenticator.service = 'login'
```
</details>

<details><summary>Logs</summary>
<!--
Errors are often logged by jupytehub. How you get logs depends on your deployment.
With kubernetes it might be:

    kubectl get pod # hub pod name starts with hub...
    kubectl logs hub-...
    # or for a single-user server
    kubectl logs jupyter-username

Or the-littlest-jupyterhub:

    journalctl -u jupyterhub
    # or for a single-user server
    journalctl -u jupyter-username
-->

```
No relevant logs.
```
</details>



Discussion:
Thank you for opening your first issue in this project! Engagement like this is essential for open source projects! :hugs:
<br>If you haven't done so already, check out [Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md). Also, please try to follow the issue template as it helps other other community members to contribute more effectively.
![welcome](https://raw.githubusercontent.com/jupyterhub/.github/master/images/welcome.jpg)
You can meet the other [Jovyans](https://jupyter.readthedocs.io/en/latest/community/content-community.html?highlight=jovyan#what-is-a-jovyan) by joining our [Discourse forum](http://discourse.jupyter.org/). There is also an intro thread there where you can stop by and say Hi! :wave:
<br>Welcome to the Jupyter community! :tada:

I believe this is a duplicate of #3816.

Resolving patch:
diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py
--- a/jupyterhub/apihandlers/base.py
+++ b/jupyterhub/apihandlers/base.py
@@ -352,6 +352,10 @@ def user_model(self, user):
             if include_stopped_servers:
                 # add any stopped servers in the db
                 seen = set(servers.keys())
+                if isinstance(user, orm.User):
+                    # need high-level User wrapper for spawner model
+                    # FIXME: this shouldn't be needed!
+                    user = self.users[user]
                 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)


Test patch:
diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.test.js b/jsx/src/components/ServerDashboard/ServerDashboard.test.js
--- a/jsx/src/components/ServerDashboard/ServerDashboard.test.js
+++ b/jsx/src/components/ServerDashboard/ServerDashboard.test.js
@@ -34,9 +34,8 @@ const serverDashboardJsx = (props) => {
   // spies is a dict of properties to mock in
   // any API calls that will fire during the test should be mocked
   props = props || {};
-  const defaultSpy = mockAsync();
   if (!props.updateUsers) {
-    props.updateUsers = defaultSpy;
+    props.updateUsers = mockUpdateUsers;
   }
   return (
     <Provider store={createStore(mockReducers, mockAppState())}>
@@ -55,6 +54,14 @@ var mockAsync = (data) =>
 var mockAsyncRejection = () =>
   jest.fn().mockImplementation(() => Promise.reject());
 
+const defaultUpdateUsersParams = {
+  offset: 0,
+  limit: 2,
+  name_filter: "",
+  sort: "id",
+  state: "",
+};
+
 var bar_servers = {
   "": {
     name: "",
@@ -80,44 +87,64 @@ var bar_servers = {
   },
 };
 
-var mockAppState = () =>
-  Object.assign({}, initialState, {
-    user_data: [
-      {
-        kind: "user",
-        name: "foo",
-        admin: true,
-        groups: [],
-        server: "/user/foo/",
-        pending: null,
-        created: "2020-12-07T18:46:27.112695Z",
-        last_activity: "2020-12-07T21:00:33.336354Z",
-        servers: {
-          "": {
-            name: "",
-            last_activity: "2020-12-07T20:58:02.437408Z",
-            started: "2020-12-07T20:58:01.508266Z",
-            pending: null,
-            ready: true,
-            state: { pid: 28085 },
-            url: "/user/foo/",
-            user_options: {},
-            progress_url: "/hub/api/users/foo/server/progress",
-          },
-        },
-      },
-      {
-        kind: "user",
-        name: "bar",
-        admin: false,
-        groups: [],
-        server: null,
+/* create new user models */
+const newUser = (name) => {
+  return {
+    kind: "user",
+    name: name,
+    admin: false,
+    groups: [],
+    server: `/user/${name}`,
+    created: "2020-12-07T18:46:27.112695Z",
+    last_activity: "2020-12-07T21:00:33.336354Z",
+    servers: {},
+  };
+};
+
+const allUsers = [
+  {
+    kind: "user",
+    name: "foo",
+    admin: true,
+    groups: [],
+    server: "/user/foo/",
+    pending: null,
+    created: "2020-12-07T18:46:27.112695Z",
+    last_activity: "2020-12-07T21:00:33.336354Z",
+    servers: {
+      "": {
+        name: "",
+        last_activity: "2020-12-07T20:58:02.437408Z",
+        started: "2020-12-07T20:58:01.508266Z",
         pending: null,
-        created: "2020-12-07T18:46:27.115528Z",
-        last_activity: "2020-12-07T20:43:51.013613Z",
-        servers: bar_servers,
+        ready: true,
+        state: { pid: 28085 },
+        url: "/user/foo/",
+        user_options: {},
+        progress_url: "/hub/api/users/foo/server/progress",
       },
-    ],
+    },
+  },
+  {
+    kind: "user",
+    name: "bar",
+    admin: false,
+    groups: [],
+    server: null,
+    pending: null,
+    created: "2020-12-07T18:46:27.115528Z",
+    last_activity: "2020-12-07T20:43:51.013613Z",
+    servers: bar_servers,
+  },
+];
+
+for (var i = 2; i < 10; i++) {
+  allUsers.push(newUser(`test-${i}`));
+}
+
+var mockAppState = () =>
+  Object.assign({}, initialState, {
+    user_data: allUsers.slice(0, 2),
     user_page: {
       offset: 0,
       limit: 2,
@@ -125,7 +152,7 @@ var mockAppState = () =>
       next: {
         offset: 2,
         limit: 2,
-        url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
+        url: "http://localhost:8000/hub/api/users?offset=2&limit=2",
       },
     },
   });
@@ -143,6 +170,40 @@ var mockReducers = jest.fn((state, action) => {
   return state;
 });
 
+let mockUpdateUsers = jest.fn(({ offset, limit, sort, name_filter, state }) => {
+  /* mock updating users 
+  
+  this has tom implement the server-side filtering, sorting, etc.
+  (at least whatever we want to test of it)
+  */
+  let matchingUsers = allUsers;
+  if (state === "active") {
+    // only first user is active
+    matchingUsers = allUsers.slice(0, 1);
+  }
+  if (name_filter) {
+    matchingUsers = matchingUsers.filter((user) =>
+      user.name.startsWith(name_filter),
+    );
+  }
+
+  const total = matchingUsers.length;
+  const items = matchingUsers.slice(offset, offset + limit);
+
+  return Promise.resolve({
+    items: items,
+    _pagination: {
+      offset: offset,
+      limit: limit,
+      total: total,
+      next: {
+        offset: offset + limit,
+        limit: limit,
+      },
+    },
+  });
+});
+
 let searchParams = new URLSearchParams();
 
 beforeEach(() => {
@@ -151,6 +212,7 @@ beforeEach(() => {
     return callback(mockAppState());
   });
   searchParams = new URLSearchParams();
+  searchParams.set("limit", "2");
 
   useSearchParams.mockImplementation(() => [
     searchParams,
@@ -164,6 +226,7 @@ afterEach(() => {
   useSearchParams.mockClear();
   useSelector.mockClear();
   mockReducers.mockClear();
+  mockUpdateUsers.mockClear();
   jest.runAllTimers();
 });
 
@@ -267,71 +330,93 @@ test("Invokes the shutdownHub event on button click", async () => {
 });
 
 test("Sorts according to username", async () => {
+  let rerender;
+  const testId = "user-sort";
   await act(async () => {
-    render(serverDashboardJsx());
+    rerender = render(serverDashboardJsx()).rerender;
   });
 
-  let handler = screen.getByTestId("user-sort");
+  expect(searchParams.get("sort")).toEqual(null);
+  let handler = screen.getByTestId(testId);
   fireEvent.click(handler);
+  expect(searchParams.get("sort")).toEqual("name");
 
-  let first = screen.getAllByTestId("user-row-name")[0];
-  expect(first.textContent).toContain("bar");
-
-  fireEvent.click(handler);
-
-  first = screen.getAllByTestId("user-row-name")[0];
-  expect(first.textContent).toContain("foo");
-});
-
-test("Sorts according to admin", async () => {
   await act(async () => {
-    render(serverDashboardJsx());
+    rerender(serverDashboardJsx());
+    handler = screen.getByTestId(testId);
   });
 
-  let handler = screen.getByTestId("admin-sort");
   fireEvent.click(handler);
+  expect(searchParams.get("sort")).toEqual("-name");
 
-  let first = screen.getAllByTestId("user-row-admin")[0];
-  expect(first.textContent).toBe("admin");
+  await act(async () => {
+    rerender(serverDashboardJsx());
+    handler = screen.getByTestId(testId);
+  });
 
   fireEvent.click(handler);
-
-  first = screen.getAllByTestId("user-row-admin")[0];
-  expect(first.textContent).toBe("");
+  expect(searchParams.get("sort")).toEqual("name");
 });
 
 test("Sorts according to last activity", async () => {
+  let rerender;
+  const testId = "last-activity-sort";
   await act(async () => {
-    render(serverDashboardJsx());
+    rerender = render(serverDashboardJsx()).rerender;
   });
 
-  let handler = screen.getByTestId("last-activity-sort");
+  expect(searchParams.get("sort")).toEqual(null);
+  let handler = screen.getByTestId(testId);
   fireEvent.click(handler);
+  expect(searchParams.get("sort")).toEqual("last_activity");
 
-  let first = screen.getAllByTestId("user-row-name")[0];
-  expect(first.textContent).toContain("foo");
+  await act(async () => {
+    rerender(serverDashboardJsx());
+    handler = screen.getByTestId(testId);
+  });
 
   fireEvent.click(handler);
+  expect(searchParams.get("sort")).toEqual("-last_activity");
+
+  await act(async () => {
+    rerender(serverDashboardJsx());
+    handler = screen.getByTestId(testId);
+  });
 
-  first = screen.getAllByTestId("user-row-name")[0];
-  expect(first.textContent).toContain("bar");
+  fireEvent.click(handler);
+  expect(searchParams.get("sort")).toEqual("last_activity");
 });
 
-test("Sorts according to server status (running/not running)", async () => {
+test("Filter according to server status (running/not running)", async () => {
+  let rerender;
   await act(async () => {
-    render(serverDashboardJsx());
+    rerender = render(serverDashboardJsx()).rerender;
   });
-
-  let handler = screen.getByTestId("running-status-sort");
+  console.log(rerender);
+  console.log("begin test");
+  const label = "only active servers";
+  let handler = screen.getByLabelText(label);
+  expect(handler.checked).toEqual(false);
   fireEvent.click(handler);
 
-  let first = screen.getAllByTestId("user-row-name")[0];
-  expect(first.textContent).toContain("foo");
+  // FIXME: need to force a rerender to get updated checkbox
+  // I don't think this should be required
+  await act(async () => {
+    rerender(serverDashboardJsx());
+    handler = screen.getByLabelText(label);
+  });
+  expect(searchParams.get("state")).toEqual("active");
+  expect(handler.checked).toEqual(true);
 
   fireEvent.click(handler);
 
-  first = screen.getAllByTestId("user-row-name")[0];
-  expect(first.textContent).toContain("bar");
+  await act(async () => {
+    rerender(serverDashboardJsx());
+    handler = screen.getByLabelText(label);
+  });
+  handler = screen.getByLabelText(label);
+  expect(handler.checked).toEqual(false);
+  expect(searchParams.get("state")).toEqual(null);
 });
 
 test("Shows server details with button click", async () => {
@@ -494,23 +579,9 @@ test("Shows a UI error dialogue when stop user server returns an improper status
 
 test("Search for user calls updateUsers with name filter", async () => {
   let spy = mockAsync();
-  let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
-    return Promise.resolve({
-      items: [],
-      _pagination: {
-        offset: offset,
-        limit: limit,
-        total: offset + limit * 2,
-        next: {
-          offset: offset + limit,
-          limit: limit,
-        },
-      },
-    });
-  });
   await act(async () => {
     searchParams.set("offset", "2");
-    render(serverDashboardJsx({ updateUsers: mockUpdateUsers }));
+    render(serverDashboardJsx());
   });
 
   let search = screen.getByLabelText("user-search");
@@ -538,17 +609,15 @@ test("Search for user calls updateUsers with name filter", async () => {
 });
 
 test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
-  let updateUsers = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx({ updateUsers: updateUsers }));
+    render(serverDashboardJsx());
   });
 
-  expect(updateUsers).toBeCalledWith(0, 100, "");
+  expect(mockUpdateUsers).toBeCalledWith(defaultUpdateUsersParams);
 
   var n = 3;
   expect(searchParams.get("offset")).toEqual(null);
-  expect(searchParams.get("limit")).toEqual(null);
+  expect(searchParams.get("limit")).toEqual("2");
 
   let next = screen.getByTestId("paginate-next");
   await act(async () => {
@@ -556,8 +625,8 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
     jest.runAllTimers();
   });
 
-  expect(searchParams.get("offset")).toEqual("100");
-  expect(searchParams.get("limit")).toEqual(null);
+  expect(searchParams.get("offset")).toEqual("2");
+  expect(searchParams.get("limit")).toEqual("2");
 
   // FIXME: should call updateUsers, does in reality.
   // tests don't reflect reality due to mocked state/useSelector
@@ -590,12 +659,9 @@ test("Start server and confirm pending state", async () => {
     );
   });
 
-  let mockUpdateUsers = jest.fn(() => Promise.resolve(mockAppState()));
-
   await act(async () => {
     render(
       serverDashboardJsx({
-        updateUsers: mockUpdateUsers,
         startServer: mockStartServer,
       }),
     );
@@ -604,16 +670,17 @@ test("Start server and confirm pending state", async () => {
   let actions = screen.getAllByTestId("user-row-server-activity")[1];
   let buttons = getAllByRole(actions, "button");
 
-  expect(buttons.length).toBe(2);
+  expect(buttons.length).toBe(3);
   expect(buttons[0].textContent).toBe("Start Server");
   expect(buttons[1].textContent).toBe("Spawn Page");
+  expect(buttons[2].textContent).toBe("Edit User");
 
   await act(async () => {
     fireEvent.click(buttons[0]);
   });
   expect(mockUpdateUsers.mock.calls).toHaveLength(1);
 
-  expect(buttons.length).toBe(2);
+  expect(buttons.length).toBe(3);
   expect(buttons[0].textContent).toBe("Start Server");
   expect(buttons[0]).toBeDisabled();
   expect(buttons[1].textContent).toBe("Spawn Page");
diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py
--- a/jupyterhub/tests/browser/test_browser.py
+++ b/jupyterhub/tests/browser/test_browser.py
@@ -1116,8 +1116,9 @@ async def test_search_on_admin_page(
     displaying = browser.get_by_text("Displaying")
     if users_count_db_filtered <= 50:
         await expect(filtered_list_on_page).to_have_count(users_count_db_filtered)
+        start = 1 if users_count_db_filtered else 0
         await expect(displaying).to_contain_text(
-            re.compile(f"1-{users_count_db_filtered}")
+            re.compile(f"{start}-{users_count_db_filtered}")
         )
         # check that users names contain the search value in the filtered list
         for element in await filtered_list_on_page.get_by_test_id(