Skip to content

Commit

Permalink
Web UI - stage1, readonly part (#606)
Browse files Browse the repository at this point in the history
* added fastapi as web ui backend

* Added cube + benchmark basic listing

* Adds navigation

* Aded mlcube detailed page

* Improved mlcubes detailed layout

* Improved mlcube layout

* yaml displaying

* yaml: spinner

* yaml panel improvement

* yaml panel layout improvement

* layout fixes

* Added benchmark detailed page

* added links to mlcube

* benchmark page: added owner

* Colors refactoring

* Dataset detailed page

* Forgot to add js file

* Unified data format for all data fields automatically

* (mlcube-detailed) Display image tarball and additional files always

* Fixed scrolling and reinvented basic page layout

* Fix navbar is hiding

* Make templates & static files independent of user's workdir

* Added error handling

* Display invalid entities correctly

* Added invalid entities highlighting + badges

* Added benchmark associations

* Improved association panel style

* Added association card

* Sorted associations by status / timestamp

* Sorted mlcubes and datasets: mine first

* Added associations to dataset page

* Added associations to mlcube page

* Refactored details page - extracted common styles to the base template

* Refactored association sorting  to common util

* Display my benchmarks first

* Hid empty links

* Mlcube-as-a-link unified view

* resources.path cannot return a dir with subdirs for py3.9

* Fixed resources path for templates also

* linter fix

* static local resources instead of remote ones

* layout fix: align mlcubes vertically

* bugfix: add some dependencies for isolated run

* Fixes after merging main

* MedperfSchema requires a name field

* Linter fix

* Pass mlcube params instead of url

* Pass mlcube parameters to fetch-yaml

---------

Co-authored-by: Alejandro Aristizabal <[email protected]>
  • Loading branch information
VukW and aristizabal95 authored Dec 16, 2024
1 parent 9e49f56 commit 01e4617
Show file tree
Hide file tree
Showing 43 changed files with 1,076 additions and 21 deletions.
2 changes: 2 additions & 0 deletions cli/medperf/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import medperf.commands.association.association as association
import medperf.commands.compatibility_test.compatibility_test as compatibility_test
import medperf.commands.storage as storage
import medperf.web_ui.app as web_ui
from medperf.utils import check_for_updates
from medperf.logging.utils import log_machine_details

Expand All @@ -30,6 +31,7 @@
app.add_typer(compatibility_test.app, name="test", help="Manage compatibility tests")
app.add_typer(auth.app, name="auth", help="Authentication")
app.add_typer(storage.app, name="storage", help="Storage management")
app.add_typer(web_ui.app, name="web-ui", help="local web UI to manage medperf entities")


@app.command("run")
Expand Down
2 changes: 2 additions & 0 deletions cli/medperf/commands/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ def run(
"""Lists all local datasets
Args:
entity_class: entity class to instantiate (Dataset, Model, etc.)
fields (list[str]): list of fields to display
unregistered (bool, optional): Display only local unregistered results. Defaults to False.
mine_only (bool, optional): Display all registered current-user results. Defaults to False.
kwargs (dict): Additional parameters for filtering entity lists.
Expand Down
16 changes: 16 additions & 0 deletions cli/medperf/entities/association.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from datetime import datetime
from typing import Optional

from medperf.entities.schemas import ApprovableSchema, MedperfSchema


class Association(MedperfSchema, ApprovableSchema):
id: int
metadata: dict
dataset: Optional[int]
model_mlcube: Optional[int]
benchmark: int
initiated_by: int
created_at: Optional[datetime]
modified_at: Optional[datetime]
name: str = "Association" # The server data doesn't have name, while MedperfSchema requires it
32 changes: 31 additions & 1 deletion cli/medperf/entities/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pydantic import HttpUrl, Field

import medperf.config as config
from medperf.entities.association import Association
from medperf.entities.interface import Entity
from medperf.entities.schemas import ApprovableSchema, DeployableSchema
from medperf.account_management import get_medperf_user_data
Expand Down Expand Up @@ -83,7 +84,6 @@ def get_models_uids(cls, benchmark_uid: int) -> List[int]:
Args:
benchmark_uid (int): UID of the benchmark.
comms (Comms): Instance of the communications interface.
Returns:
List[int]: List of mlcube uids
Expand All @@ -96,6 +96,36 @@ def get_models_uids(cls, benchmark_uid: int) -> List[int]:
]
return models_uids

@classmethod
def get_models_associations(cls, benchmark_uid: int) -> List[Association]:
"""Retrieves the list of model associations to the benchmark
Args:
benchmark_uid (int): UID of the benchmark.
Returns:
List[Association]: List of associations
"""
associations = config.comms.get_cubes_associations()
associations = [Association(**assoc) for assoc in associations]
associations = [a for a in associations if a.benchmark == benchmark_uid]
return associations

@classmethod
def get_datasets_associations(cls, benchmark_uid: int) -> List[Association]:
"""Retrieves the list of models associated to the benchmark
Args:
benchmark_uid (int): UID of the benchmark.
Returns:
List[Association]: List of associations
"""
associations = config.comms.get_datasets_associations()
associations = [Association(**assoc) for assoc in associations]
associations = [a for a in associations if a.benchmark == benchmark_uid]
return associations

def display_dict(self):
return {
"UID": self.identifier,
Expand Down
42 changes: 30 additions & 12 deletions cli/medperf/entities/cube.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import os
import yaml
import logging
from typing import Dict, Optional, Union
from typing import Dict, Optional, Union, List
from pydantic import Field
from pathlib import Path

from medperf.entities.association import Association
from medperf.utils import (
combine_proc_sp_text,
log_storage,
Expand Down Expand Up @@ -96,19 +97,20 @@ def remote_prefilter(filters: dict):
return comms_fn

@classmethod
def get(cls, cube_uid: Union[str, int], local_only: bool = False) -> "Cube":
def get(cls, cube_uid: Union[str, int], local_only: bool = False, valid_only: bool = True) -> "Cube":
"""Retrieves and creates a Cube instance from the comms. If cube already exists
inside the user's computer then retrieves it from there.
Args:
valid_only: if to raise an error in case of invalidated Cube
cube_uid (str): UID of the cube.
Returns:
Cube : a Cube instance with the retrieved data.
"""

cube = super().get(cube_uid, local_only)
if not cube.is_valid:
if not cube.is_valid and valid_only:
raise InvalidEntityError("The requested MLCube is marked as INVALID.")
cube.download_config_files()
return cube
Expand Down Expand Up @@ -152,7 +154,7 @@ def download_image(self):
# For singularity, we need the hash first before trying to convert
self._set_image_hash_from_registry()

image_folder = os.path.join(config.cubes_folder, config.image_path)
image_folder: str = os.path.join(config.cubes_folder, config.image_path)
if os.path.exists(image_folder):
for file in os.listdir(image_folder):
if file == self._converted_singularity_image_name:
Expand Down Expand Up @@ -198,7 +200,7 @@ def _get_image_from_registry(self):
cmd += f" -Psingularity.image={self._converted_singularity_image_name}"
logging.info(f"Running MLCube command: {cmd}")
with spawn_and_kill(
cmd, timeout=config.mlcube_configure_timeout
cmd, timeout=config.mlcube_configure_timeout
) as proc_wrapper:
proc = proc_wrapper.proc
combine_proc_sp_text(proc)
Expand Down Expand Up @@ -228,13 +230,13 @@ def download_run_files(self):
raise InvalidEntityError(f"MLCube {self.name} image file: {e}")

def run(
self,
task: str,
output_logs: str = None,
string_params: Dict[str, str] = {},
timeout: int = None,
read_protected_input: bool = True,
**kwargs,
self,
task: str,
output_logs: str = None,
string_params: Dict[str, str] = {},
timeout: int = None,
read_protected_input: bool = True,
**kwargs,
):
"""Executes a given task on the cube instance
Expand Down Expand Up @@ -358,6 +360,22 @@ def get_config(self, identifier):

return cube

@classmethod
def get_benchmarks_associations(cls, mlcube_uid: int) -> List[Association]:
"""Retrieves the list of benchmarks model is associated with
Args:
mlcube_uid (int): UID of the cube.
comms (Comms): Instance of the communications interface.
Returns:
List[Association]: List of associations
"""
associations = config.comms.get_cubes_associations()
associations = [Association(**assoc) for assoc in associations]
associations = [a for a in associations if a.model_mlcube == mlcube_uid]
return associations

def display_dict(self):
return {
"UID": self.identifier,
Expand Down
18 changes: 16 additions & 2 deletions cli/medperf/entities/dataset.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import os
import yaml
from pydantic import Field, validator
from typing import Optional, Union
from typing import Optional, Union, List

from medperf.entities.association import Association
from medperf.utils import remove_path
from medperf.entities.interface import Entity
from medperf.entities.schemas import DeployableSchema
Expand Down Expand Up @@ -112,14 +113,27 @@ def remote_prefilter(filters: dict) -> callable:
comms_fn = config.comms.get_user_datasets

if "mlcube" in filters and filters["mlcube"] is not None:

def func():
return config.comms.get_mlcube_datasets(filters["mlcube"])

comms_fn = func

return comms_fn

@classmethod
def get_benchmarks_associations(cls, dataset_uid: int) -> List[Association]:
"""Retrieves the list of benchmarks dataset is associated with
Args:
dataset_uid (int): UID of the dataset.
Returns:
List[Association]: List of associations
"""
associations = config.comms.get_datasets_associations()
associations = [Association(**assoc) for assoc in associations]
associations = [a for a in associations if a.dataset == dataset_uid]
return associations

def display_dict(self):
return {
"UID": self.identifier,
Expand Down
5 changes: 3 additions & 2 deletions cli/medperf/entities/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def path(self) -> str:

@classmethod
def all(
cls: Type[EntityType], unregistered: bool = False, filters: dict = {}
cls: Type[EntityType], unregistered: bool = False, filters: dict = {}
) -> List[EntityType]:
"""Gets a list of all instances of the respective entity.
Whether the list is local or remote depends on the implementation.
Expand Down Expand Up @@ -112,7 +112,7 @@ def remote_prefilter(filters: dict) -> callable:

@classmethod
def get(
cls: Type[EntityType], uid: Union[str, int], local_only: bool = False
cls: Type[EntityType], uid: Union[str, int], local_only: bool = False, valid_only: bool = True
) -> EntityType:
"""Gets an instance of the respective entity.
Wether this requires only local read or remote calls depends
Expand All @@ -121,6 +121,7 @@ def get(
Args:
uid (str): Unique Identifier to retrieve the entity
local_only (bool): If True, the entity will be retrieved locally
valid_only: if to raise en error in case of invalidated entity
Returns:
Entity: Entity Instance associated to the UID
Expand Down
5 changes: 3 additions & 2 deletions cli/medperf/entities/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,16 @@ def all(cls, unregistered: bool = False, filters: dict = {}) -> List["TestReport
return super().all(unregistered=True, filters={})

@classmethod
def get(cls, uid: str, local_only: bool = False) -> "TestReport":
def get(cls, uid: str, local_only: bool = False, valid_only: bool = True) -> "TestReport":
"""Gets an instance of the TestReport. ignores local_only inherited flag as TestReport is always a local entity.
Args:
uid (str): Report Unique Identifier
local_only (bool): ignored. Left for aligning with parent Entity class
valid_only: if to raise an error in case of invalidated entity
Returns:
TestReport: Report Instance associated to the UID
"""
return super().get(uid, local_only=True)
return super().get(uid, local_only=True, valid_only=valid_only)

def display_dict(self):
if self.data_path:
Expand Down
Empty file added cli/medperf/web_ui/__init__.py
Empty file.
50 changes: 50 additions & 0 deletions cli/medperf/web_ui/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from importlib import resources
from pathlib import Path

import typer
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles

from medperf import config
from medperf.decorators import clean_except
from medperf.web_ui.common import custom_exception_handler
from medperf.web_ui.datasets.routes import router as datasets_router
from medperf.web_ui.benchmarks.routes import router as benchmarks_router
from medperf.web_ui.mlcubes.routes import router as mlcubes_router
from medperf.web_ui.yaml_fetch.routes import router as yaml_fetch_router

web_app = FastAPI()

web_app.include_router(datasets_router, prefix="/datasets")
web_app.include_router(benchmarks_router, prefix="/benchmarks")
web_app.include_router(mlcubes_router, prefix="/mlcubes")
web_app.include_router(yaml_fetch_router)

static_folder_path = Path(resources.files("medperf.web_ui")) / "static" # noqa
web_app.mount(
"/static",
StaticFiles(
directory=static_folder_path,
)
)

web_app.add_exception_handler(Exception, custom_exception_handler)


@web_app.get("/", include_in_schema=False)
def read_root():
return RedirectResponse(url="/benchmarks/ui")


app = typer.Typer()


@app.command("run")
@clean_except
def run(
port: int = typer.Option(8100, "--port", help="port to use"),
):
"""Runs a local web UI"""
import uvicorn
uvicorn.run(web_app, host="127.0.0.1", port=port, log_level=config.loglevel)
Empty file.
65 changes: 65 additions & 0 deletions cli/medperf/web_ui/benchmarks/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import logging

from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from fastapi import Request

from medperf.entities.benchmark import Benchmark
from medperf.entities.dataset import Dataset
from medperf.entities.cube import Cube
from medperf.account_management import get_medperf_user_data
from medperf.web_ui.common import templates, sort_associations_display

router = APIRouter()
logger = logging.getLogger(__name__)


@router.get("/ui", response_class=HTMLResponse)
def benchmarks_ui(request: Request, mine_only: bool = False):
filters = {}
my_user_id = get_medperf_user_data()["id"]
if mine_only:
filters["owner"] = my_user_id

benchmarks = Benchmark.all(
filters=filters,
)

benchmarks = sorted(benchmarks, key=lambda x: x.created_at, reverse=True)
# sort by (mine recent) (mine oldish), (other recent), (other oldish)
mine_benchmarks = [d for d in benchmarks if d.owner == my_user_id]
other_benchmarks = [d for d in benchmarks if d.owner != my_user_id]
benchmarks = mine_benchmarks + other_benchmarks
return templates.TemplateResponse("benchmarks.html", {"request": request, "benchmarks": benchmarks})


@router.get("/ui/{benchmark_id}", response_class=HTMLResponse)
def benchmark_detail_ui(request: Request, benchmark_id: int):
benchmark = Benchmark.get(benchmark_id)
data_preparation_mlcube = Cube.get(cube_uid=benchmark.data_preparation_mlcube)
reference_model_mlcube = Cube.get(cube_uid=benchmark.reference_model_mlcube)
metrics_mlcube = Cube.get(cube_uid=benchmark.data_evaluator_mlcube)
datasets_associations = Benchmark.get_datasets_associations(benchmark_uid=benchmark_id)
models_associations = Benchmark.get_models_associations(benchmark_uid=benchmark_id)

datasets_associations = sort_associations_display(datasets_associations)
models_associations = sort_associations_display(models_associations)

datasets = {assoc.dataset: Dataset.get(assoc.dataset) for assoc in datasets_associations if assoc.dataset}
models = {assoc.model_mlcube: Cube.get(assoc.model_mlcube) for assoc in models_associations if assoc.model_mlcube}

return templates.TemplateResponse(
"benchmark_detail.html",
{
"request": request,
"entity": benchmark,
"entity_name": benchmark.name,
"data_preparation_mlcube": data_preparation_mlcube,
"reference_model_mlcube": reference_model_mlcube,
"metrics_mlcube": metrics_mlcube,
"datasets_associations": datasets_associations,
"models_associations": models_associations,
"datasets": datasets,
"models": models
}
)
Loading

0 comments on commit 01e4617

Please sign in to comment.