diff --git a/burr/cli/__main__.py b/burr/cli/__main__.py index b256c429..dfe600a4 100644 --- a/burr/cli/__main__.py +++ b/burr/cli/__main__.py @@ -224,7 +224,8 @@ def build_and_publish(prod: bool, no_wipe_dist: bool): default="burr/tracking/server/demo_data", ) @click.option("--unique-app-names", help="Use unique app names", is_flag=True) -def generate_demo_data(s3_bucket, data_dir, unique_app_names: bool): +@click.option("--no-clear-current-data", help="Don't clear current data", is_flag=True) +def generate_demo_data(s3_bucket, data_dir, unique_app_names: bool, no_clear_current_data: bool): _telemetry_if_enabled("generate_demo_data") git_root = _get_git_root() # We need to add the examples directory to the path so we have all the imports @@ -236,7 +237,8 @@ def generate_demo_data(s3_bucket, data_dir, unique_app_names: bool): if s3_bucket is None: with cd(git_root): logger.info("Removing old demo data") - shutil.rmtree(data_dir, ignore_errors=True) + if not no_clear_current_data: + shutil.rmtree(data_dir, ignore_errors=True) generate_all(data_dir=data_dir, unique_app_names=unique_app_names) else: generate_all(s3_bucket=s3_bucket, unique_app_names=unique_app_names) diff --git a/burr/tracking/server/backend.py b/burr/tracking/server/backend.py index c1f02bcb..4ef3efdc 100644 --- a/burr/tracking/server/backend.py +++ b/burr/tracking/server/backend.py @@ -4,7 +4,7 @@ import json import os.path import sys -from typing import Any, Optional, Sequence, Type, TypeVar +from typing import Any, Optional, Sequence, Tuple, Type, TypeVar import aiofiles import aiofiles.os as aiofilesos @@ -101,8 +101,13 @@ async def list_projects(self, request: fastapi.Request) -> Sequence[schema.Proje @abc.abstractmethod async def list_apps( - self, request: fastapi.Request, project_id: str, partition_key: Optional[str] - ) -> Sequence[schema.ApplicationSummary]: + self, + request: fastapi.Request, + project_id: str, + partition_key: Optional[str], + limit: int, + offset: int, + ) -> Tuple[Sequence[schema.ApplicationSummary], int]: """Lists out all apps (continual state machine runs with shared state) for a given project. :param request: The request object, used for authentication/authorization if needed @@ -230,8 +235,13 @@ async def _load_metadata(self, metadata_path: str) -> models.ApplicationMetadata return models.ApplicationMetadataModel() async def list_apps( - self, request: fastapi.Request, project_id: str, partition_key: Optional[str] - ) -> Sequence[ApplicationSummary]: + self, + request: fastapi.Request, + project_id: str, + partition_key: Optional[str], + limit: int = 100, + offset: int = 0, + ) -> Tuple[Sequence[ApplicationSummary], bool]: project_filepath = os.path.join(self.path, project_id) if not os.path.exists(project_filepath): return [] @@ -265,7 +275,9 @@ async def list_apps( spawning_parent_pointer=metadata.spawning_parent_pointer, ) ) - return out + out = sorted(out, key=lambda x: x.last_written, reverse=True) + # TODO -- actually only get the most recent ones rather than reading everything, this is inefficient + return out[offset : offset + limit], len(out) async def get_application_logs( self, request: fastapi.Request, project_id: str, app_id: str, partition_key: Optional[str] diff --git a/burr/tracking/server/run.py b/burr/tracking/server/run.py index f85a9e05..f99c4602 100644 --- a/burr/tracking/server/run.py +++ b/burr/tracking/server/run.py @@ -23,7 +23,12 @@ from starlette.templating import Jinja2Templates from burr.tracking.server import schema - from burr.tracking.server.schema import ApplicationLogs, BackendSpec, IndexingJob + from burr.tracking.server.schema import ( + ApplicationLogs, + ApplicationPage, + BackendSpec, + IndexingJob, + ) # dynamic importing due to the dashes (which make reading the examples on github easier) email_assistant = importlib.import_module("burr.examples.email-assistant.server") @@ -166,12 +171,14 @@ async def get_projects(request: Request) -> Sequence[schema.Project]: return await backend.list_projects(request) -@app.get( - "/api/v0/{project_id}/{partition_key}/apps", response_model=Sequence[schema.ApplicationSummary] -) +@app.get("/api/v0/{project_id}/{partition_key}/apps", response_model=ApplicationPage) async def get_apps( - request: Request, project_id: str, partition_key: str -) -> Sequence[schema.ApplicationSummary]: + 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 @@ -180,7 +187,14 @@ async def get_apps( """ if partition_key == SENTINEL_PARTITION_KEY: partition_key = None - return await backend.list_apps(request, project_id, partition_key=partition_key) + 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, + ) @app.get("/api/v0/{project_id}/{app_id}/{partition_key}/apps") diff --git a/burr/tracking/server/s3/backend.py b/burr/tracking/server/s3/backend.py index 879d7ec7..b9467351 100644 --- a/burr/tracking/server/s3/backend.py +++ b/burr/tracking/server/s3/backend.py @@ -549,7 +549,7 @@ async def list_apps( partition_key: Optional[str], limit: int = 100, offset: int = 0, - ) -> Sequence[schema.ApplicationSummary]: + ) -> Tuple[Sequence[schema.ApplicationSummary], int]: # TODO -- distinctify between project name and project ID # Currently they're the same in the UI but we'll want to have them decoupled app_query = ( @@ -586,7 +586,8 @@ async def list_apps( tags={}, ) ) - return out + total_app_count = await app_query.count() + return out, total_app_count async def get_application_logs( self, request: fastapi.Request, project_id: str, app_id: str, partition_key: str diff --git a/burr/tracking/server/schema.py b/burr/tracking/server/schema.py index 09057550..7bc1d30f 100644 --- a/burr/tracking/server/schema.py +++ b/burr/tracking/server/schema.py @@ -41,6 +41,12 @@ class ApplicationSummary(pydantic.BaseModel): spawning_parent_pointer: Optional[PointerModel] = None +class ApplicationPage(pydantic.BaseModel): + applications: List[ApplicationSummary] + total: int + has_another_page: bool + + class ApplicationModelWithChildren(pydantic.BaseModel): application: ApplicationModel children: List[PointerModel] diff --git a/telemetry/ui/src/api/index.ts b/telemetry/ui/src/api/index.ts index 721d334b..35b03467 100644 --- a/telemetry/ui/src/api/index.ts +++ b/telemetry/ui/src/api/index.ts @@ -10,6 +10,7 @@ export type { OpenAPIConfig } from './core/OpenAPI'; export type { ActionModel } from './models/ActionModel'; export type { ApplicationLogs } from './models/ApplicationLogs'; export type { ApplicationModel } from './models/ApplicationModel'; +export type { ApplicationPage } from './models/ApplicationPage'; export type { ApplicationSummary } from './models/ApplicationSummary'; export type { AttributeModel } from './models/AttributeModel'; export type { BackendSpec } from './models/BackendSpec'; diff --git a/telemetry/ui/src/api/services/DefaultService.ts b/telemetry/ui/src/api/services/DefaultService.ts index a4ad5d05..2d4ff459 100644 --- a/telemetry/ui/src/api/services/DefaultService.ts +++ b/telemetry/ui/src/api/services/DefaultService.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ import type { ApplicationLogs } from '../models/ApplicationLogs'; -import type { ApplicationSummary } from '../models/ApplicationSummary'; +import type { ApplicationPage } from '../models/ApplicationPage'; import type { BackendSpec } from '../models/BackendSpec'; import type { ChatItem } from '../models/ChatItem'; import type { DraftInit } from '../models/DraftInit'; @@ -63,13 +63,17 @@ export class DefaultService { * :return: a list of projects visible by the user * @param projectId * @param partitionKey - * @returns ApplicationSummary Successful Response + * @param limit + * @param offset + * @returns ApplicationPage Successful Response * @throws ApiError */ public static getAppsApiV0ProjectIdPartitionKeyAppsGet( projectId: string, - partitionKey: string - ): CancelablePromise> { + partitionKey: string, + limit: number = 100, + offset?: number + ): CancelablePromise { return __request(OpenAPI, { method: 'GET', url: '/api/v0/{project_id}/{partition_key}/apps', @@ -77,6 +81,10 @@ export class DefaultService { project_id: projectId, partition_key: partitionKey }, + query: { + limit: limit, + offset: offset + }, errors: { 422: `Validation Error` } diff --git a/telemetry/ui/src/components/common/link.tsx b/telemetry/ui/src/components/common/link.tsx index ed5a77d3..8ac1e951 100644 --- a/telemetry/ui/src/components/common/link.tsx +++ b/telemetry/ui/src/components/common/link.tsx @@ -5,7 +5,7 @@ * ensure this does what we want. */ -import { DataInteractive as HeadlessDataInteractive } from '@headlessui/react'; +import { Link as RouterLink } from 'react-router-dom'; import React from 'react'; export const Link = React.forwardRef(function Link( @@ -13,8 +13,9 @@ export const Link = React.forwardRef(function Link( ref: React.ForwardedRef ) { return ( - - - + + // + // + // ); }); diff --git a/telemetry/ui/src/components/common/pagination.tsx b/telemetry/ui/src/components/common/pagination.tsx new file mode 100644 index 00000000..cbcbffe3 --- /dev/null +++ b/telemetry/ui/src/components/common/pagination.tsx @@ -0,0 +1,185 @@ +import clsx from 'clsx'; +import type React from 'react'; +import { Button } from './button'; + +export function Pagination({ + 'aria-label': ariaLabel = 'Page navigation', + className, + ...props +}: React.ComponentPropsWithoutRef<'nav'>) { + return