This is an automated email from the ASF dual-hosted git repository.

skrawcz pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/burr.git


The following commit(s) were added to refs/heads/main by this push:
     new 04d172ff Enable Burr UI to be added to existing FastAPI app (#671)
04d172ff is described below

commit 04d172ff53558f5277033df8339652d5963c7128
Author: Smita D Ambiger <[email protected]>
AuthorDate: Sun Mar 22 10:46:34 2026 +0530

    Enable Burr UI to be added to existing FastAPI app (#671)
    
    * Add create_burr_ui_app factory and mount_burr_ui helper to embed Burr UI 
into existing FastAPI apps
    
    * Add FastAPI mount example and documentation for Burr UI embedding
    
    * feat: enable Burr UI mounting as sub-app with dynamic base path support
    
    Refactor run.py to use a factory pattern (create_burr_ui_app) so all routes
    are registered on the sub-app instance, not at module level. Add 
mount_burr_ui()
    helper for embedding the Burr UI into an existing FastAPI application.
    
    When mounted at a sub-path (e.g. /burr), the server rewrites CRA's hardcoded
    absolute paths in index.html and injects window.__BURR_BASE_PATH__ so the
    React app can prefix all API calls and client-side routes at runtime.
    
    React-side changes:
    - OpenAPI.ts reads __BURR_BASE_PATH__ for API client BASE
    - App.tsx uses it as Router basename
    - appcontainer.tsx prefixes logo image paths
    - StreamingChatbot.tsx prefixes direct fetch calls
    
    * fix: resolve pre-commit and eslint CI failures
    
    - Add Window.__BURR_BASE_PATH__ type declaration to avoid no-explicit-any
    - Apply prettier formatting to TS files
    - Apply black formatting to test_local_tracking_client.py
    
    ---------
    
    Co-authored-by: Smita Ambiger <[email protected]>
    Co-authored-by: Stefan Krawczyk <[email protected]>
---
 burr/tracking/server/run.py                      | 413 +++++++++++++----------
 docs/concepts/tracking.rst                       |  22 ++
 examples/fastapi_mount_example.py                |  35 ++
 telemetry/ui/package-lock.json                   | 153 +++++++++
 telemetry/ui/src/App.tsx                         |   2 +-
 telemetry/ui/src/api/core/OpenAPI.ts             |   8 +-
 telemetry/ui/src/components/nav/appcontainer.tsx | 113 +++----
 telemetry/ui/src/examples/StreamingChatbot.tsx   |  12 +-
 telemetry/ui/src/react-app-env.d.ts              |   4 +
 tests/tracking/test_local_tracking_client.py     |   8 +-
 10 files changed, 527 insertions(+), 243 deletions(-)

diff --git a/burr/tracking/server/run.py b/burr/tracking/server/run.py
index 0e5ce62b..2e975965 100644
--- a/burr/tracking/server/run.py
+++ b/burr/tracking/server/run.py
@@ -42,7 +42,6 @@ try:
     from fastapi import FastAPI, HTTPException, Request
     from fastapi.staticfiles import StaticFiles
     from fastapi_utils.tasks import repeat_every
-    from starlette.templating import Jinja2Templates
 
     from burr.tracking.server import schema
     from burr.tracking.server.schema import (  # AnnotationUpdate,
@@ -141,20 +140,8 @@ async def lifespan(app: FastAPI):
     await backend.lifespan(app).__anext__()
 
 
-app = FastAPI(lifespan=lifespan)
-
-
[email protected]("/ready")
-def is_ready():
-    if not initialized:
-        raise HTTPException(
-            status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Backend 
is not ready yet."
-        )
-    return {"ready": True}
-
-
[email protected]("/api/v0/metadata/app_spec", response_model=BackendSpec)
-def get_app_spec():
+def _get_app_spec() -> BackendSpec:
+    """Computes the backend spec from the current backend configuration."""
     is_indexing_backend = isinstance(backend, IndexingBackendMixin)
     is_snapshotting_backend = isinstance(backend, SnapshottingBackendMixin)
     is_annotations_backend = isinstance(backend, AnnotationsBackendMixin)
@@ -167,7 +154,7 @@ def get_app_spec():
     )
 
 
-app_spec = get_app_spec()
+app_spec = _get_app_spec()
 
 logger = logging.getLogger(__name__)
 
@@ -190,178 +177,258 @@ if app_spec.snapshotting:
     )(save_snapshot)
 
 
[email protected]("/api/v0/projects", response_model=Sequence[schema.Project])
-async def get_projects(request: Request) -> Sequence[schema.Project]:
-    """Gets all projects visible by the user.
+def create_burr_ui_app(serve_static: bool = SERVE_STATIC) -> FastAPI:
+    """Create a fully-configured Burr UI FastAPI application.
 
-    :param request: FastAPI request
-    :return:  a list of projects visible by the user
-    """
-    return await backend.list_projects(request)
-
-
[email protected]("/api/v0/{project_id}/{partition_key}/apps", 
response_model=ApplicationPage)
-async def get_apps(
-    request: Request,
-    project_id: str,
-    partition_key: str,
-    limit: int = 100,
-    offset: int = 0,
-) -> ApplicationPage:
-    """Gets all apps visible by the user
-
-    :param request: FastAPI request
-    :param project_id: project name
-    :return: a list of projects visible by the user
-    """
-    if partition_key == SENTINEL_PARTITION_KEY:
-        partition_key = None
-    applications, total_count = await backend.list_apps(
-        request, project_id, partition_key=partition_key, limit=limit, 
offset=offset
-    )
-    return ApplicationPage(
-        applications=list(applications),
-        total=total_count,
-        has_another_page=total_count > offset + limit,
-    )
+    This factory creates a new FastAPI instance with all Burr UI routes,
+    demo routers, and (optionally) static file serving configured.
 
-
[email protected]("/api/v0/{project_id}/{app_id}/{partition_key}/apps")
-async def get_application_logs(
-    request: Request, project_id: str, app_id: str, partition_key: str
-) -> ApplicationLogs:
-    """Lists steps for a given App.
-    TODO: add streaming capabilities for bi-directional communication
-    TODO: add pagination for quicker loading
-
-    :param request: FastAPI
-    :param project_id: ID of the project
-    :param app_id: ID of the assIndociated application
-    :return: A list of steps with all associated step data
+    :param serve_static: Whether to serve the React UI static files. Defaults 
to
+        the BURR_SERVE_STATIC environment variable (true by default).
+    :return: A fully-configured FastAPI application.
     """
-    if partition_key == SENTINEL_PARTITION_KEY:
-        partition_key = None
-    return await backend.get_application_logs(
-        request, project_id=project_id, app_id=app_id, 
partition_key=partition_key
-    )
+    ui_app = FastAPI(lifespan=lifespan)
+
+    @ui_app.get("/ready")
+    def is_ready():
+        if not initialized:
+            raise HTTPException(
+                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+                detail="Backend is not ready yet.",
+            )
+        return {"ready": True}
+
+    @ui_app.get("/api/v0/metadata/app_spec", response_model=BackendSpec)
+    def get_app_spec():
+        return _get_app_spec()
+
+    @ui_app.get("/api/v0/projects", response_model=Sequence[schema.Project])
+    async def get_projects(request: Request) -> Sequence[schema.Project]:
+        """Gets all projects visible by the user.
+
+        :param request: FastAPI request
+        :return:  a list of projects visible by the user
+        """
+        return await backend.list_projects(request)
+
+    @ui_app.get("/api/v0/{project_id}/{partition_key}/apps", 
response_model=ApplicationPage)
+    async def get_apps(
+        request: Request,
+        project_id: str,
+        partition_key: str,
+        limit: int = 100,
+        offset: int = 0,
+    ) -> ApplicationPage:
+        """Gets all apps visible by the user
+
+        :param request: FastAPI request
+        :param project_id: project name
+        :return: a list of projects visible by the user
+        """
+        if partition_key == SENTINEL_PARTITION_KEY:
+            partition_key = None
+        applications, total_count = await backend.list_apps(
+            request, project_id, partition_key=partition_key, limit=limit, 
offset=offset
+        )
+        return ApplicationPage(
+            applications=list(applications),
+            total=total_count,
+            has_another_page=total_count > offset + limit,
+        )
 
+    @ui_app.get("/api/v0/{project_id}/{app_id}/{partition_key}/apps")
+    async def get_application_logs(
+        request: Request, project_id: str, app_id: str, partition_key: str
+    ) -> ApplicationLogs:
+        """Lists steps for a given App.
+        TODO: add streaming capabilities for bi-directional communication
+        TODO: add pagination for quicker loading
+
+        :param request: FastAPI
+        :param project_id: ID of the project
+        :param app_id: ID of the assIndociated application
+        :return: A list of steps with all associated step data
+        """
+        if partition_key == SENTINEL_PARTITION_KEY:
+            partition_key = None
+        return await backend.get_application_logs(
+            request, project_id=project_id, app_id=app_id, 
partition_key=partition_key
+        )
 
[email protected](
-    "/api/v0/{project_id}/{app_id}/{partition_key}/{sequence_id}/annotations",
-    response_model=AnnotationOut,
-)
-async def create_annotation(
-    request: Request,
-    project_id: str,
-    app_id: str,
-    partition_key: str,
-    sequence_id: int,
-    annotation: AnnotationCreate,
-):
-    if partition_key == SENTINEL_PARTITION_KEY:
-        partition_key = None
-    spec = get_app_spec()
-    if not spec.supports_annotations:
-        return []  # empty default -- the case that we don't support 
annotations
-    return await backend.create_annotation(
-        annotation, project_id, partition_key, app_id, sequence_id
+    @ui_app.post(
+        
"/api/v0/{project_id}/{app_id}/{partition_key}/{sequence_id}/annotations",
+        response_model=AnnotationOut,
     )
+    async def create_annotation(
+        request: Request,
+        project_id: str,
+        app_id: str,
+        partition_key: str,
+        sequence_id: int,
+        annotation: AnnotationCreate,
+    ):
+        if partition_key == SENTINEL_PARTITION_KEY:
+            partition_key = None
+        spec = _get_app_spec()
+        if not spec.supports_annotations:
+            return []  # empty default -- the case that we don't support 
annotations
+        return await backend.create_annotation(
+            annotation, project_id, partition_key, app_id, sequence_id
+        )
 
-
-#
-# # TODO -- take out these parameters cause we have the annotation ID
[email protected](
-    "/api/v0/{project_id}/{annotation_id}/update_annotations",
-    response_model=AnnotationOut,
-)
-async def update_annotation(
-    request: Request,
-    project_id: str,
-    annotation_id: int,
-    annotation: AnnotationUpdate,
-):
-    return await backend.update_annotation(
-        annotation_id=annotation_id, annotation=annotation, 
project_id=project_id
+    #
+    # # TODO -- take out these parameters cause we have the annotation ID
+    @ui_app.put(
+        "/api/v0/{project_id}/{annotation_id}/update_annotations",
+        response_model=AnnotationOut,
     )
-
-
[email protected]("/api/v0/{project_id}/annotations", 
response_model=Sequence[AnnotationOut])
-async def get_annotations(
-    request: Request,
-    project_id: str,
-    app_id: Optional[str] = None,
-    partition_key: Optional[str] = None,
-    step_sequence_id: Optional[int] = None,
-):
-    # Handle the sentinel value for partition_key
-    if partition_key == SENTINEL_PARTITION_KEY:
-        partition_key = None
-    backend_spec = get_app_spec()
-
-    if not backend_spec.supports_annotations:
-        # makes it easier to wire through to the FE
-        return []
-
-    # Logic to retrieve the annotations
-    return await backend.get_annotations(project_id, partition_key, app_id, 
step_sequence_id)
-
-
[email protected]("/api/v0/ready")
-async def ready() -> bool:
-    return True
-
-
[email protected]("/api/v0/indexing_jobs", response_model=Sequence[IndexingJob])
-async def get_indexing_jobs(
-    offset: int = 0, limit: int = 100, filter_empty: bool = True
-) -> Sequence[IndexingJob]:
-    if not app_spec.indexing:
-        raise HTTPException(
-            status_code=status.HTTP_404_NOT_FOUND,
-            detail="This backend does not support indexing jobs.",
+    async def update_annotation(
+        request: Request,
+        project_id: str,
+        annotation_id: int,
+        annotation: AnnotationUpdate,
+    ):
+        return await backend.update_annotation(
+            annotation_id=annotation_id, annotation=annotation, 
project_id=project_id
         )
-    return await backend.indexing_jobs(offset=offset, limit=limit, 
filter_empty=filter_empty)
 
+    @ui_app.get("/api/v0/{project_id}/annotations", 
response_model=Sequence[AnnotationOut])
+    async def get_annotations(
+        request: Request,
+        project_id: str,
+        app_id: Optional[str] = None,
+        partition_key: Optional[str] = None,
+        step_sequence_id: Optional[int] = None,
+    ):
+        # Handle the sentinel value for partition_key
+        if partition_key == SENTINEL_PARTITION_KEY:
+            partition_key = None
+        backend_spec = _get_app_spec()
+
+        if not backend_spec.supports_annotations:
+            # makes it easier to wire through to the FE
+            return []
+
+        # Logic to retrieve the annotations
+        return await backend.get_annotations(project_id, partition_key, 
app_id, step_sequence_id)
+
+    @ui_app.get("/api/v0/ready")
+    async def ready() -> bool:
+        return True
+
+    @ui_app.get("/api/v0/indexing_jobs", response_model=Sequence[IndexingJob])
+    async def get_indexing_jobs(
+        offset: int = 0, limit: int = 100, filter_empty: bool = True
+    ) -> Sequence[IndexingJob]:
+        if not app_spec.indexing:
+            raise HTTPException(
+                status_code=status.HTTP_404_NOT_FOUND,
+                detail="This backend does not support indexing jobs.",
+            )
+        return await backend.indexing_jobs(offset=offset, limit=limit, 
filter_empty=filter_empty)
+
+    @ui_app.get("/api/v0/version")
+    async def version() -> dict:
+        """Returns the burr version"""
+        import pkg_resources
 
[email protected]("/api/v0/version")
-async def version() -> dict:
-    """Returns the burr version"""
-    import pkg_resources
-
-    try:
-        version = pkg_resources.get_distribution("apache-burr").version
-    except pkg_resources.DistributionNotFound:
         try:
-            # Fallback for older installations or development
-            version = pkg_resources.get_distribution("burr").version
+            burr_version = 
pkg_resources.get_distribution("apache-burr").version
         except pkg_resources.DistributionNotFound:
-            version = "unknown"
-    return {"version": version}
-
-
-# Examples -- todo -- put them behind `if` statements
-app.include_router(chatbot.router, prefix="/api/v0/chatbot")
-app.include_router(email_assistant.router, prefix="/api/v0/email_assistant")
-app.include_router(streaming_chatbot.router, 
prefix="/api/v0/streaming_chatbot")
-app.include_router(deep_researcher.router, prefix="/api/v0/deep_researcher")
-app.include_router(counter.router, prefix="/api/v0/counter")
-
-if SERVE_STATIC:
-    BASE_ASSET_DIRECTORY = str(files("burr").joinpath("tracking/server/build"))
+            try:
+                # Fallback for older installations or development
+                burr_version = pkg_resources.get_distribution("burr").version
+            except pkg_resources.DistributionNotFound:
+                burr_version = "unknown"
+        return {"version": burr_version}
+
+    # Examples -- todo -- put them behind `if` statements
+    ui_app.include_router(chatbot.router, prefix="/api/v0/chatbot")
+    ui_app.include_router(email_assistant.router, 
prefix="/api/v0/email_assistant")
+    ui_app.include_router(streaming_chatbot.router, 
prefix="/api/v0/streaming_chatbot")
+    ui_app.include_router(deep_researcher.router, 
prefix="/api/v0/deep_researcher")
+    ui_app.include_router(counter.router, prefix="/api/v0/counter")
+
+    if serve_static:
+        base_asset_directory = 
str(files("burr").joinpath("tracking/server/build"))
+        static_directory = os.path.join(base_asset_directory, "static")
+
+        ui_app.mount(
+            "/static",
+            StaticFiles(directory=static_directory),
+            "/static",
+        )
+        # public assets in create react app don't get put under build/static,
+        # we need to route them over
+        ui_app.mount("/public", StaticFiles(directory=base_asset_directory, 
html=True), "/public")
+
+        # Read index.html once at startup
+        with open(os.path.join(base_asset_directory, "index.html")) as f:
+            _index_html_template = f.read()
+
+        @ui_app.get("/manifest.json")
+        async def manifest_json():
+            """Serve manifest.json from the build directory."""
+            from starlette.responses import FileResponse
+
+            return FileResponse(
+                os.path.join(base_asset_directory, "manifest.json"),
+                media_type="application/manifest+json",
+            )
+
+        @ui_app.get("/{rest_of_path:path}")
+        async def react_app(req: Request, rest_of_path: str):
+            """Serves the React app, rewriting asset paths to respect the 
mount prefix.
+
+            When mounted as a sub-app (e.g. under /burr), the CRA build's 
hardcoded
+            absolute paths (/static/js/..., /api/v0/...) need to be prefixed 
with the
+            mount path so the browser fetches them from the correct location.
+
+            This rewrites both the HTML (href/src attributes) and injects a 
script that
+            sets the OpenAPI client's BASE to the mount prefix, so all API 
calls are
+            also correctly routed.
+            """
+            from starlette.responses import HTMLResponse
+
+            root_path = req.scope.get("root_path", "")
+            if root_path:
+                # Rewrite CRA's absolute paths to include the mount prefix
+                html = _index_html_template.replace('href="/', 
f'href="{root_path}/')
+                html = html.replace('src="/', f'src="{root_path}/')
+                # Inject a script before </head> that patches the OpenAPI BASE 
config
+                # so all runtime API calls (fetch) go to the correct mount 
path.
+                # Also patches image/asset references that use /public/.
+                patch_script = f"<script>" 
f"window.__BURR_BASE_PATH__='{root_path}';" f"</script>"
+                html = html.replace("</head>", f"{patch_script}</head>")
+            else:
+                html = _index_html_template
+            return HTMLResponse(html)
+
+    return ui_app
+
+
+def mount_burr_ui(
+    parent_app: FastAPI,
+    path: str = "/burr",
+    name: str = "burr-ui",
+    serve_static: bool = SERVE_STATIC,
+) -> FastAPI:
+    """Mount the Burr UI inside another FastAPI app.
+
+    :param parent_app: The parent FastAPI application to mount onto.
+    :param path: URL path prefix for the Burr UI. Defaults to "/burr".
+    :param name: Name for the mounted sub-application. Defaults to "burr-ui".
+    :param serve_static: Whether to serve the React UI static files. Defaults 
to
+        the BURR_SERVE_STATIC environment variable (true by default).
+    :return: The mounted Burr UI FastAPI app instance.
+    """
+    ui_app = create_burr_ui_app(serve_static=serve_static)
+    parent_app.mount(path, ui_app, name=name)
+    return ui_app
 
-    templates = Jinja2Templates(directory=BASE_ASSET_DIRECTORY)
-    app.mount(
-        "/static", StaticFiles(directory=os.path.join(BASE_ASSET_DIRECTORY, 
"static")), "/static"
-    )
-    # public assets in create react app don't get put under build/static, we 
need to route them over
-    app.mount("/public", StaticFiles(directory=BASE_ASSET_DIRECTORY, 
html=True), "/public")
 
-    @app.get("/{rest_of_path:path}")
-    async def react_app(req: Request, rest_of_path: str):
-        """Quick trick to server the react app
-        Thanks to https://github.com/hop-along-polly/fastapi-webapp-react for 
the example/demo
-        """
-        return templates.TemplateResponse("index.html", {"request": req})
+# Module-level app for backwards compatibility (used by uvicorn, CLI, etc.)
+app = create_burr_ui_app()
 
 
 if __name__ == "__main__":
diff --git a/docs/concepts/tracking.rst b/docs/concepts/tracking.rst
index d22b9f89..1e69b452 100644
--- a/docs/concepts/tracking.rst
+++ b/docs/concepts/tracking.rst
@@ -131,3 +131,25 @@ This will print the URL to access the Burr UI web app.
     from google.colab import output
     output.serve_kernel_port_as_window(7241) # this will open a new window
     output.serve_kernel_port_as_iframe(7241) # this will inline in an iframe
+
+---------------------------------------------
+Mount Burr UI inside an existing FastAPI app
+---------------------------------------------
+
+You can embed the Burr UI inside an existing FastAPI application using the
+``mount_burr_ui`` helper.
+
+Example:
+
+.. code-block:: python
+
+    from fastapi import FastAPI
+    from burr.tracking.server.run import mount_burr_ui
+
+    app = FastAPI()
+
+    # Mount Burr UI under /burr
+    mount_burr_ui(app, path="/burr")
+
+This allows you to run the Burr tracking UI alongside your own FastAPI
+application in the same server process.
diff --git a/examples/fastapi_mount_example.py 
b/examples/fastapi_mount_example.py
new file mode 100644
index 00000000..aa1e9922
--- /dev/null
+++ b/examples/fastapi_mount_example.py
@@ -0,0 +1,35 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, 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 uvicorn
+from fastapi import FastAPI
+
+from burr.tracking.server.run import mount_burr_ui
+
+app = FastAPI()
+
+# Mount Burr UI under /burr
+mount_burr_ui(app, path="/burr")
+
+
[email protected]("/")
+def root():
+    return {"message": "Main FastAPI app with Burr UI mounted at /burr"}
+
+
+if __name__ == "__main__":
+    uvicorn.run(app, host="0.0.0.0", port=8003)
diff --git a/telemetry/ui/package-lock.json b/telemetry/ui/package-lock.json
index 21461ed4..95f8310a 100644
--- a/telemetry/ui/package-lock.json
+++ b/telemetry/ui/package-lock.json
@@ -5566,6 +5566,159 @@
       "integrity": 
"sha512-/0hWQfiaD5//LvGNgc8PjvyqV50vGK0cADYzaoOOGN8fxzBn3iAiaq3S0tCRnFBldq0LVveLcxCTi41ZoYgAgg==",
       "peer": true
     },
+    "node_modules/@next/swc-darwin-arm64": {
+      "version": "14.2.14",
+      "resolved": 
"https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.14.tgz";,
+      "integrity": 
"sha512-bsxbSAUodM1cjYeA4o6y7sp9wslvwjSkWw57t8DtC8Zig8aG8V6r+Yc05/9mDzLKcybb6EN85k1rJDnMKBd9Gw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-darwin-x64": {
+      "version": "14.2.14",
+      "resolved": 
"https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.14.tgz";,
+      "integrity": 
"sha512-cC9/I+0+SK5L1k9J8CInahduTVWGMXhQoXFeNvF0uNs3Bt1Ub0Azb8JzTU9vNCr0hnaMqiWu/Z0S1hfKc3+dww==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-gnu": {
+      "version": "14.2.14",
+      "resolved": 
"https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.14.tgz";,
+      "integrity": 
"sha512-RMLOdA2NU4O7w1PQ3Z9ft3PxD6Htl4uB2TJpocm+4jcllHySPkFaUIFacQ3Jekcg6w+LBaFvjSPthZHiPmiAUg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-musl": {
+      "version": "14.2.14",
+      "resolved": 
"https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.14.tgz";,
+      "integrity": 
"sha512-WgLOA4hT9EIP7jhlkPnvz49iSOMdZgDJVvbpb8WWzJv5wBD07M2wdJXLkDYIpZmCFfo/wPqFsFR4JS4V9KkQ2A==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-gnu": {
+      "version": "14.2.14",
+      "resolved": 
"https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.14.tgz";,
+      "integrity": 
"sha512-lbn7svjUps1kmCettV/R9oAvEW+eUI0lo0LJNFOXoQM5NGNxloAyFRNByYeZKL3+1bF5YE0h0irIJfzXBq9Y6w==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-musl": {
+      "version": "14.2.14",
+      "resolved": 
"https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.14.tgz";,
+      "integrity": 
"sha512-7TcQCvLQ/hKfQRgjxMN4TZ2BRB0P7HwrGAYL+p+m3u3XcKTraUFerVbV3jkNZNwDeQDa8zdxkKkw2els/S5onQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-arm64-msvc": {
+      "version": "14.2.14",
+      "resolved": 
"https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.14.tgz";,
+      "integrity": 
"sha512-8i0Ou5XjTLEje0oj0JiI0Xo9L/93ghFtAUYZ24jARSeTMXLUx8yFIdhS55mTExq5Tj4/dC2fJuaT4e3ySvXU1A==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-ia32-msvc": {
+      "version": "14.2.14",
+      "resolved": 
"https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.14.tgz";,
+      "integrity": 
"sha512-2u2XcSaDEOj+96eXpyjHjtVPLhkAFw2nlaz83EPeuK4obF+HmtDJHqgR1dZB7Gb6V/d55FL26/lYVd0TwMgcOQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-x64-msvc": {
+      "version": "14.2.14",
+      "resolved": 
"https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.14.tgz";,
+      "integrity": 
"sha512-MZom+OvZ1NZxuRovKt1ApevjiUJTcU2PmdJKL66xUPaJeRywnbGGRWUlaAOwunD6dX+pm83vj979NTC8QXjGWg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "peer": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
       "version": "5.1.1-v1",
       "resolved": 
"https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz";,
diff --git a/telemetry/ui/src/App.tsx b/telemetry/ui/src/App.tsx
index 4b80c5bd..121337ae 100644
--- a/telemetry/ui/src/App.tsx
+++ b/telemetry/ui/src/App.tsx
@@ -50,7 +50,7 @@ import { DeepResearcherWithTelemetry } from 
'./examples/DeepResearcher';
 const App = () => {
   return (
     <QueryClientProvider client={new QueryClient()}>
-      <Router>
+      <Router basename={window.__BURR_BASE_PATH__ || ''}>
         <AppContainer>
           <Routes>
             <Route path="/" element={<Navigate to="/projects" />} />
diff --git a/telemetry/ui/src/api/core/OpenAPI.ts 
b/telemetry/ui/src/api/core/OpenAPI.ts
index 48cc36aa..3a8dbf3e 100644
--- a/telemetry/ui/src/api/core/OpenAPI.ts
+++ b/telemetry/ui/src/api/core/OpenAPI.ts
@@ -38,8 +38,14 @@ export type OpenAPIConfig = {
   ENCODE_PATH?: ((path: string) => string) | undefined;
 };
 
+// When the Burr UI is mounted as a sub-app (e.g. under /burr), the server
+// injects window.__BURR_BASE_PATH__ so API calls are correctly prefixed.
+const basePath = typeof window !== 'undefined'
+  ? window.__BURR_BASE_PATH__ || ''
+  : '';
+
 export const OpenAPI: OpenAPIConfig = {
-  BASE: '',
+  BASE: basePath,
   VERSION: '0.1.0',
   WITH_CREDENTIALS: false,
   CREDENTIALS: 'include',
diff --git a/telemetry/ui/src/components/nav/appcontainer.tsx 
b/telemetry/ui/src/components/nav/appcontainer.tsx
index 86e9614d..35c217e5 100644
--- a/telemetry/ui/src/components/nav/appcontainer.tsx
+++ b/telemetry/ui/src/components/nav/appcontainer.tsx
@@ -45,8 +45,7 @@ const GithubLogo = () => (
     xmlns="http://www.w3.org/2000/svg";
     fill="none"
     viewBox="0 0 22 22"
-    stroke="currentColor"
-  >
+    stroke="currentColor">
     {/* SVG path for GitHub logo */}
     <path
       strokeLinecap="round"
@@ -108,35 +107,35 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
     },
     ...(backendSpec?.supports_demos
       ? [
-        {
-          name: 'Demos',
-          href: '/demos',
-          icon: ListBulletIcon,
-          linkType: 'internal',
-          children: [
-            { name: 'counter', href: '/demos/counter', current: false, 
linkType: 'internal' },
-            { name: 'chatbot', href: '/demos/chatbot', current: false, 
linkType: 'internal' },
-            {
-              name: 'email-assistant',
-              href: '/demos/email-assistant',
-              current: false,
-              linkType: 'internal'
-            },
-            {
-              name: 'streaming-chatbot',
-              href: '/demos/streaming-chatbot',
-              current: false,
-              linkType: 'internal'
-            },
-            {
-              name: 'deep-researcher',
-              href: '/demos/deep-researcher',
-              current: false,
-              linkType: 'internal'
-            }
-          ]
-        }
-      ]
+          {
+            name: 'Demos',
+            href: '/demos',
+            icon: ListBulletIcon,
+            linkType: 'internal',
+            children: [
+              { name: 'counter', href: '/demos/counter', current: false, 
linkType: 'internal' },
+              { name: 'chatbot', href: '/demos/chatbot', current: false, 
linkType: 'internal' },
+              {
+                name: 'email-assistant',
+                href: '/demos/email-assistant',
+                current: false,
+                linkType: 'internal'
+              },
+              {
+                name: 'streaming-chatbot',
+                href: '/demos/streaming-chatbot',
+                current: false,
+                linkType: 'internal'
+              },
+              {
+                name: 'deep-researcher',
+                href: '/demos/deep-researcher',
+                current: false,
+                linkType: 'internal'
+              }
+            ]
+          }
+        ]
       : []),
     {
       name: 'Develop',
@@ -192,8 +191,7 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
               enterTo="opacity-100"
               leave="transition-opacity ease-linear duration-300"
               leaveFrom="opacity-100"
-              leaveTo="opacity-0"
-            >
+              leaveTo="opacity-0">
               <div className="fixed inset-0 bg-gray-900/80" />
             </Transition.Child>
 
@@ -205,8 +203,7 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
                 enterTo="translate-x-0"
                 leave="transition ease-in-out duration-300 transform"
                 leaveFrom="translate-x-0"
-                leaveTo="-translate-x-full"
-              >
+                leaveTo="-translate-x-full">
                 <Dialog.Panel className="relative mr-16 flex w-full max-w-xs 
flex-1">
                   <Transition.Child
                     as={Fragment}
@@ -215,14 +212,12 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
                     enterTo="opacity-100"
                     leave="ease-in-out duration-300"
                     leaveFrom="opacity-100"
-                    leaveTo="opacity-0"
-                  >
+                    leaveTo="opacity-0">
                     <div className="absolute left-full top-0 flex w-16 
justify-center pt-5">
                       <button
                         type="button"
                         className="-m-2.5 p-2.5"
-                        onClick={() => setSmallSidebarOpen(false)}
-                      >
+                        onClick={() => setSmallSidebarOpen(false)}>
                         <span className="sr-only">Close sidebar</span>
                         <XMarkIcon className="h-6 w-6 text-white" 
aria-hidden="true" />
                       </button>
@@ -231,7 +226,11 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
                   {/* Sidebar component, swap this element with another 
sidebar if you like */}
                   <div className="flex grow flex-col gap-y-5 overflow-y-auto 
bg-white px-6 pb-2 py-2">
                     <div className="flex h-16 shrink-0 items-center">
-                      <img className="h-10 w-auto" src={'/logo.png'} 
alt="Burr" />
+                      <img
+                        className="h-10 w-auto"
+                        src={`${window.__BURR_BASE_PATH__ || ''}/logo.png`}
+                        alt="Burr"
+                      />
                     </div>
                     <nav className="flex flex-1 flex-col">
                       <ul role="list" className="flex flex-1 flex-col gap-y-7">
@@ -250,8 +249,7 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
                                     'group flex gap-x-3 rounded-md p-2 text-sm 
leading-6 font-semibold'
                                   )}
                                   target={item.linkType === 'external' ? 
'_blank' : undefined}
-                                  rel={item.linkType === 'external' ? 
'noreferrer' : undefined}
-                                >
+                                  rel={item.linkType === 'external' ? 
'noreferrer' : undefined}>
                                   <item.icon
                                     className={classNames(
                                       isCurrent(item.href, item.linkType)
@@ -278,13 +276,17 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
 
         {/* Static sidebar for desktop */}
         <div
-          className={`hidden ${sidebarOpen ? 'h-screen lg:fixed lg:inset-y-0 
lg:z-50 lg:flex lg:w-72 lg:flex-col' : ''
-            }`}
-        >
+          className={`hidden ${
+            sidebarOpen ? 'h-screen lg:fixed lg:inset-y-0 lg:z-50 lg:flex 
lg:w-72 lg:flex-col' : ''
+          }`}>
           {/* Sidebar component, swap this element with another sidebar if you 
like */}
           <div className="flex grow flex-col gap-y-5 overflow-y-auto border-r 
border-gray-200 bg-white px-6 py-2">
             <div className="flex h-16 shrink-0 items-center">
-              <img className="h-12 w-auto" src={'/public/logo.png'} alt="Burr" 
/>
+              <img
+                className="h-12 w-auto"
+                src={`${window.__BURR_BASE_PATH__ || ''}/public/logo.png`}
+                alt="Burr"
+              />
             </div>
             <nav className="flex flex-1 flex-col">
               <ul role="list" className="flex flex-1 flex-col gap-y-7">
@@ -302,8 +304,7 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
                               'group flex gap-x-3 rounded-md p-2 text-sm 
leading-6 font-semibold text-gray-700'
                             )}
                             target={item.linkType === 'external' ? '_blank' : 
undefined}
-                            rel={item.linkType === 'external' ? 'noreferrer' : 
undefined}
-                          >
+                            rel={item.linkType === 'external' ? 'noreferrer' : 
undefined}>
                             <item.icon
                               className="h-6 w-6 shrink-0 text-gray-400"
                               aria-hidden="true"
@@ -320,8 +321,7 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
                                       ? 'bg-gray-50'
                                       : 'hover:bg-gray-50',
                                     'flex items-center w-full text-left 
rounded-md p-2 gap-x-3 text-sm leading-6 font-semibold text-gray-700'
-                                  )}
-                                >
+                                  )}>
                                   <item.icon
                                     className="h-6 w-6 shrink-0 text-gray-400"
                                     aria-hidden="true"
@@ -351,8 +351,7 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
                                         }
                                         rel={
                                           subItem.linkType === 'external' ? 
'noreferrer' : undefined
-                                        }
-                                      >
+                                        }>
                                         {subItem.name}
                                       </Link>
                                     </li>
@@ -374,11 +373,11 @@ export const AppContainer = (props: { children: 
React.ReactNode }) => {
           </div>
         </div>
         <div
-          className={`hidden h-screen ${!sidebarOpen
-            ? 'lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-8 lg:flex-col 
justify-end lg:py-2 lg:px-1'
-            : ''
-            }`}
-        >
+          className={`hidden h-screen ${
+            !sidebarOpen
+              ? 'lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-8 lg:flex-col 
justify-end lg:py-2 lg:px-1'
+              : ''
+          }`}>
           <ToggleOpenButton open={sidebarOpen} toggleSidebar={toggleSidebar} />
         </div>
 
diff --git a/telemetry/ui/src/examples/StreamingChatbot.tsx 
b/telemetry/ui/src/examples/StreamingChatbot.tsx
index 1e46e386..e006ab01 100644
--- a/telemetry/ui/src/examples/StreamingChatbot.tsx
+++ b/telemetry/ui/src/examples/StreamingChatbot.tsx
@@ -95,8 +95,7 @@ const ChatMessage = (props: { message: ChatItem; id?: string 
}) => {
               a: ({ ...props }) => <a className="text-dwlightblue 
hover:underline" {...props} />
             }}
             remarkPlugins={[remarkGfm]}
-            className={`whitespace-pre-wrap break-lines max-w-full 
${props.message.type === ChatItem.type.ERROR ? 'bg-dwred/10' : ''} p-0.5`}
-          >
+            className={`whitespace-pre-wrap break-lines max-w-full 
${props.message.type === ChatItem.type.ERROR ? 'bg-dwred/10' : ''} p-0.5`}>
             {props.message.content}
           </Markdown>
         ) : (
@@ -180,8 +179,9 @@ export const StreamingChatbot = (props: { projectId: 
string; appId: string | und
   const submitPrompt = async () => {
     setCurrentResponse(''); // Reset it
     setIsChatWaiting(true);
+    const basePath = window.__BURR_BASE_PATH__ || '';
     const response = await fetch(
-      `/api/v0/streaming_chatbot/response/${props.projectId}/${props.appId}`,
+      
`${basePath}/api/v0/streaming_chatbot/response/${props.projectId}/${props.appId}`,
       {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
@@ -292,8 +292,7 @@ export const StreamingChatbot = (props: { projectId: 
string; appId: string | und
           disabled={isChatWaiting || props.appId === undefined}
           onClick={() => {
             submitPrompt();
-          }}
-        >
+          }}>
           Send
         </Button>
       </div>
@@ -318,7 +317,6 @@ export const StreamingChatbotWithTelemetry = () => {
           }
         />
       }
-      mode={'third'}
-    ></TwoColumnLayout>
+      mode={'third'}></TwoColumnLayout>
   );
 };
diff --git a/telemetry/ui/src/react-app-env.d.ts 
b/telemetry/ui/src/react-app-env.d.ts
index 8d10cd35..748ef67e 100644
--- a/telemetry/ui/src/react-app-env.d.ts
+++ b/telemetry/ui/src/react-app-env.d.ts
@@ -18,3 +18,7 @@
  */
 
 /// <reference types="react-scripts" />
+
+interface Window {
+  __BURR_BASE_PATH__?: string;
+}
diff --git a/tests/tracking/test_local_tracking_client.py 
b/tests/tracking/test_local_tracking_client.py
index db071f96..6fe45543 100644
--- a/tests/tracking/test_local_tracking_client.py
+++ b/tests/tracking/test_local_tracking_client.py
@@ -303,9 +303,9 @@ def test_fork_children_have_correct_partition_key(tmpdir):
     assert len(children) == 1
     child = children[0]
     assert child.child.app_id == new_app_id
-    assert child.child.partition_key == partition_key, (
-        f"Child partition_key should be '{partition_key}', got 
'{child.child.partition_key}'"
-    )
+    assert (
+        child.child.partition_key == partition_key
+    ), f"Child partition_key should be '{partition_key}', got 
'{child.child.partition_key}'"
     assert child.event_type == "fork"
 
 
@@ -460,7 +460,7 @@ def test_that_we_fail_on_non_unicode_characters(tmp_path):
 
     @action(reads=["test"], writes=["test"])
     def state_2(state: State) -> State:
-        return state.update(test="\uD800")  # Invalid UTF-8 byte sequence
+        return state.update(test="\ud800")  # Invalid UTF-8 byte sequence
 
     tracker = LocalTrackingClient(project="test", storage_dir=tmp_path)
     app: Application = (


Reply via email to