Repository: dask/distributed

Issue:
Bokeh 3.3.0 uses absolute URLs and breaks proxied dashboards
I noticed when using `distributed==2023.10.1` with `bokeh==3.2.2` the URLs to the Bokeh JavaScript are relative.

```html
<script type="text/javascript" src="static/js/bokeh.min.js?v=3ca6425586de5036dc01992dd69aa61e9196dd02619557cfaeb1b3d8b77adf724be49401b1168483d165494ce57a6daa16e6f6d3660fef117d45028221f86357"></script>
```

But when upgrading to `bokeh==3.3.0` they become absolute.

```html
<script type="text/javascript" src="/static/js/bokeh.min.js?v=39ef57c3a83533e24f961e5c27f651a61045dbccefac4b5df86a7680b1edaff31886a7c0322250ffb0d758fa14ae156c9b640f60cca99f020096b050a4dbb571"></script>
```

This breaks dashboards that are being proxied at some sub-url.

Setting `dask scheduler --dashboard-prefix ""` doesn't fix it.

### Reproducer

```
$ pip install dask distributed bokeh==3.3.0
$ dask scheduler &
$ curl localhost:8787/status | grep bokeh.min.js
```


Discussion:
Thanks @jacobtomlinson -- I think something similar was reported over in `dask-kubernetes` (maybe that's what's spawning this issue). Do you have thoughts on how we might fix? Also cc @bryevdv for visibility 
3.3.1 will be probably go out today. I'd try that first, if that does not fix then most likely just a bug.
Thanks @bryevdv. I tested with `3.3.1rc1` and the issue is still there. I'll put together an MRE and open a ticket on bokeh.
cc @mattpap 
I've opened https://github.com/bokeh/bokeh/issues/13521

Resolving patch:
diff --git a/distributed/dashboard/core.py b/distributed/dashboard/core.py
--- a/distributed/dashboard/core.py
+++ b/distributed/dashboard/core.py
@@ -5,6 +5,7 @@
 
 from bokeh.application import Application
 from bokeh.application.handlers.function import FunctionHandler
+from bokeh.resources import Resources
 from bokeh.server.server import BokehTornado
 from bokeh.server.util import create_hosts_allowlist
 
@@ -31,6 +32,11 @@
     from bokeh.models import TabPanel  # noqa: F401
 
 
+class DaskBokehTornado(BokehTornado):
+    def resources(self, absolute_url: str | bool | None = True) -> Resources:
+        return super().resources(absolute_url)
+
+
 def BokehApplication(applications, server, prefix="/", template_variables=None):
     template_variables = template_variables or {}
     prefix = "/" + prefix.strip("/") + "/" if prefix else "/"
@@ -45,10 +51,11 @@ def BokehApplication(applications, server, prefix="/", template_variables=None):
         kwargs.pop("allow_websocket_origin"), server.http_server.port
     )
 
-    return BokehTornado(
+    return DaskBokehTornado(
         apps,
         prefix=prefix,
         use_index=False,
         extra_websocket_origins=extra_websocket_origins,
+        absolute_url="",
         **kwargs,
     )


Test patch:
diff --git a/distributed/dashboard/tests/test_scheduler_bokeh.py b/distributed/dashboard/tests/test_scheduler_bokeh.py
--- a/distributed/dashboard/tests/test_scheduler_bokeh.py
+++ b/distributed/dashboard/tests/test_scheduler_bokeh.py
@@ -1301,6 +1301,14 @@ async def test_prefix_bokeh(s, a, b):
     assert bokeh_app.prefix == f"/{prefix}"
 
 
+@gen_cluster(scheduler_kwargs={"dashboard": True})
+async def test_bokeh_relative(s, a, b):
+    http_client = AsyncHTTPClient()
+    response = await http_client.fetch(f"http://localhost:{s.http_server.port}/status")
+    assert response.code == 200
+    assert '<script type="text/javascript" src="static/' in response.body.decode()
+
+
 @gen_cluster(client=True, scheduler_kwargs={"dashboard": True})
 async def test_shuffling(c, s, a, b):
     pytest.importorskip("pyarrow")