diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ccc89b44..81386c2e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.21" + go-version: "1.22" - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/ui-bff-build.yml b/.github/workflows/ui-bff-build.yml index 9248d2dee..e3e4727ed 100644 --- a/.github/workflows/ui-bff-build.yml +++ b/.github/workflows/ui-bff-build.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.22.2" + go-version: "1.23.5" - name: Clean working-directory: clients/ui/bff @@ -27,7 +27,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v6 with: - version: v1.57.2 + version: v1.63.4 working-directory: clients/ui/bff/ - name: Build diff --git a/README.md b/README.md index f7faef0cb..39b94336c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Model registry provides a central repository for model developers to store and m 8. [UI](.clients/ui/README.md) ## Pre-requisites: -- go >= 1.21 +- go >= 1.22 - protoc v24.3 - [Protocol Buffers v24.3 Release](https://github.com/protocolbuffers/protobuf/releases/tag/v24.3) - npm >= 10.2.0 - [Installing Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) - Java >= 11.0 @@ -173,7 +173,7 @@ To delete something, simply update its status. ## Tips ### Pull image rate limiting -Ocassionally you may encounter an 'ImagePullBackOff' error when deploying the Model Registry manifests. See example below for the `model-registry-db` container. +Ocassionally you may encounter an 'ImagePullBackOff' error when deploying the Model Registry manifests. See example below for the `model-registry-db` container. ``` Failed to pull image “mysql:8.3.0”: rpc error: code = Unknown desc = fetching target platform image selected from image index: reading manifest sha256:f9097d95a4ba5451fff79f4110ea6d750ac17ca08840f1190a73320b84ca4c62 in docker.io/library/mysql: toomanyrequests: You have reached your pull rate limit. You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limit diff --git a/clients/python/poetry.lock b/clients/python/poetry.lock index 71cce3eba..31f9dc4c4 100644 --- a/clients/python/poetry.lock +++ b/clients/python/poetry.lock @@ -1053,43 +1053,49 @@ files = [ [[package]] name = "mypy" -version = "1.14.0" +version = "1.14.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87"}, - {file = "mypy-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179"}, - {file = "mypy-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1628c5c3ce823d296e41e2984ff88c5861499041cb416a8809615d0c1f41740e"}, - {file = "mypy-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fadb29b77fc14a0dd81304ed73c828c3e5cde0016c7e668a86a3e0dfc9f3af3"}, - {file = "mypy-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:3fa76988dc760da377c1e5069200a50d9eaaccf34f4ea18428a3337034ab5a44"}, - {file = "mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a"}, - {file = "mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc"}, - {file = "mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015"}, - {file = "mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb"}, - {file = "mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc"}, - {file = "mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd"}, - {file = "mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1"}, - {file = "mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63"}, - {file = "mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d"}, - {file = "mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba"}, - {file = "mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741"}, - {file = "mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7"}, - {file = "mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8"}, - {file = "mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc"}, - {file = "mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f"}, - {file = "mypy-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b16738b1d80ec4334654e89e798eb705ac0c36c8a5c4798496cd3623aa02286"}, - {file = "mypy-1.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10065fcebb7c66df04b05fc799a854b1ae24d9963c8bb27e9064a9bdb43aa8ad"}, - {file = "mypy-1.14.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbb7d683fa6bdecaa106e8368aa973ecc0ddb79a9eaeb4b821591ecd07e9e03c"}, - {file = "mypy-1.14.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3498cb55448dc5533e438cd13d6ddd28654559c8c4d1fd4b5ca57a31b81bac01"}, - {file = "mypy-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:c7b243408ea43755f3a21a0a08e5c5ae30eddb4c58a80f415ca6b118816e60aa"}, - {file = "mypy-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14117b9da3305b39860d0aa34b8f1ff74d209a368829a584eb77524389a9c13e"}, - {file = "mypy-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af98c5a958f9c37404bd4eef2f920b94874507e146ed6ee559f185b8809c44cc"}, - {file = "mypy-1.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b343a1d3989547024377c2ba0dca9c74a2428ad6ed24283c213af8dbb0710b"}, - {file = "mypy-1.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cdb5563c1726c85fb201be383168f8c866032db95e1095600806625b3a648cb7"}, - {file = "mypy-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:74e925649c1ee0a79aa7448baf2668d81cc287dc5782cff6a04ee93f40fb8d3f"}, - {file = "mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab"}, - {file = "mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6"}, + {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, + {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, + {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, + {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, + {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, + {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, + {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, + {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, + {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, + {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, + {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, + {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, + {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, + {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, + {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, + {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, ] [package.dependencies] @@ -1315,13 +1321,13 @@ files = [ [[package]] name = "pydantic" -version = "2.10.4" +version = "2.10.6" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, - {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, + {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, + {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, ] [package.dependencies] @@ -1615,29 +1621,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.9.2" +version = "0.9.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"}, - {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"}, - {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"}, - {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"}, - {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"}, - {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"}, - {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"}, + {file = "ruff-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624"}, + {file = "ruff-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c"}, + {file = "ruff-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6"}, + {file = "ruff-0.9.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730"}, + {file = "ruff-0.9.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2"}, + {file = "ruff-0.9.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519"}, + {file = "ruff-0.9.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b"}, + {file = "ruff-0.9.3-py3-none-win32.whl", hash = "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c"}, + {file = "ruff-0.9.3-py3-none-win_amd64.whl", hash = "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4"}, + {file = "ruff-0.9.3-py3-none-win_arm64.whl", hash = "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b"}, + {file = "ruff-0.9.3.tar.gz", hash = "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a"}, ] [[package]] diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index ce11ea1c9..d246fbb19 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "model-registry" -version = "0.2.12" +version = "0.2.13" description = "Client for Kubeflow Model Registry" authors = ["Isabella Basso do Amaral "] license = "Apache-2.0" diff --git a/clients/python/src/model_registry/__init__.py b/clients/python/src/model_registry/__init__.py index 552b844ce..74be16c6d 100644 --- a/clients/python/src/model_registry/__init__.py +++ b/clients/python/src/model_registry/__init__.py @@ -1,6 +1,6 @@ """Main package for the Kubeflow model registry.""" -__version__ = "0.2.12" +__version__ = "0.2.13" from ._client import ModelRegistry diff --git a/clients/python/src/model_registry/core.py b/clients/python/src/model_registry/core.py index 19da2af02..ac6dfc4f8 100644 --- a/clients/python/src/model_registry/core.py +++ b/clients/python/src/model_registry/core.py @@ -78,7 +78,7 @@ def insecure_connection( user_token: The PEM-encoded user token as a string. """ return cls( - Configuration(host=f"{server_address}:{port}", access_token=user_token) + Configuration(host=f"{server_address}:{port}", access_token=user_token, verify_ssl=False) ) @asynccontextmanager diff --git a/clients/python/src/mr_openapi/configuration.py b/clients/python/src/mr_openapi/configuration.py index 6b6a378a0..f569dfb20 100644 --- a/clients/python/src/mr_openapi/configuration.py +++ b/clients/python/src/mr_openapi/configuration.py @@ -53,8 +53,11 @@ class Configuration: string values to replace variables in templated server configuration. The validation of enums is performed for variables with defined enum values before. - :param ssl_ca_cert: str - the path to a file of concatenated CA certificates + :param ssl_ca_cert: str - The path to a file of concatenated CA certificates in PEM format. + :param verify_ssl: bool - Whether to verify the SSL certificate when making API + requests to an HTTPS server. + Set to False to disable verification, default=True. :Example: """ @@ -74,6 +77,7 @@ def __init__( server_operation_index=None, server_operation_variables=None, ssl_ca_cert=None, + verify_ssl=True, ) -> None: """Constructor.""" self._base_path = "https://localhost:8080" if host is None else host @@ -133,7 +137,7 @@ def __init__( """Debug switch """ - self.verify_ssl = True + self.verify_ssl = verify_ssl """SSL/TLS verification Set this to false to skip verifying SSL certificate when calling API from https server. diff --git a/clients/python/tests/regression_test.py b/clients/python/tests/regression_test.py index 0310f3879..4645833ea 100644 --- a/clients/python/tests/regression_test.py +++ b/clients/python/tests/regression_test.py @@ -1,4 +1,5 @@ import pytest +import requests from model_registry import ModelRegistry from model_registry.types.artifacts import ModelArtifact @@ -99,3 +100,35 @@ async def test_create_standalone_model_artifact(client: ModelRegistry): assert mv.id mv_ma = await client._api.upsert_model_version_artifact(new_ma, mv.id) assert mv_ma.id == new_ma.id + +@pytest.mark.e2e +async def test_patch_model_artifacts_artifact_type(client: ModelRegistry): + """Patching Artifacts makes the model registry server panic. + + reported with https://issues.redhat.com/browse/RHOAIENG-16932 + """ + name = "test_model" + version = "1.0.0" + rm = client.register_model( + name, + "s3", + model_format_name="test_format", + model_format_version="test_version", + version=version, + ) + assert rm.id + mv = client.get_model_version(name, version) + assert mv + assert mv.id + ma = client.get_model_artifact(name, version) + assert ma + assert ma.id + + payload = { "modelFormatName": "foo", "artifactType": "model-artifact" } + from .conftest import REGISTRY_HOST, REGISTRY_PORT + response = requests.patch(url=f"{REGISTRY_HOST}:{REGISTRY_PORT}/api/model_registry/v1alpha3/artifacts/{ma.id}", json=payload, timeout=10, headers={"Content-Type": "application/json"}) + assert response.status_code == 200 + ma = client.get_model_artifact(name, version) + assert ma + assert ma.id + assert ma.model_format_name == "foo" diff --git a/clients/ui/Dockerfile b/clients/ui/Dockerfile index 21a9af3b3..99cb90598 100644 --- a/clients/ui/Dockerfile +++ b/clients/ui/Dockerfile @@ -4,13 +4,15 @@ ARG BFF_SOURCE_CODE=./bff # Set the base images for the build stages ARG NODE_BASE_IMAGE=node:20 -ARG GOLANG_BASE_IMAGE=golang:1.22.2 +ARG GOLANG_BASE_IMAGE=golang:1.23.5 ARG DISTROLESS_BASE_IMAGE=gcr.io/distroless/static:nonroot # UI build stage FROM ${NODE_BASE_IMAGE} AS ui-builder ARG UI_SOURCE_CODE +ARG DEPLOYMENT_MODE +ARG MOCK_AUTH WORKDIR /usr/src/app diff --git a/clients/ui/Makefile b/clients/ui/Makefile index 06498fb53..de125cc7d 100644 --- a/clients/ui/Makefile +++ b/clients/ui/Makefile @@ -50,7 +50,7 @@ docker-build: .PHONY: docker-build-standalone docker-build-standalone: - MOCK_AUTH=true DEPLOYMENT_MODE=standalone $(CONTAINER_TOOL) build -t ${IMG_UI_STANDALONE} . + $(CONTAINER_TOOL) build --build-arg MOCK_AUTH=true --build-arg DEPLOYMENT_MODE=standalone -t ${IMG_UI_STANDALONE} . .PHONY: docker-buildx docker-buildx: @@ -58,7 +58,7 @@ docker-buildx: .PHONY: docker-buildx-standalone docker-buildx-standalone: - MOCK_AUTH=true DEPLOYMENT_MODE=standalone docker buildx build --platform ${PLATFORM} -t ${IMG_UI_STANDALONE} --push . + docker buildx build --build-arg MOCK_AUTH=true --build-arg DEPLOYMENT_MODE=standalone --platform ${PLATFORM} -t ${IMG_UI_STANDALONE} --push . ############ Push ############ @@ -83,7 +83,7 @@ frontend-build: .PHONY: frontend-build-standalone frontend-build-standalone: - MOCK_AUTH=true DEPLOYMENT_MODE=standalone cd frontend && npm run build:prod + cd frontend && MOCK_AUTH=true DEPLOYMENT_MODE=standalone npm run build:prod .PHONY: bff-build bff-build: diff --git a/clients/ui/bff/Makefile b/clients/ui/bff/Makefile index 4cbf7d79e..2d53af2da 100644 --- a/clients/ui/bff/Makefile +++ b/clients/ui/bff/Makefile @@ -64,7 +64,7 @@ ENVTEST ?= $(LOCALBIN)/setup-envtest-$(ENVTEST_VERSION) GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint-$(GOLANGCI_LINT_VERSION) ## Tool Versions -GOLANGCI_LINT_VERSION ?= v1.57.2 +GOLANGCI_LINT_VERSION ?= v1.63.4 ENVTEST_VERSION ?= release-0.17 .PHONY: envtest diff --git a/clients/ui/bff/README.md b/clients/ui/bff/README.md index 474bbde35..4a910a90f 100644 --- a/clients/ui/bff/README.md +++ b/clients/ui/bff/README.md @@ -1,13 +1,16 @@ # Kubeflow Model Registry UI BFF + The Kubeflow Model Registry UI BFF is the _backend for frontend_ (BFF) used by the Kubeflow Model Registry UI. ## Pre-requisites: -### Dependencies +### Dependencies + - Go >= 1.22.2 ### Running model registry & ml-metadata -To be operational, our BFF needs the Model Registry & Ml-metadata backend running. + +To be operational, our BFF needs the Model Registry & Ml-metadata backend running. > **NOTE:** Docker compose must be installed in your environment. @@ -24,37 +27,46 @@ When shutting down the docker compose, you might want to clean-up the SQLite db # Development Run the following command to build the BFF: + ```shell make build ``` + After building it, you can run our app with: + ```shell make run ``` + If you want to use a different port, mock kubernetes client or model registry client - useful for front-end development, you can run: + ```shell make run PORT=8000 MOCK_K8S_CLIENT=true MOCK_MR_CLIENT=true ``` + If you want to change the log level on deployment, add the LOG_LEVEL argument when running, supported levels are: ERROR, WARN, INFO, DEBUG. The default level is INFO. + ```shell # Run with debug logging -make run LOG_LEVEL=DEBUG +make run LOG_LEVEL=DEBUG ``` # Building and Deploying Run the following command to build the BFF: + ```shell make build ``` + The BFF binary will be inside `bin` directory You can also build BFF docker image with: + ```shell make docker-build ``` - ## Getting started ### Endpoints @@ -66,33 +78,40 @@ See the [OpenAPI specification](../api/openapi/mod-arch.yaml) for a complete lis You will need to inject your requests with a `kubeflow-userid` header and namespace for authorization purposes. When running the service with the mocked Kubernetes client (MOCK_K8S_CLIENT=true), the user `user@example.com` is preconfigured with the necessary RBAC permissions to perform these actions. + ``` # GET /v1/healthcheck curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/healthcheck" ``` + ``` # GET /v1/user curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/user" ``` + ``` # GET /v1/namespaces (only works when DEV_MODE=true) curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/namespaces" ``` + ``` -# GET /v1/model_registry +# GET /v1/model_registry curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry?namespace=kubeflow" ``` + ``` # GET /v1/model_registry using groups permissions -curl -i \ +curl -i \ -H "kubeflow-userid: non-user@example.com" \ -H "kubeflow-groups: dora-namespace-group ,group2,group3" \ "http://localhost:4000/api/v1/model_registry?namespace=dora-namespace" ``` + ``` # GET /v1/model_registry/{model_registry_id}/registered_models curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow" ``` + ``` # GET /v1/model_registry/{model_registry_id}/registered_models using group permissions curl -i \ @@ -100,6 +119,7 @@ curl -i \ -H "kubeflow-groups: dora-namespace-group ,dora-service-group,group3" \ "http://localhost:4000/api/v1/model_registry/model-registry-dora/registered_models?namespace=dora-namespace" ``` + ``` #POST /v1/model_registry/{model_registry_id}/registered_models curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow" \ @@ -118,10 +138,12 @@ curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/ap "state": "LIVE" }}' ``` + ``` # GET /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id} curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow" ``` + ``` # PATCH /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id} curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow" \ @@ -130,14 +152,17 @@ curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/a "description": "New description" }}' ``` + ``` # GET /api/v1/model_registry/{model_registry_id}/model_versions curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/model_versions?namespace=kubeflow" ``` + ``` -# GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id} +# GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id} curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow" ``` + ``` # POST /api/v1/model_registry/{model_registry_id}/model_versions curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions?namespace=kubeflow" \ @@ -157,6 +182,7 @@ curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/ap "registeredModelId": "1" }}' ``` + ``` # PATCH /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id} curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow" \ @@ -165,10 +191,12 @@ curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/a "description": "New description 2" }}' ``` + ``` # GET /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id}/versions curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow" ``` + ``` # POST /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id}/versions curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow" \ @@ -188,10 +216,12 @@ curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/ap "registeredModelId": "1" }}' ``` + ``` -# GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts +# GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow" ``` + ``` # POST /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow" \ @@ -211,27 +241,72 @@ curl -i -H "kubeflow-userid: user@example.com" -X POST "http://localhost:4000/ap }}' ``` +``` +# GET /api/v1/model_registry/{model_registry_id}/artifacts +curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/artifacts?namespace=kubeflow" + +``` + +``` +# GET /api/v1/model_registry/{model_registry_id}/artifacts/{artifact_id} +curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/artifacts/{artifact_id}?namespace=kubeflow" + +``` + +``` +# POST /api/v1/model_registry/{model_registry_id}/artifacts +curl -i \ + -H "kubeflow-userid: user@example.com" \ + -X POST "http://localhost:4000/api/v1/model_registry/model-registry/artifacts?namespace=kubeflow" \ + -H "Content-Type: application/json" \ + -d '{ + "data": { + "artifactType": "model-artifact", + "name": "dora-classifier-v2", + "description": "MNIST digit classification model trained on TensorFlow", + "uri": "gs://my-models/mnist-classifier/v2", + "externalId": "model-12345678", + "modelFormatName": "tensorflow", + "modelFormatVersion": "2.9.0", + "storageKey": "models/mnist/1.0.0", + "storagePath": "/models/mnist/1.0.0/model.savedmodel" + } + }' +``` + +``` +# PATCH /api/v1/model_registry/{model_registry_id}/artifacts/{artifact_id} +curl -i -H "kubeflow-userid: user@example.com" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/artifacts/1?namespace=kubeflow" \ + -H "Content-Type: application/json" \ +-d '{ "data": { + "artifactType": "model-artifact", + "description": "New description 2" +}}' +``` + ### Pagination + The following query parameters are supported by "Get All" style endpoints to control pagination. | Parameter Name | Description | -|----------------|-----------------------------------------------------------------------------------------------------------| +| -------------- | --------------------------------------------------------------------------------------------------------- | | pageSize | Number of entities in each page | | orderBy | Specifies the order by criteria for listing entities. Available values: CREATE_TIME, LAST_UPDATE_TIME, ID | | sortOrder | Specifies the sort order for listing entities. Available values: ASC, DESC. Default: ASC | -| nextPageToken | Token to use to retrieve next page of results. | +| nextPageToken | Token to use to retrieve next page of results. | ### Sample local calls + ``` # Get with a page size of 5 getting a specific page. curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/registered_models?pageSize=5&nextPageToken=CAEQARoCCAE" ``` + ``` # Get with a page size of 5, order by last update time in descending order. curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/registered_models?pageSize=5&orderBy=LAST_UPDATE_TIME&sortOrder=DESC" ``` - ### FAQ #### 1. How do we filter model registry services from other Kubernetes services? @@ -239,6 +314,7 @@ curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/mod We filter Model Registry services by using the Kubernetes label `component: model-registry. This label helps distinguish Model Registry services from other services in the cluster. For example, in our service manifest, the `component label is defined as follows: + ```yaml # ... labels: @@ -246,6 +322,7 @@ labels: component: model-registry #... ``` + You can view the complete Model Registry service manifest [here](https://github.com/kubeflow/model-registry/blob/main/manifests/kustomize/base/model-registry-service.yaml#L10). #### 2. What is the structure of the mock Kubernetes environment? @@ -253,23 +330,24 @@ You can view the complete Model Registry service manifest [here](https://github. The mock Kubernetes environment is activated when the environment variable `MOCK_K8S_CLIENT` is set to `true`. It is based on `env-test` and is designed to simulate a realistic Kubernetes setup for testing. The mock has the following characteristics: - **Namespaces**: + - `kubeflow` - `dora-namespace` - `bella-namespace` - **Users**: + - `user@example.com` (has `cluster-admin` privileges) - `doraNonAdmin@example.com` (restricted to the `dora-namespace`) - `bellaNonAdmin@example.com` (restricted to the `bella-namespace`) - **Groups**: - `dora-service-group` (has access to `model-registry-dora` inside `dora-namespace`) - - `dora-namespace-group` (has access to the `dora-namespace`) - + - `dora-namespace-group` (has access to the `dora-namespace`) - **Services (Model Registries)**: - `model-registry`: resides in the `kubeflow` namespace with the label `component: model-registry`. - `model-registry-one`: resides in the `kubeflow` namespace with the label `component: model-registry`. - - `non-model-registry`: resides in the `kubeflow` namespace *without* the label `component: model-registry`. + - `non-model-registry`: resides in the `kubeflow` namespace _without_ the label `component: model-registry`. - `model-registry-dora`: resides in the `dora-namespace` namespace with the label `component: model-registry`. #### 3. How BFF authorization works for kubeflow-userid and kubeflow-groups? @@ -279,22 +357,24 @@ Authorization is performed using Kubernetes SubjectAccessReview (SAR), which val - `kubeflow-userid`: Required header that specifies the user’s email. Access is checked directly for the user via SAR. - `kubeflow-groups`: Optional header with a comma-separated list of groups. If the user does not have access, SAR checks group permissions using OR logic. If any group has access, the request is authorized. - Access to Model Registry List: + - To list all model registries (/v1/model_registry), we perform a SAR check for get and list verbs on services within the specified namespace. - If the user or any group has permission to get and list services in the namespace, the request is authorized. Access to Specific Model Registry Endpoints: + - For other endpoints (e.g., /v1/model_registry/{model_registry_id}/...), we perform a SAR check for get and list verbs on the specific service (identified by model_registry_id) within the namespace. - If the user or any group has permission to get or list the specific service, the request is authorized. #### 4. How do I allow CORS requests from other origins -When serving the UI directly from the BFF there is no need for any CORS headers to be served, by default they are turned off for security reasons. +When serving the UI directly from the BFF there is no need for any CORS headers to be served, by default they are turned off for security reasons. If you need to enable CORS for any reasons you can add origins to the allow-list in several ways: ##### Via the make command + Add the following parameter to your command: `ALLOWED_ORIGINS` this takes a comma separated list of origins to permit serving to, alterantively you can specify the value `*` to allow all origins, **Note this is not recommended in production deployments as it poses a security risk** Examples: @@ -314,6 +394,7 @@ make run ALLOWED_ORIGINS="" ``` #### Via environment variable + Setting CORS via environment variable follows the same rules as using the Makefile, simply set the environment variable `ALLOWED_ORIGINS` with the same value as above. #### Via the command line arguments @@ -321,7 +402,7 @@ Setting CORS via environment variable follows the same rules as using the Makefi Setting CORS via command line arguments follows the same rules as using the Makefile. Simply add the `--allowed-origins=` flag to your command. Examples: + ```shell ./bff --allowed-origins="http://my-domain.com,http://my-other-domain.com" ``` - diff --git a/clients/ui/bff/go.mod b/clients/ui/bff/go.mod index 64204468a..110d296fb 100644 --- a/clients/ui/bff/go.mod +++ b/clients/ui/bff/go.mod @@ -1,6 +1,6 @@ module github.com/kubeflow/model-registry/ui/bff -go 1.22.2 +go 1.23.5 require ( github.com/brianvoe/gofakeit/v7 v7.1.2 @@ -12,7 +12,7 @@ require ( github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.9.0 k8s.io/api v0.31.2 - k8s.io/apimachinery v0.31.4 + k8s.io/apimachinery v0.32.1 k8s.io/client-go v0.31.2 sigs.k8s.io/controller-runtime v0.19.1 ) @@ -27,9 +27,9 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -62,18 +62,17 @@ require ( golang.org/x/sys v0.28.0 // indirect golang.org/x/term v0.27.0 // indirect golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.5.0 // indirect + golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.26.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.35.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.31.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect - k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/clients/ui/bff/go.sum b/clients/ui/bff/go.sum index 94e1cc65c..c1712caae 100644 --- a/clients/ui/bff/go.sum +++ b/clients/ui/bff/go.sum @@ -23,13 +23,14 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -151,8 +152,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -174,7 +175,6 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -184,21 +184,21 @@ k8s.io/api v0.31.2 h1:3wLBbL5Uom/8Zy98GRPXpJ254nEFpl+hwndmk9RwmL0= k8s.io/api v0.31.2/go.mod h1:bWmGvrGPssSK1ljmLzd3pwCQ9MgoTsRCuK35u6SygUk= k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk= k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk= -k8s.io/apimachinery v0.31.4 h1:8xjE2C4CzhYVm9DGf60yohpNUh5AEBnPxCryPBECmlM= -k8s.io/apimachinery v0.31.4/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apimachinery v0.32.1 h1:683ENpaCBjma4CYqsmZyhEzrGz6cjn1MY/X2jB2hkZs= +k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= k8s.io/client-go v0.31.2 h1:Y2F4dxU5d3AQj+ybwSMqQnpZH9F30//1ObxOKlTI9yc= k8s.io/client-go v0.31.2/go.mod h1:NPa74jSVR/+eez2dFsEIHNa+3o09vtNaWwWwb1qSxSs= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.19.1 h1:Son+Q40+Be3QWb+niBXAg2vFiYWolDjjRfO8hn/cxOk= sigs.k8s.io/controller-runtime v0.19.1/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/clients/ui/bff/internal/api/app.go b/clients/ui/bff/internal/api/app.go index 054434e08..1742e9a9c 100644 --- a/clients/ui/bff/internal/api/app.go +++ b/clients/ui/bff/internal/api/app.go @@ -3,13 +3,14 @@ package api import ( "context" "fmt" - "github.com/kubeflow/model-registry/ui/bff/internal/config" - "github.com/kubeflow/model-registry/ui/bff/internal/integrations" - "github.com/kubeflow/model-registry/ui/bff/internal/repositories" "log/slog" "net/http" "path" + "github.com/kubeflow/model-registry/ui/bff/internal/config" + "github.com/kubeflow/model-registry/ui/bff/internal/integrations" + "github.com/kubeflow/model-registry/ui/bff/internal/repositories" + "github.com/julienschmidt/httprouter" "github.com/kubeflow/model-registry/ui/bff/internal/mocks" ) @@ -22,6 +23,7 @@ const ( RegisteredModelId = "registered_model_id" ModelVersionId = "model_version_id" ModelArtifactId = "model_artifact_id" + ArtifactId = "artifact_id" HealthCheckPath = PathPrefix + "/healthcheck" UserPath = PathPrefix + "/user" ModelRegistryListPath = PathPrefix + "/model_registry" @@ -35,6 +37,9 @@ const ( ModelVersionArtifactListPath = ModelVersionPath + "/artifacts" ModelArtifactListPath = ModelRegistryPath + "/model_artifacts" ModelArtifactPath = ModelArtifactListPath + "/:" + ModelArtifactId + + ArtifactListPath = ModelRegistryPath + "/artifacts" + ArtifactPath = ArtifactListPath + "/:" + ArtifactId ) type App struct { @@ -105,13 +110,17 @@ func (app *App) Routes() http.Handler { apiRouter.GET(ModelVersionListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelVersionHandler)))) apiRouter.GET(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetModelVersionHandler)))) apiRouter.PATCH(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler)))) + apiRouter.GET(ArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllArtifactsHandler)))) + apiRouter.GET(ArtifactPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetArtifactHandler)))) + apiRouter.POST(ArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateArtifactHandler)))) + apiRouter.PATCH(ArtifactPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateArtifactHandler)))) apiRouter.GET(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelArtifactsByModelVersionHandler)))) apiRouter.POST(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelArtifactByModelVersionHandler)))) apiRouter.PATCH(ModelRegistryPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler)))) // Kubernetes routes apiRouter.GET(UserPath, app.UserHandler) - // Perform SAR to Get List Services by Namspace + // Perform SAR to Get List Services by Namespace apiRouter.GET(ModelRegistryListPath, app.AttachNamespace(app.PerformSARonGetListServicesByNamespace(app.ModelRegistryHandler))) if app.config.StandaloneMode { apiRouter.GET(NamespaceListPath, app.GetNamespacesHandler) diff --git a/clients/ui/bff/internal/api/artifacts_handler.go b/clients/ui/bff/internal/api/artifacts_handler.go new file mode 100644 index 000000000..5e9d54c8f --- /dev/null +++ b/clients/ui/bff/internal/api/artifacts_handler.go @@ -0,0 +1,168 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/julienschmidt/httprouter" + "github.com/kubeflow/model-registry/pkg/openapi" + "github.com/kubeflow/model-registry/ui/bff/internal/constants" + "github.com/kubeflow/model-registry/ui/bff/internal/integrations" +) + +type ArtifactListEnvelope Envelope[*openapi.ArtifactList, None] +type ArtifactEnvelope Envelope[*openapi.Artifact, None] +type ArtifactUpdateEnvelope Envelope[*openapi.ArtifactUpdate, None] + +func (app *App) CreateArtifactHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + client, ok := r.Context().Value(constants.ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) + if !ok { + app.serverErrorResponse(w, r, errors.New("REST client not found")) + return + } + + var envelope ArtifactEnvelope + if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil { + app.serverErrorResponse(w, r, fmt.Errorf("error decoding JSON:: %v", err.Error())) + return + } + + data := *envelope.Data + + jsonData, err := json.Marshal(data) + if err != nil { + app.serverErrorResponse(w, r, fmt.Errorf("error marshaling model to JSON: %w", err)) + return + } + + createdArtifact, err := app.repositories.ModelRegistryClient.CreateArtifact(client, jsonData) + if err != nil { + var httpErr *integrations.HTTPError + if errors.As(err, &httpErr) { + app.errorResponse(w, r, httpErr) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + + if createdArtifact == nil || (createdArtifact.DocArtifact == nil && createdArtifact.ModelArtifact == nil) { + app.serverErrorResponse(w, r, fmt.Errorf("created artifact is nil or does not contain valid data")) + return + } + + response := ArtifactEnvelope{ + Data: createdArtifact, + } + + if createdArtifact.DocArtifact != nil && createdArtifact.DocArtifact.Id != nil { + w.Header().Set("Location", r.URL.JoinPath(*createdArtifact.DocArtifact.Id).String()) + } else if createdArtifact.ModelArtifact != nil && createdArtifact.ModelArtifact.Id != nil { + w.Header().Set("Location", r.URL.JoinPath(*createdArtifact.ModelArtifact.Id).String()) + } else { + app.serverErrorResponse(w, r, fmt.Errorf("artifact ID is missing")) + return + } + + err = app.WriteJSON(w, http.StatusCreated, response, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *App) GetArtifactHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + client, ok := r.Context().Value(constants.ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) + if !ok { + app.serverErrorResponse(w, r, errors.New("REST client not found")) + return + } + + model, err := app.repositories.ModelRegistryClient.GetArtifact(client, ps.ByName(ArtifactId)) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + result := ArtifactEnvelope{ + Data: model, + } + + err = app.WriteJSON(w, http.StatusOK, result, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *App) GetAllArtifactsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + client, ok := r.Context().Value(constants.ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) + + if !ok { + app.serverErrorResponse(w, r, errors.New("REST client not found")) + return + } + + artifactList, err := app.repositories.ModelRegistryClient.GetAllArtifacts(client, r.URL.Query()) + + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + artifactsRes := ArtifactListEnvelope{ + Data: artifactList, + } + + err = app.WriteJSON(w, http.StatusOK, artifactsRes, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *App) UpdateArtifactHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + client, ok := r.Context().Value(constants.ModelRegistryHttpClientKey).(integrations.HTTPClientInterface) + if !ok { + app.serverErrorResponse(w, r, errors.New("REST client not found")) + return + } + + var envelope ArtifactUpdateEnvelope + if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil { + app.serverErrorResponse(w, r, fmt.Errorf("error decoding JSON:: %v", err.Error())) + return + } + + data := *envelope.Data + + jsonData, err := json.Marshal(data) + if err != nil { + app.serverErrorResponse(w, r, fmt.Errorf("error marshaling model to JSON: %w", err)) + return + } + + patchedArtifact, err := app.repositories.ModelRegistryClient.UpdateArtifact(client, ps.ByName(ArtifactId), jsonData) + if err != nil { + var httpErr *integrations.HTTPError + if errors.As(err, &httpErr) { + app.errorResponse(w, r, httpErr) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + + if patchedArtifact == nil || (patchedArtifact.DocArtifact == nil && patchedArtifact.ModelArtifact == nil) { + app.serverErrorResponse(w, r, fmt.Errorf("created artifact is nil or does not contain valid data")) + return + } + + responseBody := ArtifactEnvelope{ + Data: patchedArtifact, + } + + err = app.WriteJSON(w, http.StatusOK, responseBody, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/clients/ui/bff/internal/api/artifacts_handler_test.go b/clients/ui/bff/internal/api/artifacts_handler_test.go new file mode 100644 index 000000000..09dae9c94 --- /dev/null +++ b/clients/ui/bff/internal/api/artifacts_handler_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "net/http" + + "github.com/brianvoe/gofakeit/v7" + "github.com/kubeflow/model-registry/ui/bff/internal/mocks" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TestArtifactsHandler", func() { + Context("testing artifacts", Ordered, func() { + Context("successful operations", func() { + It("should retrieve an artifact", func() { + By("fetching a specific artifact") + _ = gofakeit.Seed(123) + data := mocks.GenerateMockArtifact() + expected := ArtifactEnvelope{Data: &data} + + _ = gofakeit.Seed(123) + actual, rs, err := setupApiTest[ArtifactEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/artifacts/1?namespace=kubeflow", nil, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") + Expect(err).NotTo(HaveOccurred()) + + By("should match the expected artifact") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) + Expect(actual.Data.ModelArtifact.GetName()).To(Equal(expected.Data.ModelArtifact.GetName())) + Expect(actual.Data.ModelArtifact.GetArtifactType()).To(Equal(expected.Data.ModelArtifact.GetArtifactType())) + Expect(actual.Data.ModelArtifact.GetDescription()).To(Equal(expected.Data.ModelArtifact.GetDescription())) + }) + + It("should list all artifacts", func() { + By("fetching all artifacts") + _ = gofakeit.Seed(123) + + actual, rs, err := setupApiTest[ArtifactListEnvelope](http.MethodGet, "/api/v1/model_registry/model-registry/artifacts?namespace=kubeflow", nil, k8sClient, mocks.KubeflowUserIDHeaderValue, "kubeflow") + Expect(err).NotTo(HaveOccurred()) + + By("should return success status and valid data") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) + Expect(actual.Data).NotTo(BeNil()) + Expect(actual.Data.Items).NotTo(BeEmpty()) + + By("should contain valid artifacts") + for _, item := range actual.Data.Items { + Expect(*item.ModelArtifact.Name).NotTo(BeEmpty()) + Expect(item.ModelArtifact.ArtifactType).NotTo(BeEmpty()) + } + }) + }) + }) +}) diff --git a/clients/ui/bff/internal/api/suite_test.go b/clients/ui/bff/internal/api/suite_test.go index a81975935..d24c88413 100644 --- a/clients/ui/bff/internal/api/suite_test.go +++ b/clients/ui/bff/internal/api/suite_test.go @@ -2,17 +2,17 @@ package api import ( "context" - k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations" - "github.com/kubeflow/model-registry/ui/bff/internal/mocks" "log/slog" "os" + "testing" + + k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations" + "github.com/kubeflow/model-registry/ui/bff/internal/mocks" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" - "testing" -) -import ( . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) diff --git a/clients/ui/bff/internal/api/test_utils.go b/clients/ui/bff/internal/api/test_utils.go index 1f8f0cc93..b24ef4493 100644 --- a/clients/ui/bff/internal/api/test_utils.go +++ b/clients/ui/bff/internal/api/test_utils.go @@ -4,15 +4,16 @@ import ( "bytes" "context" "encoding/json" - "github.com/kubeflow/model-registry/ui/bff/internal/constants" - k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations" - "github.com/kubeflow/model-registry/ui/bff/internal/mocks" - "github.com/kubeflow/model-registry/ui/bff/internal/repositories" "io" "net/http" "net/http/httptest" "os" "path/filepath" + + "github.com/kubeflow/model-registry/ui/bff/internal/constants" + k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations" + "github.com/kubeflow/model-registry/ui/bff/internal/mocks" + "github.com/kubeflow/model-registry/ui/bff/internal/repositories" ) func setupApiTest[T any](method string, url string, body interface{}, k8sClient k8s.KubernetesClientInterface, kubeflowUserIDHeaderValue string, namespace string) (T, *http.Response, error) { diff --git a/clients/ui/bff/internal/mocks/k8s_mock.go b/clients/ui/bff/internal/mocks/k8s_mock.go index 7a74e65df..0cb014446 100644 --- a/clients/ui/bff/internal/mocks/k8s_mock.go +++ b/clients/ui/bff/internal/mocks/k8s_mock.go @@ -3,16 +3,17 @@ package mocks import ( "context" "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" - "log/slog" - "os" - "path/filepath" - "runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" ) @@ -274,9 +275,6 @@ func createService(k8sClient client.Client, ctx context.Context, name string, na return fmt.Errorf("failed to list services: %w", err) } - if err != nil { - return err - } return nil } diff --git a/clients/ui/bff/internal/mocks/model_registry_client_mock.go b/clients/ui/bff/internal/mocks/model_registry_client_mock.go index d9fcfd48a..714b71824 100644 --- a/clients/ui/bff/internal/mocks/model_registry_client_mock.go +++ b/clients/ui/bff/internal/mocks/model_registry_client_mock.go @@ -85,3 +85,23 @@ func (m *ModelRegistryClientMock) CreateModelArtifactByModelVersion(_ integratio mockData := GetModelArtifactMocks()[0] return &mockData, nil } + +func (m *ModelRegistryClientMock) GetAllArtifacts(_ integrations.HTTPClientInterface, _ url.Values) (*openapi.ArtifactList, error) { + mockData := GenerateMockArtifactList() + return &mockData, nil +} + +func (m *ModelRegistryClientMock) GetArtifact(_ integrations.HTTPClientInterface, _ string) (*openapi.Artifact, error) { + mockData := GenerateMockArtifact() + return &mockData, nil +} + +func (m *ModelRegistryClientMock) CreateArtifact(_ integrations.HTTPClientInterface, _ []byte) (*openapi.Artifact, error) { + mockData := GenerateMockArtifact() + return &mockData, nil +} + +func (m *ModelRegistryClientMock) UpdateArtifact(_ integrations.HTTPClientInterface, _ string, _ []byte) (*openapi.Artifact, error) { + mockData := GenerateMockArtifact() + return &mockData, nil +} diff --git a/clients/ui/bff/internal/mocks/static_data_mock.go b/clients/ui/bff/internal/mocks/static_data_mock.go index 252a285e8..92a24c6f2 100644 --- a/clients/ui/bff/internal/mocks/static_data_mock.go +++ b/clients/ui/bff/internal/mocks/static_data_mock.go @@ -2,11 +2,13 @@ package mocks import ( "context" + "log/slog" + "os" + + "github.com/brianvoe/gofakeit/v7" "github.com/google/uuid" "github.com/kubeflow/model-registry/pkg/openapi" "github.com/kubeflow/model-registry/ui/bff/internal/constants" - "log/slog" - "os" ) func GetRegisteredModelMocks() []openapi.RegisteredModel { @@ -221,3 +223,27 @@ func NewMockSessionContext(parent context.Context) context.Context { func NewMockSessionContextNoParent() context.Context { return NewMockSessionContext(context.TODO()) } + +func GenerateMockArtifactList() openapi.ArtifactList { + var artifacts []openapi.Artifact + for i := 0; i < 2; i++ { + artifact := GenerateMockArtifact() + artifacts = append(artifacts, artifact) + } + + return openapi.ArtifactList{ + NextPageToken: gofakeit.UUID(), + PageSize: int32(gofakeit.Number(1, 20)), + Size: int32(len(artifacts)), + Items: artifacts, + } +} + +func GenerateMockArtifact() openapi.Artifact { + modelArtifact := GenerateMockModelArtifact() + + mockData := openapi.Artifact{ + ModelArtifact: &modelArtifact, + } + return mockData +} diff --git a/clients/ui/bff/internal/mocks/types_mock.go b/clients/ui/bff/internal/mocks/types_mock.go index 096d3dfe0..c96a034c3 100644 --- a/clients/ui/bff/internal/mocks/types_mock.go +++ b/clients/ui/bff/internal/mocks/types_mock.go @@ -2,10 +2,11 @@ package mocks import ( "fmt" - "github.com/brianvoe/gofakeit/v7" - "github.com/kubeflow/model-registry/pkg/openapi" "net/url" "strconv" + + "github.com/brianvoe/gofakeit/v7" + "github.com/kubeflow/model-registry/pkg/openapi" ) func GenerateMockRegisteredModelList() openapi.RegisteredModelList { @@ -85,7 +86,7 @@ func GenerateMockModelVersionList() openapi.ModelVersionList { func GenerateMockModelArtifact() openapi.ModelArtifact { artifact := openapi.ModelArtifact{ - ArtifactType: gofakeit.Word(), + ArtifactType: "model-artifact", CustomProperties: &map[string]openapi.MetadataValue{ "example_key": { MetadataStringValue: &openapi.MetadataStringValue{ diff --git a/clients/ui/bff/internal/repositories/artifacts.go b/clients/ui/bff/internal/repositories/artifacts.go new file mode 100644 index 000000000..7069427f3 --- /dev/null +++ b/clients/ui/bff/internal/repositories/artifacts.go @@ -0,0 +1,91 @@ +package repositories + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + + "github.com/kubeflow/model-registry/pkg/openapi" + "github.com/kubeflow/model-registry/ui/bff/internal/integrations" +) + +const artifactPath = "/artifacts" + +type ArtifactInterface interface { + GetAllArtifacts(client integrations.HTTPClientInterface, pageValues url.Values) (*openapi.ArtifactList, error) + GetArtifact(client integrations.HTTPClientInterface, id string) (*openapi.Artifact, error) + CreateArtifact(client integrations.HTTPClientInterface, jsonData []byte) (*openapi.Artifact, error) + UpdateArtifact(client integrations.HTTPClientInterface, id string, jsonData []byte) (*openapi.Artifact, error) +} + +type Artifact struct { + ArtifactInterface +} + +func (a Artifact) GetAllArtifacts(client integrations.HTTPClientInterface, pageValues url.Values) (*openapi.ArtifactList, error) { + responseData, err := client.GET(UrlWithPageParams(artifactPath, pageValues)) + if err != nil { + return nil, fmt.Errorf("error fetching artifacts: %w", err) + } + + var artifacts openapi.ArtifactList + if err := json.Unmarshal(responseData, &artifacts); err != nil { + return nil, fmt.Errorf("error decoding response data: %w", err) + } + + return &artifacts, nil +} + +func (a Artifact) GetArtifact(client integrations.HTTPClientInterface, id string) (*openapi.Artifact, error) { + path, err := url.JoinPath(artifactPath, id) + if err != nil { + return nil, err + } + + responseData, err := client.GET(path) + if err != nil { + return nil, fmt.Errorf("error fetching artifacts: %w", err) + } + + var artifact openapi.Artifact + if err := json.Unmarshal(responseData, &artifact); err != nil { + return nil, fmt.Errorf("error decoding response data: %w", err) + } + + return &artifact, nil +} + +func (a Artifact) CreateArtifact(client integrations.HTTPClientInterface, jsonData []byte) (*openapi.Artifact, error) { + responseData, err := client.POST(artifactPath, bytes.NewBuffer(jsonData)) + + if err != nil { + return nil, fmt.Errorf("error creating artifact: %w", err) + } + + var artifact openapi.Artifact + if err := json.Unmarshal(responseData, &artifact); err != nil { + return nil, fmt.Errorf("error decoding response data: %w", err) + } + + return &artifact, nil +} + +func (a Artifact) UpdateArtifact(client integrations.HTTPClientInterface, id string, jsonData []byte) (*openapi.Artifact, error) { + path, err := url.JoinPath(artifactPath, id) + if err != nil { + return nil, err + } + + responseData, err := client.PATCH(path, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("error patching registered model: %w", err) + } + + var artifact openapi.Artifact + if err := json.Unmarshal(responseData, &artifact); err != nil { + return nil, fmt.Errorf("error decoding response data: %w", err) + } + + return &artifact, nil +} diff --git a/clients/ui/bff/internal/repositories/model_registry_client.go b/clients/ui/bff/internal/repositories/model_registry_client.go index fe1a0158b..775dafbc5 100644 --- a/clients/ui/bff/internal/repositories/model_registry_client.go +++ b/clients/ui/bff/internal/repositories/model_registry_client.go @@ -7,12 +7,14 @@ import ( type ModelRegistryClientInterface interface { RegisteredModelInterface ModelVersionInterface + ArtifactInterface } type ModelRegistryClient struct { logger *slog.Logger RegisteredModel ModelVersion + Artifact } func NewModelRegistryClient(logger *slog.Logger) (ModelRegistryClientInterface, error) { diff --git a/clients/ui/docs/local-deployment-guide-ui.md b/clients/ui/docs/local-deployment-guide-ui.md index 10e9298a9..570454e85 100644 --- a/clients/ui/docs/local-deployment-guide-ui.md +++ b/clients/ui/docs/local-deployment-guide-ui.md @@ -26,15 +26,18 @@ kubectl create namespace kubeflow ### 3. Deploy Model Registry UI to cluster -You can now deploy the UI and BFF to your newly created cluster using the kustomize configs in this directory: - +You can now deploy the UI and BFF to your newly created cluster using the kustomize configs in the root manifest directory: ```shell -cd clients/ui - -kubectl apply -k manifests/overlay/standalone -n kubeflow +cd manifests/kustomize/options/ui/overlays/standalone +``` +```shell +kustomize edit set namespace kubeflow +``` +```shell +kubectl apply -k . ``` -After a few seconds you should see 2 pods running (1 for BFF and 1 for UI): +After a few seconds you should see 1 pod running: ```shell kubectl get pods -n kubeflow @@ -95,5 +98,5 @@ To fix this, you'll need to increase the amount of memory available to the VM. T Alternatively, if you'd like to run the UI and BFF pods with an Istio configuration for the KF Central Dashboard, you can apply the manifests by running: ```shell -kubectl apply -k overlays/istio -n kubeflow +kubectl apply -k manifests/kustomize/options/ui/overlays/istio -n kubeflow ``` diff --git a/clients/ui/frontend/.env b/clients/ui/frontend/.env index cf90978c5..7ff436e10 100644 --- a/clients/ui/frontend/.env +++ b/clients/ui/frontend/.env @@ -7,4 +7,5 @@ LOGO_DARK=logo-dark-theme.svg FAVICON=favicon.ico PRODUCT_NAME="Model Registry" STYLE_THEME=mui-theme +PLATFORM_MODE=kubeflow diff --git a/clients/ui/frontend/package-lock.json b/clients/ui/frontend/package-lock.json index 99e3eb0bc..ea762de2a 100644 --- a/clients/ui/frontend/package-lock.json +++ b/clients/ui/frontend/package-lock.json @@ -32,7 +32,7 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.5", "@cypress/code-coverage": "^3.13.8", - "@mui/icons-material": "^6.1.10", + "@mui/icons-material": "^6.4.1", "@mui/material": "^6.1.7", "@mui/types": "^7.2.20", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", @@ -41,7 +41,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.1.0", - "@testing-library/user-event": "14.5.2", + "@testing-library/user-event": "14.6.1", "@types/classnames": "^2.3.1", "@types/dompurify": "^3.2.0", "@types/jest": "^29.5.13", @@ -76,7 +76,7 @@ "prop-types": "^15.8.1", "raw-loader": "^4.0.2", "react-refresh": "^0.14.2", - "react-router-dom": "^7.1.1", + "react-router-dom": "^7.1.3", "regenerator-runtime": "^0.14.1", "sass": "^1.83.0", "sass-loader": "^13.2.0", @@ -102,8 +102,8 @@ "node": ">=20.0.0" }, "optionalDependencies": { - "@typescript-eslint/eslint-plugin": "^8.17.0", - "@typescript-eslint/parser": "^8.17.0", + "@typescript-eslint/eslint-plugin": "^8.20.0", + "@typescript-eslint/parser": "^8.20.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-node": "^0.3.7", @@ -115,7 +115,7 @@ "eslint-plugin-no-relative-import-paths": "^1.5.2", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-react": "^7.37.2", - "eslint-plugin-react-hooks": "^5.0.0" + "eslint-plugin-react-hooks": "^5.1.0" } }, "node_modules/@adobe/css-tools": { @@ -3031,20 +3031,22 @@ "license": "MIT" }, "node_modules/@mui/core-downloads-tracker": { - "version": "6.1.10", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.10.tgz", - "integrity": "sha512-LY5wdiLCBDY7u+Od8UmFINZFGN/5ZU90fhAslf/ZtfP+5RhuY45f679pqYIxe0y54l6Gkv9PFOc8Cs10LDTBYg==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.1.tgz", + "integrity": "sha512-SfDLWMV5b5oXgDf3NTa2hCTPC1d2defhDH2WgFKmAiejC4mSfXYbyi+AFCLzpizauXhgBm8OaZy9BHKnrSpahQ==", "dev": true, + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/icons-material": { - "version": "6.1.10", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.1.10.tgz", - "integrity": "sha512-G6P1BCSt6EQDcKca47KwvKjlqgOXFbp2I3oWiOlFgKYTANBH89yk7ttMQ5ysqNxSYAB+4TdM37MlPYp4+FkVrQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.1.tgz", + "integrity": "sha512-wsxFcUTQxt4s+7Bg4GgobqRjyaHLmZGNOs+HJpbwrwmLbT6mhIJxhpqsKzzWq9aDY8xIe7HCjhpH7XI5UD6teA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0" }, @@ -3056,7 +3058,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^6.1.10", + "@mui/material": "^6.4.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -3067,22 +3069,23 @@ } }, "node_modules/@mui/material": { - "version": "6.1.10", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.10.tgz", - "integrity": "sha512-txnwYObY4N9ugv5T2n5h1KcbISegZ6l65w1/7tpSU5OB6MQCU94YkP8n/3slDw2KcEfRk4+4D8EUGfhSPMODEQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.1.tgz", + "integrity": "sha512-MFBfia6UiKxyoLeGkAh8M15bkeDmfnsUTMRJd/vTQue6YQ8AQ6lw9HqDthyYghzDEWIvZO/lQQzLrZE8XwNJLA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/core-downloads-tracker": "^6.1.10", - "@mui/system": "^6.1.10", - "@mui/types": "^7.2.19", - "@mui/utils": "^6.1.10", + "@mui/core-downloads-tracker": "^6.4.1", + "@mui/system": "^6.4.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.1", "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.11", + "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", - "react-is": "^18.3.1", + "react-is": "^19.0.0", "react-transition-group": "^4.4.5" }, "engines": { @@ -3095,7 +3098,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.1.10", + "@mui/material-pigment-css": "^6.4.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -3115,14 +3118,22 @@ } } }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "dev": true, + "license": "MIT" + }, "node_modules/@mui/private-theming": { - "version": "6.1.10", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.10.tgz", - "integrity": "sha512-DqgsH0XFEweeG3rQfVkqTkeXcj/E76PGYWag8flbPdV8IYdMo+DfVdFlZK8JEjsaIVD2Eu1kJg972XnH5pfnBQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.1.tgz", + "integrity": "sha512-DcT7mwK89owwgcEuiE7w458te4CIjHbYWW6Kn6PiR6eLtxBsoBYphA968uqsQAOBQDpbYxvkuFLwhgk4bxoN/Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/utils": "^6.1.10", + "@mui/utils": "^6.4.1", "prop-types": "^15.8.1" }, "engines": { @@ -3143,10 +3154,11 @@ } }, "node_modules/@mui/styled-engine": { - "version": "6.1.10", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.10.tgz", - "integrity": "sha512-+NV9adKZYhslJ270iPjf2yzdVJwav7CIaXcMlPSi1Xy1S/zRe5xFgZ6BEoMdmGRpr34lIahE8H1acXP2myrvRw==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.0.tgz", + "integrity": "sha512-ek/ZrDujrger12P6o4luQIfRd2IziH7jQod2WMbLqGE03Iy0zUwYmckRTVhRQTLPNccpD8KXGcALJF+uaUQlbg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", "@emotion/cache": "^11.13.5", @@ -3177,16 +3189,17 @@ } }, "node_modules/@mui/system": { - "version": "6.1.10", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.10.tgz", - "integrity": "sha512-5YNIqxETR23SIkyP7MY2fFnXmplX/M4wNi2R+10AVRd3Ub+NLctWY/Vs5vq1oAMF0eSDLhRTGUjaUe+IGSfWqg==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.1.tgz", + "integrity": "sha512-rgQzgcsHCTtzF9MZ+sL0tOhf2ZBLazpjrujClcb4Siju5lTrK0xX4PsiropActzCemNfM+mOu+0jezAVnfRK8g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/private-theming": "^6.1.10", - "@mui/styled-engine": "^6.1.10", - "@mui/types": "^7.2.19", - "@mui/utils": "^6.1.10", + "@mui/private-theming": "^6.4.1", + "@mui/styled-engine": "^6.4.0", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.1", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -3217,10 +3230,11 @@ } }, "node_modules/@mui/types": { - "version": "7.2.20", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.20.tgz", - "integrity": "sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw==", + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", "dev": true, + "license": "MIT", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -3231,17 +3245,18 @@ } }, "node_modules/@mui/utils": { - "version": "6.1.10", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.10.tgz", - "integrity": "sha512-1ETuwswGjUiAf2dP9TkBy8p49qrw2wXa+RuAjNTRE5+91vtXJ1HKrs7H9s8CZd1zDlQVzUcUAPm9lpQwF5ogTw==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.1.tgz", + "integrity": "sha512-iQUDUeYh87SvR4lVojaRaYnQix8BbRV51MxaV6MBmqthecQoxwSbS5e2wnbDJUeFxY2ppV505CiqPLtd0OWkqw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.19", - "@types/prop-types": "^15.7.13", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^18.3.1" + "react-is": "^19.0.0" }, "engines": { "node": ">=14.0.0" @@ -3260,6 +3275,13 @@ } } }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "dev": true, + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4266,9 +4288,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.5.2", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", - "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", "engines": { @@ -4679,9 +4701,9 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.13", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "dev": true, "license": "MIT" }, @@ -4744,12 +4766,12 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", - "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "dev": true, "license": "MIT", - "dependencies": { + "peerDependencies": { "@types/react": "*" } }, @@ -4883,21 +4905,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.0.tgz", - "integrity": "sha512-NR2yS7qUqCL7AIxdJUQf2MKKNDVNaig/dEB0GBLU7D+ZdHgK1NoH/3wsgO3OnPVipn51tG3MAwaODEGil70WEw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.21.0.tgz", + "integrity": "sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==", "license": "MIT", "optional": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.18.0", - "@typescript-eslint/type-utils": "8.18.0", - "@typescript-eslint/utils": "8.18.0", - "@typescript-eslint/visitor-keys": "8.18.0", + "@typescript-eslint/scope-manager": "8.21.0", + "@typescript-eslint/type-utils": "8.21.0", + "@typescript-eslint/utils": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4913,16 +4935,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.0.tgz", - "integrity": "sha512-hgUZ3kTEpVzKaK3uNibExUYm6SKKOmTU2BOxBSvOYwtJEPdVQ70kZJpPjstlnhCHcuc2WGfSbpKlb/69ttyN5Q==", - "license": "MITClause", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.21.0.tgz", + "integrity": "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==", + "license": "MIT", "optional": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.18.0", - "@typescript-eslint/types": "8.18.0", - "@typescript-eslint/typescript-estree": "8.18.0", - "@typescript-eslint/visitor-keys": "8.18.0", + "@typescript-eslint/scope-manager": "8.21.0", + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/typescript-estree": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", "debug": "^4.3.4" }, "engines": { @@ -4938,14 +4960,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.0.tgz", - "integrity": "sha512-PNGcHop0jkK2WVYGotk/hxj+UFLhXtGPiGtiaWgVBVP1jhMoMCHlTyJA+hEj4rszoSdLTK3fN4oOatrL0Cp+Xw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", + "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", "license": "MIT", "optional": true, "dependencies": { - "@typescript-eslint/types": "8.18.0", - "@typescript-eslint/visitor-keys": "8.18.0" + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4956,16 +4978,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.0.tgz", - "integrity": "sha512-er224jRepVAVLnMF2Q7MZJCq5CsdH2oqjP4dT7K6ij09Kyd+R21r7UVJrF0buMVdZS5QRhDzpvzAxHxabQadow==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.21.0.tgz", + "integrity": "sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ==", "license": "MIT", "optional": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.18.0", - "@typescript-eslint/utils": "8.18.0", + "@typescript-eslint/typescript-estree": "8.21.0", + "@typescript-eslint/utils": "8.21.0", "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4980,9 +5002,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.0.tgz", - "integrity": "sha512-FNYxgyTCAnFwTrzpBGq+zrnoTO4x0c1CKYY5MuUTzpScqmY5fmsh2o3+57lqdI3NZucBDCzDgdEbIaNfAjAHQA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", + "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", "license": "MIT", "optional": true, "engines": { @@ -4994,20 +5016,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.0.tgz", - "integrity": "sha512-rqQgFRu6yPkauz+ms3nQpohwejS8bvgbPyIDq13cgEDbkXt4LH4OkDMT0/fN1RUtzG8e8AKJyDBoocuQh8qNeg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz", + "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==", "license": "MIT", "optional": true, "dependencies": { - "@typescript-eslint/types": "8.18.0", - "@typescript-eslint/visitor-keys": "8.18.0", + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5034,16 +5056,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.0.tgz", - "integrity": "sha512-p6GLdY383i7h5b0Qrfbix3Vc3+J2k6QWw6UMUeY5JGfm3C5LbZ4QIZzJNoNOfgyRe0uuYKjvVOsO/jD4SJO+xg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.21.0.tgz", + "integrity": "sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==", "license": "MIT", "optional": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.18.0", - "@typescript-eslint/types": "8.18.0", - "@typescript-eslint/typescript-estree": "8.18.0" + "@typescript-eslint/scope-manager": "8.21.0", + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/typescript-estree": "8.21.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5058,13 +5080,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.0.tgz", - "integrity": "sha512-pCh/qEA8Lb1wVIqNvBke8UaRjJ6wrAWkJO5yyIbs8Yx6TNGYyfNjOo61tLv+WwLvoLPp4BQ8B7AHKijl8NGUfw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", + "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", "license": "MIT", "optional": true, "dependencies": { - "@typescript-eslint/types": "8.18.0", + "@typescript-eslint/types": "8.21.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -7057,6 +7079,7 @@ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -9418,9 +9441,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz", - "integrity": "sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", + "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", "license": "MIT", "optional": true, "engines": { @@ -17238,9 +17261,9 @@ } }, "node_modules/react-router": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.1.tgz", - "integrity": "sha512-39sXJkftkKWRZ2oJtHhCxmoCrBCULr/HAH4IT5DHlgu/Q0FCPV0S4Lx+abjDTx/74xoZzNYDYbOZWlJjruyuDQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.3.tgz", + "integrity": "sha512-EezYymLY6Guk/zLQ2vRA8WvdUhWFEj5fcE3RfWihhxXBW7+cd1LsIiA3lmx+KCmneAGQuyBv820o44L2+TtkSA==", "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^1.0.1", @@ -17261,12 +17284,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.1.tgz", - "integrity": "sha512-vSrQHWlJ5DCfyrhgo0k6zViOe9ToK8uT5XGSmnuC2R3/g261IdIMpZVqfjD6vWSXdnf5Czs4VA/V60oVR6/jnA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.3.tgz", + "integrity": "sha512-qQGTE+77hleBzv9SIUIkGRvuFBQGagW+TQKy53UTZAO/3+YFNBYvRsNIZ1GT17yHbc63FylMOdS+m3oUriF1GA==", "dev": true, "dependencies": { - "react-router": "7.1.1" + "react-router": "7.1.3" }, "engines": { "node": ">=20.0.0" @@ -19705,16 +19728,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", - "integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", + "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", "license": "MIT", "optional": true, "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-jest": { diff --git a/clients/ui/frontend/package.json b/clients/ui/frontend/package.json index bc5bd2e1c..ed269a898 100644 --- a/clients/ui/frontend/package.json +++ b/clients/ui/frontend/package.json @@ -38,13 +38,13 @@ "@babel/preset-typescript": "^7.21.5", "@cypress/code-coverage": "^3.13.8", "@mui/material": "^6.1.7", - "@mui/icons-material": "^6.1.10", + "@mui/icons-material": "^6.4.1", "@mui/types": "^7.2.20", "@testing-library/cypress": "^10.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.1.0", - "@testing-library/user-event": "14.5.2", + "@testing-library/user-event": "14.6.1", "@types/classnames": "^2.3.1", "@types/dompurify": "^3.2.0", "@types/jest": "^29.5.13", @@ -79,7 +79,7 @@ "prop-types": "^15.8.1", "raw-loader": "^4.0.2", "react-refresh": "^0.14.2", - "react-router-dom": "^7.1.1", + "react-router-dom": "^7.1.3", "regenerator-runtime": "^0.14.1", "sass": "^1.83.0", "sass-loader": "^13.2.0", @@ -121,8 +121,8 @@ "classnames": "^2.2.6" }, "optionalDependencies": { - "@typescript-eslint/eslint-plugin": "^8.17.0", - "@typescript-eslint/parser": "^8.17.0", + "@typescript-eslint/eslint-plugin": "^8.20.0", + "@typescript-eslint/parser": "^8.20.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-node": "^0.3.7", @@ -134,7 +134,7 @@ "eslint-plugin-no-relative-import-paths": "^1.5.2", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-react": "^7.37.2", - "eslint-plugin-react-hooks": "^5.0.0" + "eslint-plugin-react-hooks": "^5.1.0" }, "overrides": { "serve": { diff --git a/clients/ui/frontend/src/__mocks__/mockModelRegistry.ts b/clients/ui/frontend/src/__mocks__/mockModelRegistry.ts index 56fed2e3f..ade07d64f 100644 --- a/clients/ui/frontend/src/__mocks__/mockModelRegistry.ts +++ b/clients/ui/frontend/src/__mocks__/mockModelRegistry.ts @@ -8,7 +8,7 @@ type MockModelRegistry = { export const mockModelRegistry = ({ name = 'modelregistry-sample', - description = 'New model registry', + description = 'Model registry description', displayName = 'Model Registry Sample', }: MockModelRegistry): ModelRegistry => ({ name, diff --git a/clients/ui/frontend/src/__mocks__/mockModelVersion.ts b/clients/ui/frontend/src/__mocks__/mockModelVersion.ts index 80a6f3107..018b12f01 100644 --- a/clients/ui/frontend/src/__mocks__/mockModelVersion.ts +++ b/clients/ui/frontend/src/__mocks__/mockModelVersion.ts @@ -1,23 +1,22 @@ -import { ModelVersion, ModelState } from '~/app/types'; -import { createModelRegistryLabelsObject } from './utils'; +import { ModelVersion, ModelState, ModelRegistryCustomProperties } from '~/app/types'; type MockModelVersionType = { author?: string; id?: string; registeredModelId?: string; name?: string; - labels?: string[]; state?: ModelState; description?: string; createTimeSinceEpoch?: string; lastUpdateTimeSinceEpoch?: string; + customProperties?: ModelRegistryCustomProperties; }; export const mockModelVersion = ({ author = 'Test author', registeredModelId = '1', name = 'new model version', - labels = [], + customProperties = {}, id = '1', state = ModelState.LIVE, description = 'Description of model version', @@ -26,7 +25,7 @@ export const mockModelVersion = ({ }: MockModelVersionType): ModelVersion => ({ author, createTimeSinceEpoch, - customProperties: createModelRegistryLabelsObject(labels), + customProperties, id, lastUpdateTimeSinceEpoch, name, diff --git a/clients/ui/frontend/src/__mocks__/mockRegisteredModel.ts b/clients/ui/frontend/src/__mocks__/mockRegisteredModel.ts index 7c45fbe57..61886e311 100644 --- a/clients/ui/frontend/src/__mocks__/mockRegisteredModel.ts +++ b/clients/ui/frontend/src/__mocks__/mockRegisteredModel.ts @@ -1,5 +1,4 @@ -import { ModelState, RegisteredModel } from '~/app/types'; -import { createModelRegistryLabelsObject } from './utils'; +import { ModelRegistryCustomProperties, ModelState, RegisteredModel } from '~/app/types'; type MockRegisteredModelType = { id?: string; @@ -7,7 +6,7 @@ type MockRegisteredModelType = { owner?: string; state?: ModelState; description?: string; - labels?: string[]; + customProperties?: ModelRegistryCustomProperties; }; export const mockRegisteredModel = ({ @@ -15,7 +14,7 @@ export const mockRegisteredModel = ({ owner = 'Author 1', state = ModelState.LIVE, description = '', - labels = [], + customProperties = {}, id = '1', }: MockRegisteredModelType): RegisteredModel => ({ createTimeSinceEpoch: '1710404288975', @@ -26,5 +25,5 @@ export const mockRegisteredModel = ({ name, state, owner, - customProperties: createModelRegistryLabelsObject(labels), + customProperties, }); diff --git a/clients/ui/frontend/src/__mocks__/mockRegisteredModelsList.ts b/clients/ui/frontend/src/__mocks__/mockRegisteredModelsList.ts index ddb525afe..1695c410d 100644 --- a/clients/ui/frontend/src/__mocks__/mockRegisteredModelsList.ts +++ b/clients/ui/frontend/src/__mocks__/mockRegisteredModelsList.ts @@ -1,4 +1,4 @@ -import { RegisteredModelList } from '~/app/types'; +import { ModelRegistryMetadataType, RegisteredModelList } from '~/app/types'; import { mockRegisteredModel } from './mockRegisteredModel'; export const mockRegisteredModelList = ({ @@ -10,39 +10,35 @@ export const mockRegisteredModelList = ({ name: 'Fraud detection model', description: 'A machine learning model trained to detect fraudulent transactions in financial data', - labels: [ - 'Financial data', - 'Fraud detection', - 'Test label', - 'Machine learning', - 'Next data to be overflow', - ], + customProperties: { + 'Financial data': { + metadataType: ModelRegistryMetadataType.STRING, + // eslint-disable-next-line camelcase + string_value: '', + }, + }, }), mockRegisteredModel({ name: 'Credit Scoring', - labels: [ - 'Credit Score Predictor', - 'Creditworthiness scoring system', - 'Default Risk Analyzer', - 'Portfolio Management', - 'Risk Assessment', - ], + customProperties: { + 'Credit Score Predictor': { + metadataType: ModelRegistryMetadataType.STRING, + // eslint-disable-next-line camelcase + string_value: '', + }, + }, }), mockRegisteredModel({ name: 'Label modal', description: 'A machine learning model trained to detect fraudulent transactions in financial data', - labels: [ - 'Testing label', - 'Financial data', - 'Fraud detection', - 'Long label data to be truncated abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc', - 'Machine learning', - 'Next data to be overflow', - 'Label x', - 'Label y', - 'Label z', - ], + customProperties: { + 'Testing label': { + metadataType: ModelRegistryMetadataType.STRING, + // eslint-disable-next-line camelcase + string_value: '', + }, + }, }), ], }: Partial): RegisteredModelList => ({ diff --git a/clients/ui/frontend/src/__mocks__/utils.ts b/clients/ui/frontend/src/__mocks__/utils.ts index 43d1768e8..f1599462b 100644 --- a/clients/ui/frontend/src/__mocks__/utils.ts +++ b/clients/ui/frontend/src/__mocks__/utils.ts @@ -1,20 +1,4 @@ -import { - ModelRegistryMetadataType, - ModelRegistryBody, - ModelRegistryStringCustomProperties, -} from '~/app/types'; - -export const createModelRegistryLabelsObject = ( - labels: string[], -): ModelRegistryStringCustomProperties => - labels.reduce((acc, label) => { - acc[label] = { - metadataType: ModelRegistryMetadataType.STRING, - // eslint-disable-next-line camelcase - string_value: '', - }; - return acc; - }, {} as ModelRegistryStringCustomProperties); +import { ModelRegistryBody } from '~/app/types'; export const mockBFFResponse = (data: T): ModelRegistryBody => ({ data, diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/subComponents/SearchSelector.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/subComponents/SearchSelector.ts new file mode 100644 index 000000000..21591ae2a --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/subComponents/SearchSelector.ts @@ -0,0 +1,59 @@ +import { SubComponentBase } from '~/__tests__/cypress/cypress/pages/components/subComponents/SubComponentBase'; + +export class SearchSelector extends SubComponentBase { + constructor( + private selectorId: string, + contextSelectorId?: string, + ) { + super(contextSelectorId); + } + + private findContextualItem(suffix: string): Cypress.Chainable> { + return this.findScope().document().findByTestId(`${this.selectorId}-${suffix}`); + } + + findItem(name: string, useMenuList: boolean): Cypress.Chainable> { + const list = useMenuList ? this.findMenuList() : this.findResultTableList(); + return list.contains(name).should('exist'); + } + + selectItem(name: string, useMenuList = false): void { + this.findItem(name, useMenuList).click(); + } + + findSearchInput(): Cypress.Chainable> { + return this.findContextualItem('search'); + } + + findToggleButton(): Cypress.Chainable> { + return this.findContextualItem('toggle'); + } + + findResultTableList(): Cypress.Chainable> { + return this.findContextualItem('table-list'); + } + + findSearchHelpText(): Cypress.Chainable> { + return this.findContextualItem('searchHelpText'); + } + + findMenu(): Cypress.Chainable> { + return this.findContextualItem('menu'); + } + + findMenuList(): Cypress.Chainable> { + return this.findContextualItem('menuList'); + } + + // Search for an item by typing into the search input + searchItem(name: string): void { + this.findSearchInput().clear().type(name); + } + + // Perform the entire process: open, search, and select + openAndSelectItem(name: string, useMenuList = false): void { + this.findToggleButton().click(); + this.searchItem(name); + this.selectItem(name, useMenuList); + } +} diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/subComponents/SubComponentBase.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/subComponents/SubComponentBase.ts new file mode 100644 index 000000000..e1706e07e --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/subComponents/SubComponentBase.ts @@ -0,0 +1,35 @@ +/** + * A SubComponent is a component that doesn't make up a full page and will be consumed in other page + * objects. This could be a complex field, a group of fields, or some section. Typically not large + * enough to warrant its own standalone page object. + * + * Primary use-case example: + * class Foo extends SubComponentBase { + * constructor(private myTestId: string, scopedTestId?: string) { + * super(scopedTestId); + * } + * + * private find(suffix: string) { + * return this.findScope().getByTestId(`${this.myTestId}-${suffix}`); + * } + * + * selectItem(name: string) { + * // "list" would be an internal suffix for your component to know where the "items" are + * return this.find('list').findDropdownItem(name); + * } + * } + * + * Search uses of this component to see further examples + */ +export class SubComponentBase { + constructor(private scopedBaseTestId?: string) {} + + /** Allows for extended classes to make use of a simple one-check for their `find()` calls */ + protected findScope(): (Cypress.cy & CyEventEmitter) | Cypress.Chainable> { + if (this.scopedBaseTestId) { + return cy.findByTestId(this.scopedBaseTestId); + } + + return cy; + } +} diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts index 6e4b28591..fa8a72adc 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts @@ -87,14 +87,38 @@ class ModelRegistry { return cy.findByTestId('empty-model-registries-state'); } + findModelRegistryEmptyTableState() { + return cy.findByTestId('dashboard-empty-table-state'); + } + shouldregisteredModelsEmpty() { cy.findByTestId('empty-registered-models').should('exist'); } + findViewDetailsButton() { + return cy.findByTestId('view-details-button'); + } + + findDetailsPopover() { + return cy.findByTestId('mr-details-popover'); + } + + findHelpContentButton() { + return cy.findByTestId('model-registry-help-button'); + } + + findHelpContentPopover() { + return cy.findByTestId('model-registry-help-content'); + } + shouldmodelVersionsEmpty() { cy.findByTestId('empty-model-versions').should('exist'); } + shouldArchiveModelVersionsEmpty() { + cy.findByTestId('empty-archive-model-versions').should('exist'); + } + shouldModelRegistrySelectorExist() { cy.findByTestId('model-registry-selector-dropdown').should('exist'); } @@ -103,10 +127,6 @@ class ModelRegistry { cy.findByTestId('registered-models-table-toolbar').should('exist'); } - shouldArchiveModelVersionsEmpty() { - cy.findByTestId('empty-archive-model-versions').should('exist'); - } - tabEnabled() { appChrome.findNavItem('Model Registry').should('exist'); return this; diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive.ts index 951b3b04c..a52fe97e1 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive.ts @@ -97,6 +97,14 @@ class ModelVersionArchive { return cy.findByTestId('archive-version-page-breadcrumb'); } + findVersionDetailsTab() { + return cy.findByTestId('model-versions-details-tab'); + } + + findVersionDeploymentTab() { + return cy.findByTestId('deployments-tab'); + } + findArchiveVersionTable() { return cy.findByTestId('model-versions-archive-table'); } diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails.ts index c3d44c5f7..5106f1948 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails.ts @@ -1,3 +1,5 @@ +import { TableRow } from '~/__tests__/cypress/cypress/pages/components/table'; + class ModelVersionDetails { visit() { const preferredModelRegistry = 'modelregistry-sample'; @@ -20,6 +22,14 @@ class ModelVersionDetails { return cy.findByTestId('model-version-description'); } + findSourceModelFormat(subComponent: 'group' | 'edit' | 'save' | 'cancel') { + return cy.findByTestId(`source-model-format-${subComponent}`); + } + + findSourceModelVersion(subComponent: 'group' | 'edit' | 'save' | 'cancel') { + return cy.findByTestId(`source-model-version-${subComponent}`); + } + findMoreLabelsButton() { return cy.findByTestId('label-group').find('button'); } @@ -68,6 +78,78 @@ class ModelVersionDetails { findRegisteredDeploymentsTab() { return cy.findByTestId('deployments-tab'); } + + findAddPropertyButton() { + return cy.findByTestId('add-property-button'); + } + + findAddKeyInput() { + return cy.findByTestId('add-property-key-input'); + } + + findAddValueInput() { + return cy.findByTestId('add-property-value-input'); + } + + findKeyEditInput(key: string) { + return cy.findByTestId(['edit-property-key-input', key]); + } + + findValueEditInput(value: string) { + return cy.findByTestId(['edit-property-value-input', value]); + } + + findSaveButton() { + return cy.findByTestId('save-edit-button-property'); + } + + findCancelButton() { + return cy.findByTestId('discard-edit-button-property'); + } + + findExpandControlButton() { + return cy.findByTestId('expand-control-button'); + } + + private findTable() { + return cy.findByTestId('properties-table'); + } + + findPropertiesTableRows() { + return this.findTable().find('tbody tr'); + } + + getRow(name: string) { + return new PropertyRow(() => + this.findTable().find(`[data-label=Key]`).contains(name).parents('tr'), + ); + } + + findEditLabelsButton() { + return cy.findByTestId('editable-labels-group-edit'); + } + + findAddLabelButton() { + return cy.findByTestId('add-label-button'); + } + + findLabelInput(label: string) { + return cy.findByTestId(`edit-label-input-${label}`); + } + + findLabel(label: string) { + return cy.findByTestId(`editable-label-${label}`); + } + + findLabelErrorAlert() { + return cy.findByTestId('label-error-alert'); + } + + findSaveLabelsButton() { + return cy.findByTestId('editable-labels-group-save'); + } } +class PropertyRow extends TableRow {} + export const modelVersionDetails = new ModelVersionDetails(); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerModelPage.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerModelPage.ts index 80d38bd61..4e9f4af7c 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerModelPage.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerModelPage.ts @@ -1,3 +1,5 @@ +import { SearchSelector } from '~/__tests__/cypress/cypress/pages/components/subComponents/SearchSelector'; + export enum FormFieldSelector { MODEL_NAME = '#model-name', MODEL_DESCRIPTION = '#model-description', @@ -15,6 +17,8 @@ export enum FormFieldSelector { } class RegisterModelPage { + projectDropdown = new SearchSelector('project-selector', 'connection-autofill-modal'); + visit() { const preferredModelRegistry = 'modelregistry-sample'; cy.visit(`/model-registry/${preferredModelRegistry}/registerModel`); @@ -41,10 +45,6 @@ class RegisterModelPage { return cy.findByTestId('connection-autofill-modal'); } - findProjectSelector() { - return this.findConnectionAutofillModal().findByTestId('project-selector-dropdown'); - } - findConnectionSelector() { return this.findConnectionAutofillModal().findByTestId('select-data-connection'); } diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerVersionPage.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerVersionPage.ts new file mode 100644 index 000000000..3b9df6e9a --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerVersionPage.ts @@ -0,0 +1,51 @@ +export enum FormFieldSelector { + REGISTERED_MODEL = '#registered-model-container .pf-m-typeahead', + VERSION_NAME = '#version-name', + VERSION_DESCRIPTION = '#version-description', + SOURCE_MODEL_FORMAT = '#source-model-format', + SOURCE_MODEL_FORMAT_VERSION = '#source-model-format-version', + LOCATION_TYPE_OBJECT_STORAGE = '#location-type-object-storage', + LOCATION_ENDPOINT = '#location-endpoint', + LOCATION_BUCKET = '#location-bucket', + LOCATION_REGION = '#location-region', + LOCATION_PATH = '#location-path', + LOCATION_TYPE_URI = '#location-type-uri', + LOCATION_URI = '#location-uri', +} + +class RegisterVersionPage { + visit(registeredModelId?: string) { + const preferredModelRegistry = 'modelregistry-sample'; + cy.visit( + registeredModelId + ? `/model-registry/${preferredModelRegistry}/registeredModels/${registeredModelId}/registerVersion` + : `/model-registry/${preferredModelRegistry}/registerVersion`, + ); + this.wait(); + } + + private wait() { + const preferredModelRegistry = 'modelregistry-sample'; + cy.findByTestId('app-page-title').should('exist'); + cy.findByTestId('app-page-title').contains('Register new version'); + cy.findByText(`Model registry - ${preferredModelRegistry}`).should('exist'); + cy.testA11y(); + } + + findFormField(selector: FormFieldSelector) { + return cy.get(selector); + } + + selectRegisteredModel(name: string) { + this.findFormField(FormFieldSelector.REGISTERED_MODEL) + .findByRole('button', { name: 'Typeahead menu toggle' }) + .findSelectOption(name) + .click(); + } + + findSubmitButton() { + return cy.findByTestId('create-button'); + } +} + +export const registerVersionPage = new RegisterVersionPage(); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts index f4fc6018d..e560ebf26 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts @@ -50,6 +50,13 @@ declare global { }, response: ApiResponse, ) => Cypress.Chainable) & + (( + type: 'GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions', + options: { + path: { modelRegistryName: string; apiVersion: string }; + }, + response: ApiResponse, + ) => Cypress.Chainable) & (( type: 'POST /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId/versions', options: { diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts index 897b01148..abbb30e23 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts @@ -5,7 +5,12 @@ import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; -import type { ModelRegistry, ModelVersion, RegisteredModel } from '~/app/types'; +import { + ModelRegistryMetadataType, + type ModelRegistry, + type ModelVersion, + type RegisteredModel, +} from '~/app/types'; import { be } from '~/__tests__/cypress/cypress/utils/should'; import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; @@ -19,13 +24,11 @@ const initIntercepts = ({ modelRegistries = [ mockModelRegistry({ name: 'modelregistry-sample', - description: 'New model registry', - displayName: 'Model Registry Sample', }), mockModelRegistry({ name: 'modelregistry-sample-2', - description: 'New model registry 2', - displayName: 'Model Registry Sample 2', + description: '', + displayName: 'modelregistry-sample-2', }), ], registeredModels = [ @@ -33,29 +36,72 @@ const initIntercepts = ({ name: 'Fraud detection model', description: 'A machine learning model trained to detect fraudulent transactions in financial data', - labels: [ - 'Financial data', - 'Fraud detection', - 'Test label', - 'Machine learning', - 'Next data to be overflow', - ], + customProperties: { + 'Financial data': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Fraud detection': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Machine learning': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Next data to be overflow': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + }, }), mockRegisteredModel({ name: 'Label modal', description: 'A machine learning model trained to detect fraudulent transactions in financial data', - labels: [ - 'Testing label', - 'Financial data', - 'Fraud detection', - 'Long label data to be truncated abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc', - 'Machine learning', - 'Next data to be overflow', - 'Label x', - 'Label y', - 'Label z', - ], + customProperties: { + 'Testing label': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Financial data': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Fraud detection': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Long label data to be truncated abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc': + { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Machine learning': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Next data to be overflow': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Label x': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Label y': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Label z': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + }, }), ], modelVersions = [ @@ -71,6 +117,14 @@ const initIntercepts = ({ modelRegistries, ); + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions`, + { + path: { modelRegistryName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockModelVersionList({ items: modelVersions }), + ); + cy.interceptApi( `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models`, { @@ -95,20 +149,7 @@ const initIntercepts = ({ describe('Model Registry core', () => { it('Model Registry Enabled in the cluster', () => { initIntercepts({ - registeredModels: [ - mockRegisteredModel({ - name: 'Fraud detection model', - description: - 'A machine learning model trained to detect fraudulent transactions in financial data', - labels: [ - 'Financial data', - 'Fraud detection', - 'Test label', - 'Machine learning', - 'Next data to be overflow', - ], - }), - ], + registeredModels: [], }); modelRegistry.visit(); @@ -126,7 +167,6 @@ describe('Model Registry core', () => { modelRegistry.navigate(); modelRegistry.findModelRegistryEmptyState().should('exist'); }); - it('No registered models in the selected Model Registry', () => { initIntercepts({ registeredModels: [], @@ -136,6 +176,26 @@ describe('Model Registry core', () => { modelRegistry.navigate(); modelRegistry.shouldModelRegistrySelectorExist(); modelRegistry.shouldregisteredModelsEmpty(); + + modelRegistry.findViewDetailsButton().click(); + modelRegistry.findDetailsPopover().should('exist'); + modelRegistry.findDetailsPopover().findByText('Model registry description').should('exist'); + + // Model registry with no description + modelRegistry.findModelRegistry().findSelectOption('modelregistry-sample-2').click(); + modelRegistry.findViewDetailsButton().click(); + modelRegistry.findDetailsPopover().should('exist'); + modelRegistry.findDetailsPopover().findByText('No description').should('exist'); + + // Model registry help content + modelRegistry.findHelpContentButton().click(); + modelRegistry.findHelpContentPopover().should('exist'); + modelRegistry + .findHelpContentPopover() + .findByText( + 'To request access to a new or existing model registry, contact your administrator.', + ) + .should('exist'); }); describe('Registered model table', () => { diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts similarity index 86% rename from clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts rename to clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts index eeed18eda..278281f2a 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts @@ -6,7 +6,7 @@ import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; import type { ModelRegistry, ModelVersion } from '~/app/types'; -import { ModelState } from '~/app/types'; +import { ModelRegistryMetadataType, ModelState } from '~/app/types'; import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockBFFResponse } from '~/__mocks__/utils'; import { @@ -30,16 +30,40 @@ const initIntercepts = ({ name: 'model version 1', author: 'Author 1', id: '1', - labels: [ - 'Financial data', - 'Fraud detection', - 'Test label', - 'Machine learning', - 'Next data to be overflow', - 'Test label x', - 'Test label y', - 'Test label z', - ], + customProperties: { + 'Financial data': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Fraud detection': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Machine learning': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Next data to be overflow': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label x': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label y': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label z': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + }, state: ModelState.ARCHIVED, }), mockModelVersion({ id: '2', name: 'model version 2', state: ModelState.ARCHIVED }), @@ -202,7 +226,7 @@ describe('Restoring archive version', () => { modelVersionArchive.visit(); const archiveVersionRow = modelVersionArchive.getRow('model version 2'); - archiveVersionRow.findKebabAction('Restore version').click(); + archiveVersionRow.findKebabAction('Restore model version').click(); restoreVersionModal.findRestoreButton().click(); @@ -273,6 +297,13 @@ describe('Archiving version', () => { }); }); + it('Archived version details page does not have the Deployments tab', () => { + initIntercepts({}); + modelVersionArchive.visitArchiveVersionDetail(); + modelVersionArchive.findVersionDetailsTab().should('exist'); + modelVersionArchive.findVersionDeploymentTab().should('not.exist'); + }); + it('Archive version from versions details', () => { cy.interceptApi( 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', @@ -290,7 +321,7 @@ describe('Archiving version', () => { modelVersionArchive.visitModelVersionDetails(); modelVersionArchive .findModelVersionsDetailsHeaderAction() - .findDropdownItem('Archive version') + .findDropdownItem('Archive model version') .click(); archiveVersionModal.findArchiveButton().should('be.disabled'); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts new file mode 100644 index 000000000..3b5771d50 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts @@ -0,0 +1,397 @@ +/* eslint-disable camelcase */ +import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; +import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; +import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; +import { mockModelVersion } from '~/__mocks__/mockModelVersion'; +import { mockModelArtifactList } from '~/__mocks__/mockModelArtifactList'; +import { ModelRegistryMetadataType, ModelState, type ModelRegistry } from '~/app/types'; +import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; +import { modelVersionDetails } from '~/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails'; +import { mockBFFResponse } from '~/__mocks__/utils'; + +const mockModelVersions = mockModelVersion({ + id: '1', + name: 'Version 1', + customProperties: { + a1: { + metadataType: ModelRegistryMetadataType.STRING, + string_value: 'v1', + }, + a2: { + metadataType: ModelRegistryMetadataType.STRING, + string_value: 'v2', + }, + a3: { + metadataType: ModelRegistryMetadataType.STRING, + string_value: 'v3', + }, + a4: { + metadataType: ModelRegistryMetadataType.STRING, + string_value: 'v4', + }, + a5: { + metadataType: ModelRegistryMetadataType.STRING, + string_value: 'v5', + }, + a6: { + metadataType: ModelRegistryMetadataType.STRING, + string_value: 'v1', + }, + a7: { + metadataType: ModelRegistryMetadataType.STRING, + string_value: 'v7', + }, + 'Testing label': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Financial data': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Fraud detection': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Long label data to be truncated abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc': + { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Machine learning': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Next data to be overflow': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Label x': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Label y': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Label z': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + }, +}); + +type HandlersProps = { + modelRegistries?: ModelRegistry[]; +}; + +const initIntercepts = ({ + modelRegistries = [ + mockModelRegistry({ + name: 'modelregistry-sample', + description: 'New model registry', + displayName: 'Model Registry Sample', + }), + mockModelRegistry({ + name: 'modelregistry-sample-2', + description: 'New model registry 2', + displayName: 'Model Registry Sample 2', + }), + ], +}: HandlersProps) => { + cy.interceptApi( + `GET /api/:apiVersion/model_registry`, + { + path: { apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + modelRegistries, + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockRegisteredModel({}), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId/versions`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockModelVersionList({ + items: [ + mockModelVersion({ name: 'Version 1', author: 'Author 1', registeredModelId: '1' }), + mockModelVersion({ + author: 'Author 2', + registeredModelId: '1', + id: '2', + name: 'Version 2', + }), + mockModelVersion({ + author: 'Author 3', + registeredModelId: '1', + id: '3', + name: 'Version 3', + state: ModelState.ARCHIVED, + }), + ], + }), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 1, + }, + }, + mockModelVersions, + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 2, + }, + }, + mockModelVersion({ id: '2', name: 'Version 2' }), + ); + + cy.interceptApi( + `PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 1, + }, + }, + mockModelVersions, + ).as('UpdatePropertyRow'); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId/artifacts`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 1, + }, + }, + mockModelArtifactList({}), + ); +}; + +describe('Model version details', () => { + describe('Details tab', () => { + beforeEach(() => { + initIntercepts({}); + modelVersionDetails.visit(); + }); + + it('Model version details page header', () => { + verifyRelativeURL( + '/model-registry/modelregistry-sample/registeredModels/1/versions/1/details', + ); + cy.findByTestId('app-page-title').should('have.text', 'Version 1'); + cy.findByTestId('breadcrumb-version-name').should('have.text', 'Version 1'); + }); + + it('should add a property', () => { + modelVersionDetails.findAddPropertyButton().click(); + modelVersionDetails.findAddKeyInput().type('new_key'); + modelVersionDetails.findAddValueInput().type('new_value'); + modelVersionDetails.findCancelButton().click(); + + modelVersionDetails.findAddPropertyButton().click(); + modelVersionDetails.findAddKeyInput().type('new_key'); + modelVersionDetails.findAddValueInput().type('new_value'); + modelVersionDetails.findSaveButton().click(); + cy.wait('@UpdatePropertyRow'); + }); + + it('should edit a property row', () => { + modelVersionDetails.findExpandControlButton().should('have.text', 'Show 2 more properties'); + modelVersionDetails.findExpandControlButton().click(); + const propertyRow = modelVersionDetails.getRow('a6'); + propertyRow.find().findKebabAction('Edit').click(); + modelVersionDetails.findKeyEditInput('a6').clear().type('edit_key'); + modelVersionDetails.findValueEditInput('v1').clear().type('edit_value'); + + modelVersionDetails.findCancelButton().click(); + propertyRow.find().findKebabAction('Edit').click(); + modelVersionDetails.findKeyEditInput('a6').clear().type('edit_key'); + modelVersionDetails.findValueEditInput('v1').clear().type('edit_value'); + modelVersionDetails.findSaveButton().click(); + cy.wait('@UpdatePropertyRow').then((interception) => { + expect(interception.request.body).to.eql( + mockBFFResponse({ + customProperties: { + a1: { metadataType: 'MetadataStringValue', string_value: 'v1' }, + a2: { metadataType: 'MetadataStringValue', string_value: 'v2' }, + a3: { metadataType: 'MetadataStringValue', string_value: 'v3' }, + a4: { metadataType: 'MetadataStringValue', string_value: 'v4' }, + a5: { metadataType: 'MetadataStringValue', string_value: 'v5' }, + a7: { metadataType: 'MetadataStringValue', string_value: 'v7' }, + 'Testing label': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Financial data': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Fraud detection': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Long label data to be truncated abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc': + { metadataType: 'MetadataStringValue', string_value: '' }, + 'Machine learning': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Next data to be overflow': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Label x': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Label y': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Label z': { metadataType: 'MetadataStringValue', string_value: '' }, + edit_key: { string_value: 'edit_value', metadataType: 'MetadataStringValue' }, + }, + }), + ); + }); + }); + + it('should delete a property row', () => { + modelVersionDetails.findExpandControlButton().should('have.text', 'Show 2 more properties'); + modelVersionDetails.findExpandControlButton().click(); + const propertyRow = modelVersionDetails.getRow('a6'); + modelVersionDetails.findPropertiesTableRows().should('have.length', 7); + propertyRow.find().findKebabAction('Delete').click(); + cy.wait('@UpdatePropertyRow').then((interception) => { + expect(interception.request.body).to.eql( + mockBFFResponse({ + customProperties: { + a1: { metadataType: 'MetadataStringValue', string_value: 'v1' }, + a2: { metadataType: 'MetadataStringValue', string_value: 'v2' }, + a3: { metadataType: 'MetadataStringValue', string_value: 'v3' }, + a4: { metadataType: 'MetadataStringValue', string_value: 'v4' }, + a5: { metadataType: 'MetadataStringValue', string_value: 'v5' }, + a7: { metadataType: 'MetadataStringValue', string_value: 'v7' }, + 'Testing label': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Financial data': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Fraud detection': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Long label data to be truncated abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc': + { metadataType: 'MetadataStringValue', string_value: '' }, + 'Machine learning': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Next data to be overflow': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Label x': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Label y': { metadataType: 'MetadataStringValue', string_value: '' }, + 'Label z': { metadataType: 'MetadataStringValue', string_value: '' }, + }, + }), + ); + }); + }); + + it('Switching model versions', () => { + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId/artifacts`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 2, + }, + }, + mockModelArtifactList({}), + ); + modelVersionDetails.findVersionId().contains('1'); + modelVersionDetails.findModelVersionDropdownButton().click(); + modelVersionDetails.findModelVersionDropdownItem('Version 3').should('not.exist'); + modelVersionDetails.findModelVersionDropdownSearch().fill('Version 2'); + modelVersionDetails.findModelVersionDropdownItem('Version 2').click(); + modelVersionDetails.findVersionId().contains('2'); + }); + + it('should handle label editing', () => { + modelVersionDetails.findEditLabelsButton().click(); + + modelVersionDetails.findAddLabelButton().click(); + cy.findByTestId('editable-label-group') + .should('exist') + .within(() => { + cy.contains('New Label').should('exist').click(); + cy.focused().type('First Label{enter}'); + }); + + modelVersionDetails.findAddLabelButton().click(); + cy.findByTestId('editable-label-group') + .should('exist') + .within(() => { + cy.contains('New Label').should('exist').click(); + cy.focused().type('Second Label{enter}'); + }); + + cy.findByTestId('editable-label-group').within(() => { + cy.contains('First Label').should('exist').click(); + cy.focused().type('Updated First Label{enter}'); + }); + + cy.findByTestId('editable-label-group').within(() => { + cy.contains('Second Label').parent().find('[data-testid^="remove-label-"]').click(); + }); + + modelVersionDetails.findSaveLabelsButton().should('exist').click(); + }); + + it('should validate label length', () => { + modelVersionDetails.findEditLabelsButton().click(); + + const longLabel = 'a'.repeat(64); + modelVersionDetails.findAddLabelButton().click(); + cy.findByTestId('editable-label-group') + .should('exist') + .within(() => { + cy.contains('New Label').should('exist').click(); + cy.focused().type(`${longLabel}{enter}`); + }); + + cy.findByTestId('label-error-alert') + .should('be.visible') + .within(() => { + cy.contains(`can't exceed 63 characters`).should('exist'); + }); + }); + + it('should validate duplicate labels', () => { + modelVersionDetails.findEditLabelsButton().click(); + + modelVersionDetails.findAddLabelButton().click(); + cy.findByTestId('editable-label-group') + .should('exist') + .within(() => { + cy.get('[data-testid^="editable-label-"]').last().click(); + cy.focused().type('{selectall}{backspace}Testing label{enter}'); + }); + + modelVersionDetails.findAddLabelButton().click(); + cy.findByTestId('editable-label-group') + .should('exist') + .within(() => { + cy.get('[data-testid^="editable-label-"]').last().click(); + cy.focused().type('{selectall}{backspace}Testing label{enter}'); + }); + + cy.findByTestId('label-error-alert') + .should('be.visible') + .within(() => { + cy.contains('Testing label already exists').should('exist'); + }); + }); + }); +}); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts similarity index 85% rename from clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts rename to clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts index 842392f6b..292cbee0c 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts @@ -4,7 +4,7 @@ import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; import { be } from '~/__tests__/cypress/cypress/utils/should'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; -import type { ModelRegistry, ModelVersion } from '~/app/types'; +import { ModelRegistryMetadataType, type ModelRegistry, type ModelVersion } from '~/app/types'; import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; @@ -34,16 +34,40 @@ const initIntercepts = ({ mockModelVersion({ author: 'Author 1', id: '1', - labels: [ - 'Financial data', - 'Fraud detection', - 'Test label', - 'Machine learning', - 'Next data to be overflow', - 'Test label x', - 'Test label y', - 'Test label z', - ], + customProperties: { + 'Financial data': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Fraud detection': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Machine learning': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Next data to be overflow': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label x': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label y': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label z': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + }, }), mockModelVersion({ id: '2', name: 'model version' }), ], @@ -56,6 +80,14 @@ const initIntercepts = ({ modelRegistries, ); + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions`, + { + path: { modelRegistryName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockModelVersionList({ items: modelVersions }), + ); + cy.interceptApi( `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models`, { diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts similarity index 90% rename from clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts rename to clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts index 6601e3290..74cfd6471 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts @@ -7,7 +7,7 @@ import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/mod import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { be } from '~/__tests__/cypress/cypress/utils/should'; import type { ModelRegistry, ModelVersion, RegisteredModel } from '~/app/types'; -import { ModelState } from '~/app/types'; +import { ModelRegistryMetadataType, ModelState } from '~/app/types'; import { mockBFFResponse } from '~/__mocks__/utils'; import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; @@ -29,16 +29,40 @@ const initIntercepts = ({ mockRegisteredModel({ name: 'model 1', id: '1', - labels: [ - 'Financial data', - 'Fraud detection', - 'Test label', - 'Machine learning', - 'Next data to be overflow', - 'Test label x', - 'Test label y', - 'Test label z', - ], + customProperties: { + 'Financial data': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Fraud detection': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Machine learning': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Next data to be overflow': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label x': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label y': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + 'Test label z': { + metadataType: ModelRegistryMetadataType.STRING, + string_value: '', + }, + }, state: ModelState.ARCHIVED, }), mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.ARCHIVED }), @@ -70,6 +94,14 @@ const initIntercepts = ({ modelRegistries, ); + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions`, + { + path: { modelRegistryName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockModelVersionList({ items: modelVersions }), + ); + cy.interceptApi( `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models`, { diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionDetails.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionDetails.cy.ts deleted file mode 100644 index 58f28ac6a..000000000 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionDetails.cy.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* eslint-disable camelcase */ -import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; -import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; -import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; -import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; -import { mockModelVersion } from '~/__mocks__/mockModelVersion'; -import { mockModelArtifactList } from '~/__mocks__/mockModelArtifactList'; -import { mockModelArtifact } from '~/__mocks__/mockModelArtifact'; -import type { ModelRegistry } from '~/app/types'; -import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; -import { modelVersionDetails } from '~/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails'; - -type HandlersProps = { - modelRegistries?: ModelRegistry[]; -}; - -const initIntercepts = ({ - modelRegistries = [ - mockModelRegistry({ - name: 'modelregistry-sample', - description: 'New model registry', - displayName: 'Model Registry Sample', - }), - mockModelRegistry({ - name: 'modelregistry-sample-2', - description: 'New model registry 2', - displayName: 'Model Registry Sample 2', - }), - ], -}: HandlersProps) => { - cy.interceptApi( - `GET /api/:apiVersion/model_registry`, - { - path: { apiVersion: MODEL_REGISTRY_API_VERSION }, - }, - modelRegistries, - ); - - cy.interceptApi( - `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId`, - { - path: { - modelRegistryName: 'modelregistry-sample', - apiVersion: MODEL_REGISTRY_API_VERSION, - registeredModelId: 1, - }, - }, - mockRegisteredModel({}), - ); - - cy.interceptApi( - `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId/versions`, - { - path: { - modelRegistryName: 'modelregistry-sample', - apiVersion: MODEL_REGISTRY_API_VERSION, - registeredModelId: 1, - }, - }, - mockModelVersionList({ - items: [ - mockModelVersion({ name: 'Version 1', author: 'Author 1', registeredModelId: '1' }), - mockModelVersion({ - author: 'Author 2', - registeredModelId: '1', - id: '2', - name: 'Version 2', - }), - ], - }), - ); - - cy.interceptApi( - `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, - { - path: { - modelRegistryName: 'modelregistry-sample', - apiVersion: MODEL_REGISTRY_API_VERSION, - modelVersionId: 1, - }, - }, - mockModelVersion({ - id: '1', - name: 'Version 1', - labels: [ - 'Testing label', - 'Financial data', - 'Fraud detection', - 'Long label data to be truncated abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc', - 'Machine learning', - 'Next data to be overflow', - 'Label x', - 'Label y', - 'Label z', - ], - }), - ); - - cy.interceptApi( - `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, - { - path: { - modelRegistryName: 'modelregistry-sample', - apiVersion: MODEL_REGISTRY_API_VERSION, - modelVersionId: 2, - }, - }, - mockModelVersion({ id: '2', name: 'Version 2' }), - ); - - cy.interceptApi( - `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId/artifacts`, - { - path: { - modelRegistryName: 'modelregistry-sample', - apiVersion: MODEL_REGISTRY_API_VERSION, - modelVersionId: 1, - }, - }, - mockModelArtifactList({ - items: [ - mockModelArtifact({}), - mockModelArtifact({ - author: 'Author 2', - id: '2', - name: 'Artifact 2', - }), - ], - }), - ); -}; - -describe('Model version details', () => { - describe('Details tab', () => { - beforeEach(() => { - initIntercepts({}); - modelVersionDetails.visit(); - }); - - it('Model version details page header', () => { - verifyRelativeURL( - '/model-registry/modelregistry-sample/registeredModels/1/versions/1/details', - ); - cy.findByTestId('app-page-title').should('have.text', 'Version 1'); - cy.findByTestId('breadcrumb-version-name').should('have.text', 'Version 1'); - }); - - it('Model version details tab', () => { - modelVersionDetails.findVersionId().contains('1'); - modelVersionDetails.findDescription().should('have.text', 'Description of model version'); - modelVersionDetails.findMoreLabelsButton().contains('6 more'); - modelVersionDetails.findMoreLabelsButton().click(); - modelVersionDetails.shouldContainsModalLabels([ - 'Testing label', - 'Financial', - 'Financial data', - 'Fraud detection', - 'Machine learning', - 'Next data to be overflow', - 'Label x', - 'Label y', - 'Label z', - ]); - modelVersionDetails.findStorageEndpoint().contains('test-endpoint'); - modelVersionDetails.findStorageRegion().contains('test-region'); - modelVersionDetails.findStorageBucket().contains('test-bucket'); - modelVersionDetails.findStoragePath().contains('demo-models/test-path'); - }); - - it('Switching model versions', () => { - modelVersionDetails.findVersionId().contains('1'); - modelVersionDetails.findModelVersionDropdownButton().click(); - modelVersionDetails.findModelVersionDropdownSearch().fill('Version 2'); - modelVersionDetails.findModelVersionDropdownItem('Version 2').click(); - modelVersionDetails.findVersionId().contains('2'); - }); - }); -}); diff --git a/clients/ui/frontend/src/app/AppRoutes.tsx b/clients/ui/frontend/src/app/AppRoutes.tsx index 60a69d480..2be853ffb 100644 --- a/clients/ui/frontend/src/app/AppRoutes.tsx +++ b/clients/ui/frontend/src/app/AppRoutes.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; -import { NotFound } from './pages/notFound/NotFound'; +import NotFound from '~/shared/components/notFound/NotFound'; import ModelRegistrySettingsRoutes from './pages/settings/ModelRegistrySettingsRoutes'; import ModelRegistryRoutes from './pages/modelRegistry/ModelRegistryRoutes'; import useUser from './hooks/useUser'; diff --git a/clients/ui/frontend/src/app/NavBar.tsx b/clients/ui/frontend/src/app/NavBar.tsx index b9e52df62..673707376 100644 --- a/clients/ui/frontend/src/app/NavBar.tsx +++ b/clients/ui/frontend/src/app/NavBar.tsx @@ -6,14 +6,17 @@ import { Masthead, MastheadContent, MastheadMain, + MastheadToggle, MenuToggle, MenuToggleElement, + PageToggleButton, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem, } from '@patternfly/react-core'; import { SimpleSelect } from '@patternfly/react-templates'; +import { BarsIcon } from '@patternfly/react-icons'; import { NamespaceSelectorContext } from '~/shared/context/NamespaceSelectorContext'; interface NavBarProps { @@ -46,7 +49,13 @@ const NavBar: React.FC = ({ username, onLogout }) => { return ( - + + + + + + + diff --git a/clients/ui/frontend/src/app/__tests__/updateTimestamps.test.ts b/clients/ui/frontend/src/app/__tests__/updateTimestamps.test.ts new file mode 100644 index 000000000..7824ed5a3 --- /dev/null +++ b/clients/ui/frontend/src/app/__tests__/updateTimestamps.test.ts @@ -0,0 +1,132 @@ +import { + ModelRegistryAPIs, + ModelState, + ModelRegistryMetadataType, + ModelVersion, + RegisteredModel, +} from '~/app/types'; +import { + bumpModelVersionTimestamp, + bumpRegisteredModelTimestamp, + bumpBothTimestamps, +} from '~/app/utils/updateTimestamps'; + +describe('updateTimestamps', () => { + const mockApi = jest.mocked({ + createRegisteredModel: jest.fn(), + createModelVersionForRegisteredModel: jest.fn(), + createModelArtifactForModelVersion: jest.fn(), + getRegisteredModel: jest.fn(), + getModelVersion: jest.fn(), + listModelVersions: jest.fn(), + listRegisteredModels: jest.fn(), + getModelVersionsByRegisteredModel: jest.fn(), + getModelArtifactsByModelVersion: jest.fn(), + patchRegisteredModel: jest.fn(), + patchModelVersion: jest.fn(), + patchModelArtifact: jest.fn(), + }); + const fakeModelVersionId = 'test-model-version-id'; + const fakeRegisteredModelId = 'test-registered-model-id'; + + beforeEach(() => { + jest.spyOn(Date.prototype, 'toISOString').mockReturnValue('2024-01-01T00:00:00.000Z'); + }); + + describe('bumpModelVersionTimestamp', () => { + it('should successfully update model version timestamp', async () => { + await bumpModelVersionTimestamp(mockApi, fakeModelVersionId); + + expect(mockApi.patchModelVersion).toHaveBeenCalledWith( + {}, + { + state: ModelState.LIVE, + customProperties: { + _lastModified: { + metadataType: ModelRegistryMetadataType.STRING, + // eslint-disable-next-line camelcase + string_value: '2024-01-01T00:00:00.000Z', + }, + }, + }, + fakeModelVersionId, + ); + }); + + it('should throw error if modelVersionId is empty', async () => { + await expect(bumpModelVersionTimestamp(mockApi, '')).rejects.toThrow( + 'Model version ID is required', + ); + }); + + it('should handle API errors appropriately', async () => { + const errorMessage = 'API Error'; + // Use proper type for mock function + const mockFn = mockApi.patchModelVersion; + mockFn.mockRejectedValue(new Error(errorMessage)); + + await expect(bumpModelVersionTimestamp(mockApi, fakeModelVersionId)).rejects.toThrow( + `Failed to update model version timestamp: ${errorMessage}`, + ); + }); + }); + + describe('bumpRegisteredModelTimestamp', () => { + it('should successfully update registered model timestamp', async () => { + await bumpRegisteredModelTimestamp(mockApi, fakeRegisteredModelId); + + expect(mockApi.patchRegisteredModel).toHaveBeenCalledWith( + {}, + { + state: ModelState.LIVE, + customProperties: { + _lastModified: { + metadataType: ModelRegistryMetadataType.STRING, + // eslint-disable-next-line camelcase + string_value: '2024-01-01T00:00:00.000Z', + }, + }, + }, + fakeRegisteredModelId, + ); + }); + + it('should throw error if registeredModelId is empty', async () => { + await expect(bumpRegisteredModelTimestamp(mockApi, '')).rejects.toThrow( + 'Registered model ID is required', + ); + }); + + it('should handle API errors appropriately', async () => { + const errorMessage = 'API Error'; + // Use proper type for mock function + const mockFn = mockApi.patchRegisteredModel; + mockFn.mockRejectedValue(new Error(errorMessage)); + + await expect(bumpRegisteredModelTimestamp(mockApi, fakeRegisteredModelId)).rejects.toThrow( + `Failed to update registered model timestamp: ${errorMessage}`, + ); + }); + }); + + describe('bumpBothTimestamps', () => { + it('should update both timestamps successfully', async () => { + mockApi.patchModelVersion.mockResolvedValue({} as ModelVersion); + mockApi.patchRegisteredModel.mockResolvedValue({} as RegisteredModel); + + await bumpBothTimestamps(mockApi, fakeModelVersionId, fakeRegisteredModelId); + + expect(mockApi.patchModelVersion).toHaveBeenCalled(); + expect(mockApi.patchRegisteredModel).toHaveBeenCalled(); + }); + + it('should handle errors from either update', async () => { + const errorMessage = 'API Error'; + mockApi.patchModelVersion.mockRejectedValue(new Error(errorMessage)); + + await expect( + bumpBothTimestamps(mockApi, fakeModelVersionId, fakeRegisteredModelId), + ).rejects.toThrow(); + }); + }); +}); diff --git a/clients/ui/frontend/src/app/__tests__/utils.spec.ts b/clients/ui/frontend/src/app/__tests__/utils.spec.ts new file mode 100644 index 000000000..82cf97644 --- /dev/null +++ b/clients/ui/frontend/src/app/__tests__/utils.spec.ts @@ -0,0 +1,230 @@ +import { mockModelVersion } from '~/__mocks__/mockModelVersion'; +import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { + filterArchiveModels, + filterArchiveVersions, + filterLiveModels, + filterLiveVersions, + getLastCreatedItem, + ObjectStorageFields, + objectStorageFieldsToUri, + uriToObjectStorageFields, +} from '~/app/utils'; +import { RegisteredModel, ModelState, ModelVersion } from '~/app/types'; + +describe('objectStorageFieldsToUri', () => { + it('converts fields to URI with all fields present', () => { + const uri = objectStorageFieldsToUri({ + endpoint: 'http://s3.amazonaws.com/', + bucket: 'test-bucket', + region: 'us-east-1', + path: 'demo-models/flan-t5-small-caikit', + }); + expect(uri).toEqual( + 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + ); + }); + + it('converts fields to URI with region missing', () => { + const uri = objectStorageFieldsToUri({ + endpoint: 'http://s3.amazonaws.com/', + bucket: 'test-bucket', + path: 'demo-models/flan-t5-small-caikit', + }); + expect(uri).toEqual( + 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F', + ); + }); + + it('converts fields to URI with region empty', () => { + const uri = objectStorageFieldsToUri({ + endpoint: 'http://s3.amazonaws.com/', + bucket: 'test-bucket', + region: '', + path: 'demo-models/flan-t5-small-caikit', + }); + expect(uri).toEqual( + 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F', + ); + }); + + it('falls back to null if endpoint is empty', () => { + const uri = objectStorageFieldsToUri({ + endpoint: '', + bucket: 'test-bucket', + region: 'us-east-1', + path: 'demo-models/flan-t5-small-caikit', + }); + expect(uri).toEqual(null); + }); + + it('falls back to null if bucket is empty', () => { + const uri = objectStorageFieldsToUri({ + endpoint: 'http://s3.amazonaws.com/', + bucket: '', + region: 'us-east-1', + path: 'demo-models/flan-t5-small-caikit', + }); + expect(uri).toEqual(null); + }); + + it('falls back to null if path is empty', () => { + const uri = objectStorageFieldsToUri({ + endpoint: 'http://s3.amazonaws.com/', + bucket: 'test-bucket', + region: 'us-east-1', + path: '', + }); + expect(uri).toEqual(null); + }); +}); + +describe('uriToObjectStorageFields', () => { + it('converts URI to fields with all params present', () => { + const fields = uriToObjectStorageFields( + 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + ); + expect(fields).toEqual({ + endpoint: 'http://s3.amazonaws.com/', + bucket: 'test-bucket', + region: 'us-east-1', + path: 'demo-models/flan-t5-small-caikit', + } satisfies ObjectStorageFields); + }); + + it('converts URI to fields with region missing', () => { + const fields = uriToObjectStorageFields( + 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F', + ); + expect(fields).toEqual({ + endpoint: 'http://s3.amazonaws.com/', + bucket: 'test-bucket', + path: 'demo-models/flan-t5-small-caikit', + region: undefined, + } satisfies ObjectStorageFields); + }); + + it('falls back to null if endpoint is missing', () => { + const fields = uriToObjectStorageFields('s3://test-bucket/demo-models/flan-t5-small-caikit'); + expect(fields).toBeNull(); + }); + + it('falls back to null if path is missing', () => { + const fields = uriToObjectStorageFields( + 's3://test-bucket/?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + ); + expect(fields).toBeNull(); + }); + + it('falls back to null if bucket is missing', () => { + const fields = uriToObjectStorageFields( + 's3://?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + ); + expect(fields).toBeNull(); + }); + + it('falls back to null if the URI is malformed', () => { + const fields = uriToObjectStorageFields('test-bucket/demo-models/flan-t5-small-caikit'); + expect(fields).toBeNull(); + }); +}); + +describe('getLastCreatedItem', () => { + it('returns the latest item correctly', () => { + const items = [ + { + foo: 'a', + createTimeSinceEpoch: '1712234877179', // Apr 04 2024 + }, + { + foo: 'b', + createTimeSinceEpoch: '1723659611927', // Aug 14 2024 + }, + ]; + expect(getLastCreatedItem(items)).toBe(items[1]); + }); + + it('returns first item if items have no createTimeSinceEpoch', () => { + const items = [ + { foo: 'a', createTimeSinceEpoch: undefined }, + { foo: 'b', createTimeSinceEpoch: undefined }, + ]; + expect(getLastCreatedItem(items)).toBe(items[0]); + }); +}); + +describe('Filter model state', () => { + const models: RegisteredModel[] = [ + mockRegisteredModel({ name: 'Test 1', state: ModelState.ARCHIVED }), + mockRegisteredModel({ + name: 'Test 2', + state: ModelState.LIVE, + description: 'Description2', + }), + mockRegisteredModel({ name: 'Test 3', state: ModelState.ARCHIVED }), + mockRegisteredModel({ name: 'Test 4', state: ModelState.ARCHIVED }), + mockRegisteredModel({ name: 'Test 5', state: ModelState.LIVE }), + ]; + + describe('filterArchiveModels', () => { + it('should filter out only the archived versions', () => { + const archivedModels = filterArchiveModels(models); + expect(archivedModels).toEqual([models[0], models[2], models[3]]); + }); + + it('should return an empty array if the input array is empty', () => { + const result = filterArchiveModels([]); + expect(result).toEqual([]); + }); + }); + + describe('filterLiveModels', () => { + it('should filter out only the live models', () => { + const liveModels = filterLiveModels(models); + expect(liveModels).toEqual([models[1], models[4]]); + }); + + it('should return an empty array if the input array is empty', () => { + const result = filterLiveModels([]); + expect(result).toEqual([]); + }); + }); +}); + +describe('Filter model version state', () => { + const modelVersions: ModelVersion[] = [ + mockModelVersion({ name: 'Test 1', state: ModelState.ARCHIVED }), + mockModelVersion({ + name: 'Test 2', + state: ModelState.LIVE, + description: 'Description2', + }), + mockModelVersion({ name: 'Test 3', author: 'Author3', state: ModelState.ARCHIVED }), + mockModelVersion({ name: 'Test 4', state: ModelState.ARCHIVED }), + mockModelVersion({ name: 'Test 5', state: ModelState.LIVE }), + ]; + + describe('filterArchiveVersions', () => { + it('should filter out only the archived versions', () => { + const archivedVersions = filterArchiveVersions(modelVersions); + expect(archivedVersions).toEqual([modelVersions[0], modelVersions[2], modelVersions[3]]); + }); + + it('should return an empty array if the input array is empty', () => { + const result = filterArchiveVersions([]); + expect(result).toEqual([]); + }); + }); + + describe('filterLiveVersions', () => { + it('should filter out only the live versions', () => { + const liveVersions = filterLiveVersions(modelVersions); + expect(liveVersions).toEqual([modelVersions[1], modelVersions[4]]); + }); + + it('should return an empty array if the input array is empty', () => { + const result = filterLiveVersions([]); + expect(result).toEqual([]); + }); + }); +}); diff --git a/clients/ui/frontend/src/app/context/ModelRegistrySelectorContext.tsx b/clients/ui/frontend/src/app/context/ModelRegistrySelectorContext.tsx index 9b1309dbf..941881b91 100644 --- a/clients/ui/frontend/src/app/context/ModelRegistrySelectorContext.tsx +++ b/clients/ui/frontend/src/app/context/ModelRegistrySelectorContext.tsx @@ -9,6 +9,7 @@ export type ModelRegistrySelectorContextType = { modelRegistries: ModelRegistry[]; preferredModelRegistry: ModelRegistry | undefined; updatePreferredModelRegistry: (modelRegistry: ModelRegistry | undefined) => void; + //refreshRulesReview: () => void; TODO: [Midstream] Reimplement this }; type ModelRegistrySelectorContextProviderProps = { @@ -21,6 +22,7 @@ export const ModelRegistrySelectorContext = React.createContext undefined, + //refreshRulesReview: () => undefined, }); export const ModelRegistrySelectorContextProvider: React.FC< @@ -34,6 +36,8 @@ export const ModelRegistrySelectorContextProvider: React.FC< const EnabledModelRegistrySelectorContextProvider: React.FC< ModelRegistrySelectorContextProviderProps > = ({ children }) => { + // TODO: [Midstream] Add area check for enablement + const queryParams = useQueryParamNamespaces(); const [modelRegistries, isLoaded, error] = useModelRegistries(queryParams); @@ -49,6 +53,7 @@ const EnabledModelRegistrySelectorContextProvider: React.FC< modelRegistries, preferredModelRegistry: preferredModelRegistry ?? firstModelRegistry ?? undefined, updatePreferredModelRegistry: setPreferredModelRegistry, + // refreshRulesReview, }), [isLoaded, error, modelRegistries, preferredModelRegistry, firstModelRegistry], ); diff --git a/clients/ui/frontend/src/app/hooks/__tests__/useModelArtifactsByVersionId.spec.ts b/clients/ui/frontend/src/app/hooks/__tests__/useModelArtifactsByVersionId.spec.ts index aefe82671..ebaca234a 100644 --- a/clients/ui/frontend/src/app/hooks/__tests__/useModelArtifactsByVersionId.spec.ts +++ b/clients/ui/frontend/src/app/hooks/__tests__/useModelArtifactsByVersionId.spec.ts @@ -19,11 +19,13 @@ const mockModelRegistryAPIs: ModelRegistryAPIs = { createModelArtifactForModelVersion: jest.fn(), getRegisteredModel: jest.fn(), getModelVersion: jest.fn(), + listModelVersions: jest.fn(), listRegisteredModels: jest.fn(), getModelVersionsByRegisteredModel: jest.fn(), getModelArtifactsByModelVersion: jest.fn(), patchRegisteredModel: jest.fn(), patchModelVersion: jest.fn(), + patchModelArtifact: jest.fn(), }; describe('useModelArtifactsByVersionId', () => { @@ -47,7 +49,7 @@ describe('useModelArtifactsByVersionId', () => { }); }); - it('should return NotReadyError if modelVersionId is not provided', async () => { + it('should silently fail if modelVersionId is not provided', async () => { mockUseModelRegistryAPI.mockReturnValue({ api: mockModelRegistryAPIs, apiAvailable: true, @@ -58,8 +60,7 @@ describe('useModelArtifactsByVersionId', () => { await waitFor(() => { const [, , error] = result.current; - expect(error?.message).toBe('No model registeredModel id'); - expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe(undefined); }); }); diff --git a/clients/ui/frontend/src/app/hooks/useModelArtifactsByVersionId.ts b/clients/ui/frontend/src/app/hooks/useModelArtifactsByVersionId.ts index ffe8ee241..962984b8f 100644 --- a/clients/ui/frontend/src/app/hooks/useModelArtifactsByVersionId.ts +++ b/clients/ui/frontend/src/app/hooks/useModelArtifactsByVersionId.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import useFetchState, { FetchState, FetchStateCallbackPromise, + NotReadyError, } from '~/shared/utilities/useFetchState'; import { ModelArtifactList } from '~/app/types'; import { useModelRegistryAPI } from '~/app/hooks/useModelRegistryAPI'; @@ -14,7 +15,7 @@ const useModelArtifactsByVersionId = (modelVersionId?: string): FetchState => { + const { api, apiAvailable } = useModelRegistryAPI(); + const callback = React.useCallback>( + (opts) => { + if (!apiAvailable) { + return Promise.reject(new Error('API not yet available')); + } + return api.listModelVersions(opts).then((r) => r); + }, + [api, apiAvailable], + ); + return useFetchState( + callback, + { items: [], size: 0, pageSize: 0, nextPageToken: '' }, + { initialPromisePurity: true }, + ); +}; + +export default useModelVersions; diff --git a/clients/ui/frontend/src/app/hooks/useModelVersionsByRegisteredModel.ts b/clients/ui/frontend/src/app/hooks/useModelVersionsByRegisteredModel.ts index c1c1cf6b4..0dd50cd15 100644 --- a/clients/ui/frontend/src/app/hooks/useModelVersionsByRegisteredModel.ts +++ b/clients/ui/frontend/src/app/hooks/useModelVersionsByRegisteredModel.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import useFetchState, { FetchState, FetchStateCallbackPromise, + NotReadyError, } from '~/shared/utilities/useFetchState'; import { ModelVersionList } from '~/app/types'; import { useModelRegistryAPI } from '~/app/hooks/useModelRegistryAPI'; @@ -17,7 +18,7 @@ const useModelVersionsByRegisteredModel = ( return Promise.reject(new Error('API not yet available')); } if (!registeredModelId) { - return Promise.reject(new Error('No model registeredModel id')); + return Promise.reject(new NotReadyError('No model registeredModel id')); } return api.getModelVersionsByRegisteredModel(opts, registeredModelId); diff --git a/clients/ui/frontend/src/app/hooks/useRegisteredModelById.ts b/clients/ui/frontend/src/app/hooks/useRegisteredModelById.ts index b95be220f..01f959a8e 100644 --- a/clients/ui/frontend/src/app/hooks/useRegisteredModelById.ts +++ b/clients/ui/frontend/src/app/hooks/useRegisteredModelById.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import useFetchState, { FetchState, FetchStateCallbackPromise, + NotReadyError, } from '~/shared/utilities/useFetchState'; import { RegisteredModel } from '~/app/types'; import { useModelRegistryAPI } from '~/app/hooks/useModelRegistryAPI'; @@ -15,7 +16,7 @@ const useRegisteredModelById = (registeredModel?: string): FetchState = ({ getInvalidRedirectPath, }) => { const { modelRegistry } = useParams<{ modelRegistry: string }>(); - const { modelRegistriesLoaded, modelRegistriesLoadError, @@ -69,27 +68,7 @@ const ModelRegistryCoreLoader: React.FC = ({ headerIcon={() => ( )} - customAction={ - - - The person who gave you your username, or who helped you to log in for the first - time - - Someone in your IT department or help desk - A project manager or developer - - } - > - - - } + customAction={} /> ), headerContent: null, @@ -104,7 +83,6 @@ const ModelRegistryCoreLoader: React.FC = ({ ); } - // They ended up on a non-valid project path renderStateProps = { empty: true, diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx index f6fd0f486..d23e5feb8 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; +import { isPlatformDefault } from '~/shared/utilities/const'; import ModelRegistry from './screens/ModelRegistry'; import ModelRegistryCoreLoader from './ModelRegistryCoreLoader'; import { modelRegistryUrl } from './screens/routeUtils'; @@ -44,6 +45,14 @@ const ModelRegistryRoutes: React.FC = () => ( path={ModelVersionDetailsTab.DETAILS} element={} /> + {isPlatformDefault() && ( + + } + /> + )} } /> @@ -56,6 +65,18 @@ const ModelRegistryRoutes: React.FC = () => ( } /> + {isPlatformDefault() && ( + + } + /> + )} + } /> } /> @@ -86,6 +107,18 @@ const ModelRegistryRoutes: React.FC = () => ( } /> + {isPlatformDefault() && ( + + } + /> + )} + } /> } /> diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup.tsx index 60a11b82c..115c5cd3f 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup.tsx @@ -55,12 +55,15 @@ const ModelPropertiesDescriptionListGroup: React.FC} iconPosition="start" isDisabled={isAdding || isSavingEdits} - onClick={() => setIsAdding(true)} + onClick={() => { + setIsShowingMoreProperties(true); + setIsAdding(true); + }} > Add property @@ -120,6 +123,7 @@ const ModelPropertiesDescriptionListGroup: React.FC setIsShowingMoreProperties(!isShowingMoreProperties)} > {isShowingMoreProperties diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesTableRow.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesTableRow.tsx index 9cde58d83..7d92932f4 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesTableRow.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesTableRow.tsx @@ -93,12 +93,13 @@ const ModelPropertiesTableRow: React.FC = ({ const propertyKeyInput = ( setUnsavedKey(str)} validated={keyValidationError ? 'error' : 'default'} @@ -107,7 +108,7 @@ const ModelPropertiesTableRow: React.FC = ({ const propertyValueInput = ( = ({ + /> + /> ) : ( @@ -195,6 +194,7 @@ const ModelPropertiesTableRow: React.FC = ({ popperProps={{ direction: 'up' }} items={[ { title: 'Edit', onClick: onEditClick, isDisabled: isSavingEdits }, + { isSeparator: true }, { title: 'Delete', onClick: onDeleteClick, isDisabled: isSavingEdits }, ]} /> diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistry.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistry.tsx index 4c55f9f8d..d089b444f 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistry.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistry.tsx @@ -3,7 +3,7 @@ import ApplicationsPage from '~/shared/components/ApplicationsPage'; import TitleWithIcon from '~/shared/components/design/TitleWithIcon'; import { ProjectObjectType } from '~/shared/components/design/utils'; import useRegisteredModels from '~/app/hooks/useRegisteredModels'; -import { filterLiveModels } from '~/app/pages/modelRegistry/screens/utils'; +import useModelVersions from '~/app/hooks/useModelVersions'; import ModelRegistrySelectorNavigator from './ModelRegistrySelectorNavigator'; import RegisteredModelListView from './RegisteredModels/RegisteredModelListView'; import { modelRegistryUrl } from './routeUtils'; @@ -20,7 +20,16 @@ type ModelRegistryProps = Omit< >; const ModelRegistry: React.FC = ({ ...pageProps }) => { - const [registeredModels, loaded, loadError, refresh] = useRegisteredModels(); + const [registeredModels, modelsLoaded, modelsLoadError, refreshModels] = useRegisteredModels(); + const [modelVersions, versionsLoaded, versionsLoadError, refreshVersions] = useModelVersions(); + + const loaded = modelsLoaded && versionsLoaded; + const loadError = modelsLoadError || versionsLoadError; + + const refresh = React.useCallback(() => { + refreshModels(); + refreshVersions(); + }, [refreshModels, refreshVersions]); return ( = ({ ...pageProps }) => { removeChildrenTopPadding > diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistrySelector.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistrySelector.tsx index 303135b4d..267d9498a 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistrySelector.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistrySelector.tsx @@ -6,23 +6,21 @@ import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, - Divider, Flex, FlexItem, Icon, - MenuToggle, Popover, - Select, - SelectGroup, - SelectList, - SelectOption, + PopoverPosition, Tooltip, } from '@patternfly/react-core'; +import text from '@patternfly/react-styles/css/utilities/Text/text'; import truncateStyles from '@patternfly/react-styles/css/components/Truncate/truncate'; import { InfoCircleIcon, BlueprintIcon } from '@patternfly/react-icons'; import { useBrowserStorage } from '~/shared/components/browserStorage'; import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; import { ModelRegistry } from '~/app/types'; +import SimpleSelect, { SimpleSelectOption } from '~/shared/components/SimpleSelect'; +import WhosMyAdministrator from '~/shared/components/WhosMyAdministrator'; const MODEL_REGISTRY_FAVORITE_STORAGE_KEY = 'kubeflow.dashboard.model.registry.favorite'; @@ -42,7 +40,6 @@ const ModelRegistrySelector: React.FC = ({ ); const selection = modelRegistries.find((mr) => mr.name === modelRegistry); - const [isOpen, setIsOpen] = React.useState(false); const [favorites, setFavorites] = useBrowserStorage( MODEL_REGISTRY_FAVORITE_STORAGE_KEY, [], @@ -53,7 +50,7 @@ const ModelRegistrySelector: React.FC = ({ const toggleLabel = modelRegistries.length === 0 ? 'No model registries' : selectionDisplayName; const getMRSelectDescription = (mr: ModelRegistry) => { - const desc = mr.description || mr.name; + const desc = mr.description || ''; if (!desc) { return; } @@ -74,69 +71,48 @@ const ModelRegistrySelector: React.FC = ({ ); }; - const options = [ - - - {modelRegistries.map((mr) => ( - - {mr.displayName} - - ))} - - , - ]; - - const createFavorites = (favIds: string[]) => { - const favorite: JSX.Element[] = []; + const allOptions: SimpleSelectOption[] = modelRegistries.map((mr) => ({ + key: mr.name, + label: mr.name, + dropdownLabel: mr.displayName, + description: getMRSelectDescription(mr), + isFavorited: favorites.includes(mr.name), + })); - options.forEach((item) => { - if (item.type === SelectList) { - item.props.children.filter( - (child: JSX.Element) => favIds.includes(child.props.value) && favorite.push(child), - ); - } else if (item.type === SelectGroup) { - item.props.children.props.children.filter( - (child: JSX.Element) => favIds.includes(child.props.value) && favorite.push(child), - ); - } else if (favIds.includes(item.props.value)) { - favorite.push(item); - } - }); - - return favorite; - }; + const favoriteOptions = (favIds: string[]) => + allOptions.filter((option) => favIds.includes(option.key)); const selector = ( - + /> ); if (primary) { @@ -166,29 +132,52 @@ const ModelRegistrySelector: React.FC = ({ } return ( - - - - - + + + + + + + + Model registry + + {selector} + {selection && ( - Model registry + + + Description + + {selection.description || 'No description'} + + + + } + > + + - {selector} - {selection && selection.description && ( - - - - - - )} - + )} + + + ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistrySelectorNavigator.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistrySelectorNavigator.tsx index 7c606fd31..262bef4eb 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistrySelectorNavigator.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistrySelectorNavigator.tsx @@ -11,15 +11,17 @@ const ModelRegistrySelectorNavigator: React.FC { const navigate = useNavigate(); - const { modelRegistry } = useParams<{ modelRegistry: string }>(); + const { modelRegistry: currentModelRegistry } = useParams<{ modelRegistry: string }>(); return ( { - navigate(getRedirectPath(modelRegistryName)); + if (modelRegistryName !== currentModelRegistry) { + navigate(getRedirectPath(modelRegistryName)); + } }} - modelRegistry={modelRegistry ?? ''} + modelRegistry={currentModelRegistry ?? ''} /> ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetails.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetails.tsx index 61523b501..53ff6ba22 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetails.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetails.tsx @@ -90,7 +90,7 @@ const ModelVersionsDetails: React.FC = ({ tab, ...page /> - + ) diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx index 740f0538a..8b1863500 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx @@ -1,18 +1,27 @@ import * as React from 'react'; -import { Dropdown, DropdownList, MenuToggle, DropdownItem } from '@patternfly/react-core'; +import { + Dropdown, + DropdownList, + MenuToggle, + DropdownItem, + ButtonVariant, + ActionList, +} from '@patternfly/react-core'; import { useNavigate } from 'react-router'; import { ModelState, ModelVersion } from '~/app/types'; import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; import { ArchiveModelVersionModal } from '~/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal'; -import { modelVersionArchiveDetailsUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; +import { modelVersionListUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; interface ModelVersionsDetailsHeaderActionsProps { mv: ModelVersion; + hasDeployment?: boolean; } const ModelVersionsDetailsHeaderActions: React.FC = ({ mv, + hasDeployment = false, }) => { const { apiState } = React.useContext(ModelRegistryContext); const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); @@ -23,15 +32,15 @@ const ModelVersionsDetailsHeaderActions: React.FC(null); return ( - <> + setOpenActionDropdown(false)} onOpenChange={(open) => setOpenActionDropdown(open)} - popperProps={{ position: 'right' }} + popperProps={{ position: 'right', appendTo: 'inline' }} toggle={(toggleRef) => ( setOpenActionDropdown(!isOpenActionDropdown)} isExpanded={isOpenActionDropdown} @@ -44,41 +53,40 @@ const ModelVersionsDetailsHeaderActions: React.FC setIsArchiveModalOpen(true)} + tooltipProps={ + hasDeployment ? { content: 'Deployed model versions cannot be archived' } : undefined + } ref={tooltipRef} > - Archive version + Archive model version - setIsArchiveModalOpen(false)} - onSubmit={() => - apiState.api - .patchModelVersion( - {}, - { - state: ModelState.ARCHIVED, - }, - mv.id, - ) - .then(() => - navigate( - modelVersionArchiveDetailsUrl( - mv.id, - mv.registeredModelId, - preferredModelRegistry?.name, - ), - ), - ) - } - isOpen={isArchiveModalOpen} - modelVersionName={mv.name} - /> - + {isArchiveModalOpen ? ( + setIsArchiveModalOpen(false)} + onSubmit={() => + apiState.api + .patchModelVersion( + {}, + { + state: ModelState.ARCHIVED, + }, + mv.id, + ) + .then(() => + navigate(modelVersionListUrl(mv.registeredModelId, preferredModelRegistry?.name)), + ) + } + modelVersionName={mv.name} + /> + ) : null} + ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsTabs.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsTabs.tsx index 2747ce088..4aaa05097 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsTabs.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsTabs.tsx @@ -34,8 +34,8 @@ const ModelVersionDetailsTabs: React.FC = ({ data-testid="model-versions-details-tab" > = ({ isArchiveVersion, refresh, }) => { - const [modelArtifact] = useModelArtifactsByVersionId(mv.id); + const [modelArtifacts, modelArtifactsLoaded, modelArtifactsLoadError, refreshModelArtifacts] = + useModelArtifactsByVersionId(mv.id); + + const modelArtifact = modelArtifacts.items.length ? modelArtifacts.items[0] : null; const { apiState } = React.useContext(ModelRegistryContext); - const storageFields = uriToObjectStorageFields(modelArtifact.items[0]?.uri || ''); + const storageFields = uriToObjectStorageFields(modelArtifact?.uri || ''); + + if (!modelArtifactsLoaded) { + return ( + + + + ); + } + const handleVersionUpdate = async (updatePromise: Promise): Promise => { + await updatePromise; + + if (!mv.registeredModelId) { + return; + } + + await bumpRegisteredModelTimestamp(apiState.api, mv.registeredModelId); + refresh(); + }; + + const handleArtifactUpdate = async (updatePromise: Promise): Promise => { + try { + await updatePromise; + await bumpBothTimestamps(apiState.api, mv.id, mv.registeredModelId); + refreshModelArtifacts(); + } catch (error) { + throw new Error( + `Failed to update artifact: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; return ( = ({ - apiState.api - .patchModelVersion( - {}, - { - description: value, - }, - mv.id, - ) - .then(refresh) + handleVersionUpdate(apiState.api.patchModelVersion({}, { description: value }, mv.id)) } /> - apiState.api - .patchModelVersion( + title="Labels" + contentWhenEmpty="No labels" + onLabelsChange={(editedLabels) => + handleVersionUpdate( + apiState.api.patchModelVersion( {}, - { - customProperties: mergeUpdatedLabels(mv.customProperties, editedLabels), - }, + { customProperties: mergeUpdatedLabels(mv.customProperties, editedLabels) }, mv.id, - ) - .then(refresh) + ), + ) } + data-testid="model-version-labels" /> = ({ - + <Title style={{ margin: '1em 0' }} headingLevel={ContentVariants.h3}> Model location - - {storageFields && ( - <> - - - - - - - - - - - - - - )} - {!storageFields && ( - <> - - - - - )} - - {modelArtifact.items[0]?.modelFormatName} - + {modelArtifactsLoadError ? ( + + {modelArtifactsLoadError.message} + + ) : ( + <> + + {storageFields && ( + <> + + + + + + + + + + + + + + )} + {!storageFields && ( + <> + + + + + )} + + + + Source model format + + + + handleArtifactUpdate( + apiState.api.patchModelArtifact( + {}, + { modelFormatName: value }, + modelArtifact?.id || '', + ), + ) + } + title="Model Format" + contentWhenEmpty="No model format specified" + /> + + handleArtifactUpdate( + apiState.api.patchModelArtifact( + {}, + { modelFormatVersion: newVersion }, + modelArtifact?.id || '', + ), + ) + } + title="Version" + contentWhenEmpty="No source model format version" + /> + + + )} + + - } + popover="The author is the user who registered the model version." > {mv.author} diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionSelector.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionSelector.tsx index 119b9a843..bdf2556b9 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionSelector.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionSelector.tsx @@ -14,6 +14,7 @@ import { } from '@patternfly/react-core'; import { ModelVersion } from '~/app/types'; import useModelVersionsByRegisteredModel from '~/app/hooks/useModelVersionsByRegisteredModel'; +import { filterLiveVersions } from '~/app/utils'; type ModelVersionSelectorProps = { rmId?: string; @@ -33,8 +34,9 @@ const ModelVersionSelector: React.FC = ({ const menuRef = React.useRef(null); const [modelVersions] = useModelVersionsByRegisteredModel(rmId); + const liveModelVersions = filterLiveVersions(modelVersions.items); - const menuListItems = modelVersions.items + const menuListItems = liveModelVersions .filter((item) => !input || item.name.toLowerCase().includes(input.toString().toLowerCase())) .map((mv, index) => ( @@ -42,7 +44,7 @@ const ModelVersionSelector: React.FC = ({ )); - if (input && modelVersions.size === 0) { + if (input && liveModelVersions.length === 0) { menuListItems.push( No results found @@ -74,8 +76,8 @@ const ModelVersionSelector: React.FC = ({ /> - - {`Type a name to search your ${modelVersions.size} versions.`} + + {`Type a name to search your ${liveModelVersions.length} versions.`} diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/const.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/const.ts index ded505d14..30b76e22b 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/const.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/const.ts @@ -1,7 +1,9 @@ export enum ModelVersionDetailsTab { DETAILS = 'details', + DEPLOYMENTS = 'deployments', } export enum ModelVersionDetailsTabTitle { DETAILS = 'Details', + DEPLOYMENTS = 'Deployments', } diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelDetailsView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelDetailsView.tsx index 0f97fc080..89b8b280d 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelDetailsView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelDetailsView.tsx @@ -3,7 +3,7 @@ import { ClipboardCopy, DescriptionList, Flex, FlexItem, Content } from '@patter import { RegisteredModel } from '~/app/types'; import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; import EditableTextDescriptionListGroup from '~/shared/components/EditableTextDescriptionListGroup'; -import EditableLabelsDescriptionListGroup from '~/shared/components/EditableLabelsDescriptionListGroup'; +import { EditableLabelsDescriptionListGroup } from '~/shared/components/EditableLabelsDescriptionListGroup'; import { getLabels, mergeUpdatedLabels } from '~/app/pages/modelRegistry/screens/utils'; import ModelPropertiesDescriptionListGroup from '~/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup'; import DashboardDescriptionListGroup from '~/shared/components/DashboardDescriptionListGroup'; @@ -30,6 +30,7 @@ const ModelDetailsView: React.FC = ({ = ({ labels={getLabels(rm.customProperties)} isArchive={isArchiveModel} allExistingKeys={Object.keys(rm.customProperties)} - saveEditedLabels={(editedLabels) => + title="Labels" + contentWhenEmpty="No labels" + onLabelsChange={(editedLabels) => apiState.api .patchRegisteredModel( {}, @@ -86,7 +89,10 @@ const ModelDetailsView: React.FC = ({ {rm.id} - + {rm.owner || '-'} diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx index fd927bddb..b8a11ce4f 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx @@ -30,25 +30,31 @@ import { modelVersionArchiveUrl, registerVersionForModelUrl, } from '~/app/pages/modelRegistry/screens/routeUtils'; -import { asEnumMember } from '~/app/utils'; +import { asEnumMember } from '~/shared/utilities/utils'; import ModelVersionsTable from '~/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTable'; import SimpleSelect from '~/shared/components/SimpleSelect'; import FormFieldset from '~/app/pages/modelRegistry/screens/components/FormFieldset'; import { isMUITheme } from '~/shared/utilities/const'; +import { filterArchiveVersions, filterLiveVersions } from '~/app/utils'; type ModelVersionListViewProps = { modelVersions: ModelVersion[]; - registeredModel?: RegisteredModel; + registeredModel: RegisteredModel; isArchiveModel?: boolean; refresh: () => void; }; const ModelVersionListView: React.FC = ({ - modelVersions: unfilteredModelVersions, + modelVersions, registeredModel: rm, isArchiveModel, refresh, }) => { + const unfilteredModelVersions = isArchiveModel + ? modelVersions + : filterLiveVersions(modelVersions); + + const archiveModelVersions = filterArchiveVersions(modelVersions); const navigate = useNavigate(); const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); @@ -61,7 +67,7 @@ const ModelVersionListView: React.FC = ({ React.useState(false); const filteredModelVersions = filterModelVersions(unfilteredModelVersions, search, searchType); - const date = rm?.lastUpdateTimeSinceEpoch && new Date(parseInt(rm.lastUpdateTimeSinceEpoch)); + const date = rm.lastUpdateTimeSinceEpoch && new Date(parseInt(rm.lastUpdateTimeSinceEpoch)); if (unfilteredModelVersions.length === 0) { if (isArchiveModel) { @@ -75,7 +81,7 @@ const ModelVersionListView: React.FC = ({ alt="missing version" /> )} - description={`${rm?.name} has no registered versions.`} + description={`${rm.name} has no registered versions.`} /> ); } @@ -89,14 +95,16 @@ const ModelVersionListView: React.FC = ({ alt="missing version" /> )} - description={`${rm?.name} has no registered versions. Register a version to this model.`} + description={`${rm.name} has no registered versions. Register a version to this model.`} primaryActionText="Register new version" - secondaryActionText="View archived versions" primaryActionOnClick={() => { - navigate(registerVersionForModelUrl(rm?.id, preferredModelRegistry?.name)); + navigate(registerVersionForModelUrl(rm.id, preferredModelRegistry?.name)); }} + secondaryActionText={ + archiveModelVersions.length !== 0 ? 'View archived versions' : undefined + } secondaryActionOnClick={() => { - navigate(modelVersionArchiveUrl(rm?.id, preferredModelRegistry?.name)); + navigate(modelVersionArchiveUrl(rm.id, preferredModelRegistry?.name)); }} /> ); @@ -186,9 +194,9 @@ const ModelVersionListView: React.FC = ({ <> } description={} @@ -89,7 +85,7 @@ const ModelVersionsArchiveDetails: React.FC = /> )} - {mv !== null && ( + {mv !== null && isRestoreModalOpen ? ( setIsRestoreModalOpen(false)} onSubmit={() => @@ -103,10 +99,9 @@ const ModelVersionsArchiveDetails: React.FC = ) .then(() => navigate(modelVersionUrl(mv.id, rm?.id, preferredModelRegistry?.name))) } - isOpen={isRestoreModalOpen} modelVersionName={mv.name} /> - )} + ) : null} ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchive.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchive.tsx index 3d57fe309..7bf680180 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchive.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchive.tsx @@ -7,7 +7,7 @@ import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelecto import useRegisteredModelById from '~/app/hooks/useRegisteredModelById'; import useModelVersionsByRegisteredModel from '~/app/hooks/useModelVersionsByRegisteredModel'; import { registeredModelUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; -import { filterArchiveVersions } from '~/app/pages/modelRegistry/screens/utils'; +import { filterArchiveVersions } from '~/app/utils'; import ModelVersionsArchiveListView from './ModelVersionsArchiveListView'; type ModelVersionsArchiveProps = Omit< diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveListView.tsx index 097d60f1a..ad834c5d0 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveListView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveListView.tsx @@ -8,11 +8,11 @@ import { ToolbarItem, ToolbarToggleGroup, } from '@patternfly/react-core'; -import { FilterIcon } from '@patternfly/react-icons'; +import { FilterIcon, SearchIcon } from '@patternfly/react-icons'; import { ModelVersion } from '~/app/types'; import { SearchType } from '~/shared/components/DashboardSearchField'; import SimpleSelect from '~/shared/components/SimpleSelect'; -import { asEnumMember } from '~/app/utils'; +import { asEnumMember } from '~/shared/utilities/utils'; import { filterModelVersions } from '~/app/pages/modelRegistry/screens/utils'; import EmptyModelRegistryState from '~/app/pages/modelRegistry/screens/components/EmptyModelRegistryState'; import FormFieldset from '~/app/pages/modelRegistry/screens/components/FormFieldset'; @@ -38,9 +38,10 @@ const ModelVersionsArchiveListView: React.FC if (unfilteredmodelVersions.length === 0) { return ( ); } diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveTable.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveTable.tsx index 932f50480..d44f87e71 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveTable.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveTable.tsx @@ -22,10 +22,11 @@ const ModelVersionsArchiveTable: React.FC = ({ data={modelVersions} columns={mvColumns} toolbarContent={toolbarContent} - enablePagination="compact" + enablePagination + onClearFilters={clearFilters} emptyTableView={} defaultSortColumn={1} - rowRenderer={(mv: ModelVersion) => ( + rowRenderer={(mv) => ( )} /> diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterModel.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterModel.tsx index 230e9b7cc..a93fc98b8 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterModel.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterModel.tsx @@ -4,6 +4,9 @@ import { BreadcrumbItem, Form, FormGroup, + FormHelperText, + HelperText, + HelperTextItem, PageSection, Stack, StackItem, @@ -17,36 +20,53 @@ import FormFieldset from '~/app/pages/modelRegistry/screens/components/FormField import FormSection from '~/shared/components/pf-overrides/FormSection'; import ApplicationsPage from '~/shared/components/ApplicationsPage'; import { modelRegistryUrl, registeredModelUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; -import { ValueOf } from '~/shared/typeHelpers'; import { isMUITheme } from '~/shared/utilities/const'; -import { useRegisterModelData, RegistrationCommonFormData } from './useRegisterModelData'; -import { isRegisterModelSubmitDisabled, registerModel } from './utils'; -import { useRegistrationCommonState } from './useRegistrationCommonState'; +import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; +import { AppContext } from '~/app/AppContext'; +import { useRegisterModelData } from './useRegisterModelData'; +import { isNameValid, isRegisterModelSubmitDisabled, registerModel } from './utils'; import RegistrationCommonFormSections from './RegistrationCommonFormSections'; import RegistrationFormFooter from './RegistrationFormFooter'; +import { MR_CHARACTER_LIMIT, SubmitLabel } from './const'; +import PrefilledModelRegistryField from './PrefilledModelRegistryField'; const RegisterModel: React.FC = () => { const { modelRegistry: mrName } = useParams(); const navigate = useNavigate(); - - const { isSubmitting, submitError, setSubmitError, handleSubmit, apiState, author } = - useRegistrationCommonState(); - + const { apiState } = React.useContext(ModelRegistryContext); + const { user } = React.useContext(AppContext); + const author = user.userId || ''; + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [submitError, setSubmitError] = React.useState(undefined); const [formData, setData] = useRegisterModelData(); + const isModelNameValid = isNameValid(formData.modelName); const isSubmitDisabled = isSubmitting || isRegisterModelSubmitDisabled(formData); const { modelName, modelDescription } = formData; + const [registeredModelName, setRegisteredModelName] = React.useState(''); + const [versionName, setVersionName] = React.useState(''); + const [errorName, setErrorName] = React.useState(undefined); - const onSubmit = () => - handleSubmit(async () => { - const { registeredModel } = await registerModel(apiState, formData, author); + const handleSubmit = async () => { + setIsSubmitting(true); + setSubmitError(undefined); + + const { + data: { registeredModel, modelVersion, modelArtifact }, + errors, + } = await registerModel(apiState, formData, author); + if (registeredModel && modelVersion && modelArtifact) { navigate(registeredModelUrl(registeredModel.id, mrName)); - }); + } else if (Object.keys(errors).length > 0) { + setIsSubmitting(false); + setRegisteredModelName(formData.modelName); + setVersionName(formData.versionName); + const resourceName = Object.keys(errors)[0]; + setErrorName(resourceName); + setSubmitError(errors[resourceName]); + } + }; const onCancel = () => navigate(modelRegistryUrl(mrName)); - const modelRegistryInput = ( - - ); - const modelNameInput = ( {
- - {isMUITheme() ? ( - - ) : ( - modelRegistryInput - )} - + { ) : ( modelNameInput )} + {!isModelNameValid && ( + + + + Cannot exceed {MR_CHARACTER_LIMIT} characters + + + + )} { , - ) => setData(propKey, propValue)} + setData={setData} isFirstVersion /> @@ -137,13 +152,15 @@ const RegisterModel: React.FC = () => {
); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterModelErrors.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterModelErrors.tsx new file mode 100644 index 000000000..d14b8548f --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterModelErrors.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Alert, AlertActionCloseButton, StackItem } from '@patternfly/react-core'; +import { ErrorName, SubmitLabel } from './const'; + +type RegisterModelErrorProp = { + submitLabel: string; + submitError: Error; + errorName?: string; + versionName?: string; + modelName?: string; +}; + +const RegisterModelErrors: React.FC = ({ + submitLabel, + submitError, + errorName, + versionName = '', + modelName = '', +}) => { + const [showAlert, setShowAlert] = React.useState(true); + + if (submitLabel === SubmitLabel.REGISTER_MODEL && errorName === ErrorName.MODEL_VERSION) { + return ( + <> + {showAlert && ( + + setShowAlert(false)} />} + /> + + )} + + + {submitError.message} + + + + ); + } + + if (submitLabel === SubmitLabel.REGISTER_VERSION && errorName === ErrorName.MODEL_VERSION) { + return ( + + + {submitError.message} + + + ); + } + + if (submitLabel === SubmitLabel.REGISTER_MODEL && errorName === ErrorName.MODEL_ARTIFACT) { + return ( + <> + {showAlert && ( + + setShowAlert(false)} />} + /> + + )} + + + {submitError.message} + + + + ); + } + + if (submitLabel === SubmitLabel.REGISTER_VERSION && errorName === ErrorName.MODEL_ARTIFACT) { + return ( + <> + {showAlert && ( + + setShowAlert(false)} />} + /> + + )} + + + {submitError.message} + + + + ); + } + + return ( + + + {submitError.message} + + + ); +}; +export default RegisterModelErrors; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterVersion.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterVersion.tsx index 6a3524588..e25072a8d 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterVersion.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterVersion.tsx @@ -15,27 +15,31 @@ import { Link } from 'react-router-dom'; import ApplicationsPage from '~/shared/components/ApplicationsPage'; import { modelRegistryUrl, registeredModelUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; import useRegisteredModels from '~/app/hooks/useRegisteredModels'; -import { ValueOf } from '~/shared/typeHelpers'; -import { filterLiveModels } from '~/app/pages/modelRegistry/screens/utils'; -import { RegistrationCommonFormData, useRegisterVersionData } from './useRegisterModelData'; +import { filterLiveModels } from '~/app/utils'; +import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; +import { AppContext } from '~/app/AppContext'; +import { useRegisterVersionData } from './useRegisterModelData'; import { isRegisterVersionSubmitDisabled, registerVersion } from './utils'; import RegistrationCommonFormSections from './RegistrationCommonFormSections'; -import { useRegistrationCommonState } from './useRegistrationCommonState'; import PrefilledModelRegistryField from './PrefilledModelRegistryField'; import RegistrationFormFooter from './RegistrationFormFooter'; import RegisteredModelSelector from './RegisteredModelSelector'; import { usePrefillRegisterVersionFields } from './usePrefillRegisterVersionFields'; +import { SubmitLabel } from './const'; const RegisterVersion: React.FC = () => { const { modelRegistry: mrName, registeredModelId: prefilledRegisteredModelId } = useParams(); - const navigate = useNavigate(); - - const { isSubmitting, submitError, setSubmitError, handleSubmit, apiState, author } = - useRegistrationCommonState(); - + const { apiState } = React.useContext(ModelRegistryContext); + const { user } = React.useContext(AppContext); + const author = user.userId || ''; + const [isSubmitting, setIsSubmitting] = React.useState(false); const [formData, setData] = useRegisterVersionData(prefilledRegisteredModelId); const isSubmitDisabled = isSubmitting || isRegisterVersionSubmitDisabled(formData); + const [submitError, setSubmitError] = React.useState(undefined); + const [errorName, setErrorName] = React.useState(undefined); + const [versionName, setVersionName] = React.useState(''); + const { registeredModelId } = formData; const [allRegisteredModels, loadedRegisteredModels, loadRegisteredModelsError] = @@ -49,15 +53,29 @@ const RegisterVersion: React.FC = () => { setData, }); - const onSubmit = () => { + const handleSubmit = async () => { if (!registeredModel) { return; // We shouldn't be able to hit this due to form validation } - handleSubmit(async () => { - await registerVersion(apiState, registeredModel, formData, author); + setIsSubmitting(true); + setSubmitError(undefined); + + const { + data: { modelVersion, modelArtifact }, + errors, + } = await registerVersion(apiState, registeredModel, formData, author); + + if (modelVersion && modelArtifact) { navigate(registeredModelUrl(registeredModel.id, mrName)); - }); + } else if (Object.keys(errors).length > 0) { + const resourceName = Object.keys(errors)[0]; + setVersionName(formData.versionName); + setErrorName(resourceName); + setSubmitError(errors[resourceName]); + setIsSubmitting(false); + } }; + const onCancel = () => navigate( prefilledRegisteredModelId && registeredModel @@ -101,6 +119,7 @@ const RegisterVersion: React.FC = () => { { , - ) => setData(propKey, propValue)} + setData={setData} isFirstVersion={false} latestVersion={latestVersion} /> @@ -130,13 +146,14 @@ const RegisterVersion: React.FC = () => { ); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisteredModelSelector.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisteredModelSelector.tsx index 9ea05f4e8..80fed1ab7 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisteredModelSelector.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisteredModelSelector.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { FormGroup, TextInput } from '@patternfly/react-core'; -import { TypeaheadSelect, TypeaheadSelectOption } from '@patternfly/react-templates'; import { RegisteredModel } from '~/app/types'; import FormFieldset from '~/app/pages/modelRegistry/screens/components/FormFieldset'; import { isMUITheme } from '~/shared/utilities/const'; +import TypeaheadSelect, { TypeaheadSelectOption } from '~/shared/components/TypeaheadSelect'; type RegisteredModelSelectorProps = { registeredModels: RegisteredModel[]; @@ -60,7 +60,8 @@ const RegisteredModelSelector: React.FC = ({ return ( setRegisteredModelId('')} + selectOptions={options} placeholder="Select a registered model" noOptionsFoundMessage={(filter) => `No results found for "${filter}"`} onSelect={(_event, selection) => { diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationCommonFormSections.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationCommonFormSections.tsx index 8a1063259..e8e169477 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationCommonFormSections.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationCommonFormSections.tsx @@ -9,8 +9,8 @@ import { HelperText, HelperTextItem, FormHelperText, - TextInputGroup, - TextInputGroupMain, + InputGroupItem, + InputGroupText, } from '@patternfly/react-core'; import spacing from '@patternfly/react-styles/css/utilities/Spacing/spacing'; import { UpdateObjectAtPropAndValue } from '~/shared/types'; @@ -21,25 +21,33 @@ import FormSection from '~/shared/components/pf-overrides/FormSection'; import { ModelVersion } from '~/app/types'; import { isMUITheme } from '~/shared/utilities/const'; import { ModelLocationType, RegistrationCommonFormData } from './useRegisterModelData'; +import { isNameValid } from './utils'; +import { MR_CHARACTER_LIMIT } from './const'; // import { ConnectionModal } from './ConnectionModal'; -type RegistrationCommonFormSectionsProps = { - formData: RegistrationCommonFormData; - setData: UpdateObjectAtPropAndValue; +type RegistrationCommonFormSectionsProps = { + formData: D; + setData: UpdateObjectAtPropAndValue; isFirstVersion: boolean; latestVersion?: ModelVersion; }; -const RegistrationCommonFormSections: React.FC = ({ +const RegistrationCommonFormSections = ({ formData, setData, isFirstVersion, latestVersion, -}) => { - // TODO: [Data connections] Check wether we should use data connections +}: RegistrationCommonFormSectionsProps): React.ReactNode => { // const [isAutofillModalOpen, setAutofillModalOpen] = React.useState(false); + const isVersionNameValid = isNameValid(formData.versionName); - // const connectionDataMap: Record = { + // const connectionDataMap: Record< + // string, + // keyof Pick< + // RegistrationCommonFormData, + // 'modelLocationEndpoint' | 'modelLocationBucket' | 'modelLocationRegion' + // > + // > = { // AWS_S3_ENDPOINT: 'modelLocationEndpoint', // AWS_S3_BUCKET: 'modelLocationBucket', // AWS_DEFAULT_REGION: 'modelLocationRegion', @@ -72,6 +80,7 @@ const RegistrationCommonFormSections: React.FC setData('versionName', value)} + validated={isVersionNameValid ? 'default' : 'error'} /> ); @@ -140,16 +149,16 @@ const RegistrationCommonFormSections: React.FC - + setData('modelLocationPath', value)} /> - + ); const uriInput = ( @@ -179,19 +188,22 @@ const RegistrationCommonFormSections: React.FC - {latestVersion && ( - - Current version is {latestVersion.name} - + {latestVersion && ( + + Current version is {latestVersion.name} + + )} + {!isVersionNameValid && ( + + + Cannot exceed {MR_CHARACTER_LIMIT} characters + + + )} - )} - + + {isMUITheme() ? ( ) : ( @@ -267,46 +279,50 @@ const RegistrationCommonFormSections: React.FC : regionInput} - {isMUITheme() ? : pathInput} - - + + + / + + + {isMUITheme() ? : pathInput} + + Enter a path to a model or folder. This path cannot point to a root folder. - - - )} - - - { - setData('modelLocationType', ModelLocationType.URI); - }} - label="URI" - id="location-type-uri" - /> - - - {modelLocationType === ModelLocationType.URI && ( - <> - - {isMUITheme() ? : uriInput} )} + { + setData('modelLocationType', ModelLocationType.URI); + }} + label="URI" + id="location-type-uri" + body={ + modelLocationType === ModelLocationType.URI && ( + <> + + {isMUITheme() ? : uriInput} + + + ) + } + /> - {/* setAutofillModalOpen(false)} - onSubmit={(connection) => { - fillObjectStorageByConnection(connection); - setAutofillModalOpen(false); - }} - /> */} + {/* {isAutofillModalOpen ? ( + setAutofillModalOpen(false)} + onSubmit={(connection) => { + fillObjectStorageByConnection(connection); + setAutofillModalOpen(false); + }} + /> + ) : null} */} ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationFormFooter.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationFormFooter.tsx index 86ca64789..3430df9ef 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationFormFooter.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationFormFooter.tsx @@ -3,61 +3,74 @@ import { PageSection, Stack, StackItem, - Alert, - AlertActionCloseButton, - ActionGroup, Button, + ActionList, + ActionListItem, + ActionListGroup, } from '@patternfly/react-core'; +import RegisterModelErrors from './RegisterModelErrors'; type RegistrationFormFooterProps = { submitLabel: string; submitError?: Error; - setSubmitError: (e?: Error) => void; isSubmitDisabled: boolean; isSubmitting: boolean; onSubmit: () => void; onCancel: () => void; + errorName?: string; + versionName?: string; + modelName?: string; }; const RegistrationFormFooter: React.FC = ({ submitLabel, submitError, - setSubmitError, isSubmitDisabled, isSubmitting, onSubmit, onCancel, + errorName, + versionName, + modelName, }) => ( {submitError && ( - - setSubmitError(undefined)} />} - > - {submitError.message} - - + )} - - - - + + + + + + + + + + diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/const.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/const.ts new file mode 100644 index 000000000..a2ca8196b --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/const.ts @@ -0,0 +1,12 @@ +export const MR_CHARACTER_LIMIT = 128; + +export enum SubmitLabel { + REGISTER_MODEL = 'Register model', + REGISTER_VERSION = 'Register new version', +} + +export enum ErrorName { + REGISTERED_MODEL = 'registeredModel', + MODEL_VERSION = 'modelVersion', + MODEL_ARTIFACT = 'modelArtifact', +} diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/usePrefillRegisterVersionFields.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/usePrefillRegisterVersionFields.ts index be9e3601b..61538c5f1 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/usePrefillRegisterVersionFields.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/usePrefillRegisterVersionFields.ts @@ -1,10 +1,6 @@ import React from 'react'; import { RegisteredModel, ModelVersion, ModelArtifact } from '~/app/types'; -import { - filterLiveVersions, - getLastCreatedItem, - uriToObjectStorageFields, -} from '~/app/pages/modelRegistry/screens/utils'; +import { filterLiveVersions, getLastCreatedItem, uriToObjectStorageFields } from '~/app/utils'; import { UpdateObjectAtPropAndValue } from '~/shared/types'; import useModelArtifactsByVersionId from '~/app/hooks/useModelArtifactsByVersionId'; import useModelVersionsByRegisteredModel from '~/app/hooks/useModelVersionsByRegisteredModel'; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts deleted file mode 100644 index 8d3510d68..000000000 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; -import { ModelRegistryAPIState } from '~/app/hooks/useModelRegistryAPIState'; -import useUser from '~/app/hooks/useUser'; - -type RegistrationCommonState = { - isSubmitting: boolean; - setIsSubmitting: React.Dispatch>; - submitError: Error | undefined; - setSubmitError: React.Dispatch>; - handleSubmit: (doSubmit: () => Promise) => void; - apiState: ModelRegistryAPIState; - author: string; -}; - -export const useRegistrationCommonState = (): RegistrationCommonState => { - const [isSubmitting, setIsSubmitting] = React.useState(false); - const [submitError, setSubmitError] = React.useState(undefined); - - const { apiState } = React.useContext(ModelRegistryContext); - const { userId } = useUser(); - - const handleSubmit = (doSubmit: () => Promise) => { - setIsSubmitting(true); - setSubmitError(undefined); - doSubmit().catch((e: Error) => { - setIsSubmitting(false); - setSubmitError(e); - }); - }; - - return { - isSubmitting, - setIsSubmitting, - submitError, - setSubmitError, - handleSubmit, - apiState, - author: userId, - }; -}; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts index 22fef4d04..bf6ffc282 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts @@ -6,45 +6,60 @@ import { RegisteredModel, } from '~/app/types'; import { ModelRegistryAPIState } from '~/app/hooks/useModelRegistryAPIState'; -import { objectStorageFieldsToUri } from '~/app/pages/modelRegistry/screens/utils'; +import { objectStorageFieldsToUri } from '~/app/utils'; import { ModelLocationType, RegisterModelFormData, RegisterVersionFormData, RegistrationCommonFormData, } from './useRegisterModelData'; +import { ErrorName, MR_CHARACTER_LIMIT } from './const'; export type RegisterModelCreatedResources = RegisterVersionCreatedResources & { - registeredModel: RegisteredModel; + registeredModel?: RegisteredModel; }; export type RegisterVersionCreatedResources = { - modelVersion: ModelVersion; - modelArtifact: ModelArtifact; + modelVersion?: ModelVersion; + modelArtifact?: ModelArtifact; }; export const registerModel = async ( apiState: ModelRegistryAPIState, formData: RegisterModelFormData, author: string, -): Promise => { - const registeredModel = await apiState.api.createRegisteredModel( - {}, - { - name: formData.modelName, - description: formData.modelDescription, - customProperties: {}, - owner: author, - state: ModelState.LIVE, - }, - ); - const { modelVersion, modelArtifact } = await registerVersion( - apiState, - registeredModel, - formData, - author, - ); - return { registeredModel, modelVersion, modelArtifact }; +): Promise<{ + data: RegisterModelCreatedResources; + errors: { [key: string]: Error | undefined }; +}> => { + let registeredModel; + const error: { [key: string]: Error | undefined } = {}; + try { + registeredModel = await apiState.api.createRegisteredModel( + {}, + { + name: formData.modelName, + description: formData.modelDescription, + customProperties: {}, + owner: author, + state: ModelState.LIVE, + }, + ); + } catch (e) { + if (e instanceof Error) { + error[ErrorName.REGISTERED_MODEL] = e; + } + return { data: { registeredModel }, errors: error }; + } + const { + data: { modelVersion, modelArtifact }, + errors, + } = await registerVersion(apiState, registeredModel, formData, author); + + return { + data: { registeredModel, modelVersion, modelArtifact }, + errors, + }; }; export const registerVersion = async ( @@ -52,39 +67,58 @@ export const registerVersion = async ( registeredModel: RegisteredModel, formData: Omit, author: string, -): Promise => { - const modelVersion = await apiState.api.createModelVersionForRegisteredModel( - {}, - registeredModel.id, - { +): Promise<{ + data: RegisterVersionCreatedResources; + errors: { [key: string]: Error | undefined }; +}> => { + let modelVersion; + let modelArtifact; + const errors: { [key: string]: Error | undefined } = {}; + try { + modelVersion = await apiState.api.createModelVersionForRegisteredModel({}, registeredModel.id, { name: formData.versionName, description: formData.versionDescription, customProperties: {}, state: ModelState.LIVE, author, registeredModelId: registeredModel.id, - }, - ); - const modelArtifact = await apiState.api.createModelArtifactForModelVersion({}, modelVersion.id, { - name: `${registeredModel.name}-${formData.versionName}-artifact`, - description: formData.versionDescription, - customProperties: {}, - state: ModelArtifactState.LIVE, - author, - modelFormatName: formData.sourceModelFormat, - modelFormatVersion: formData.sourceModelFormatVersion, - uri: - formData.modelLocationType === ModelLocationType.ObjectStorage - ? objectStorageFieldsToUri({ - endpoint: formData.modelLocationEndpoint, - bucket: formData.modelLocationBucket, - region: formData.modelLocationRegion, - path: formData.modelLocationPath, - }) || '' // We'll only hit this case if required fields are empty strings, so form validation should catch it. - : formData.modelLocationURI, - artifactType: 'model-artifact', - }); - return { modelVersion, modelArtifact }; + }); + } catch (e) { + if (e instanceof Error) { + errors[ErrorName.MODEL_VERSION] = e; + } + return { data: { modelVersion, modelArtifact }, errors }; + } + + try { + modelArtifact = await apiState.api.createModelArtifactForModelVersion({}, modelVersion.id, { + name: `${formData.versionName}`, + description: formData.versionDescription, + customProperties: {}, + state: ModelArtifactState.LIVE, + author, + modelFormatName: formData.sourceModelFormat, + modelFormatVersion: formData.sourceModelFormatVersion, + // TODO fill in the name of the data connection we used to prefill if we used one + // storageKey: 'TODO', + uri: + formData.modelLocationType === ModelLocationType.ObjectStorage + ? objectStorageFieldsToUri({ + endpoint: formData.modelLocationEndpoint, + bucket: formData.modelLocationBucket, + region: formData.modelLocationRegion, + path: formData.modelLocationPath, + }) || '' // We'll only hit this case if required fields are empty strings, so form validation should catch it. + : formData.modelLocationURI, + artifactType: 'model-artifact', + }); + } catch (e) { + if (e instanceof Error) { + errors[ErrorName.MODEL_ARTIFACT] = e; + } + } + + return { data: { modelVersion, modelArtifact }, errors }; }; const isSubmitDisabledForCommonFields = (formData: RegistrationCommonFormData): boolean => { @@ -100,12 +134,17 @@ const isSubmitDisabledForCommonFields = (formData: RegistrationCommonFormData): !versionName || (modelLocationType === ModelLocationType.URI && !modelLocationURI) || (modelLocationType === ModelLocationType.ObjectStorage && - (!modelLocationBucket || !modelLocationEndpoint || !modelLocationPath)) + (!modelLocationBucket || !modelLocationEndpoint || !modelLocationPath)) || + !isNameValid(versionName) ); }; export const isRegisterModelSubmitDisabled = (formData: RegisterModelFormData): boolean => - !formData.modelName || isSubmitDisabledForCommonFields(formData); + !formData.modelName || + isSubmitDisabledForCommonFields(formData) || + !isNameValid(formData.modelName); export const isRegisterVersionSubmitDisabled = (formData: RegisterVersionFormData): boolean => !formData.registeredModelId || isSubmitDisabledForCommonFields(formData); + +export const isNameValid = (name: string): boolean => name.length <= MR_CHARACTER_LIMIT; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx index 4dbfcbeb1..1d921acbb 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx @@ -8,11 +8,10 @@ import { } from '@patternfly/react-core'; import { FilterIcon } from '@patternfly/react-icons'; import { useNavigate } from 'react-router-dom'; -import { RegisteredModel } from '~/app/types'; +import { ModelVersion, RegisteredModel } from '~/app/types'; import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; import { SearchType } from '~/shared/components/DashboardSearchField'; import { ProjectObjectType, typedEmptyImage } from '~/shared/components/design/utils'; -import { asEnumMember, filterRegisteredModels } from '~/app/utils'; import SimpleSelect from '~/shared/components/SimpleSelect'; import { registeredModelArchiveUrl, @@ -21,23 +20,29 @@ import { import EmptyModelRegistryState from '~/app/pages/modelRegistry/screens/components/EmptyModelRegistryState'; import FormFieldset from '~/app/pages/modelRegistry/screens/components/FormFieldset'; import { isMUITheme } from '~/shared/utilities/const'; +import { filterRegisteredModels } from '~/app/pages/modelRegistry/screens/utils'; +import { asEnumMember } from '~/shared/utilities/utils'; +import { filterArchiveModels, filterLiveModels } from '~/app/utils'; import RegisteredModelTable from './RegisteredModelTable'; import RegisteredModelsTableToolbar from './RegisteredModelsTableToolbar'; type RegisteredModelListViewProps = { registeredModels: RegisteredModel[]; + modelVersions: ModelVersion[]; refresh: () => void; }; const RegisteredModelListView: React.FC = ({ - registeredModels: unfilteredRegisteredModels, + registeredModels, + modelVersions, refresh, }) => { const navigate = useNavigate(); const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); const [searchType, setSearchType] = React.useState(SearchType.KEYWORD); const [search, setSearch] = React.useState(''); - + const unfilteredRegisteredModels = filterLiveModels(registeredModels); + const archiveRegisteredModels = filterArchiveModels(registeredModels); const searchTypes = React.useMemo(() => [SearchType.KEYWORD, SearchType.OWNER], []); if (unfilteredRegisteredModels.length === 0) { @@ -51,9 +56,13 @@ const RegisteredModelListView: React.FC = ({ alt="missing model" /> )} - description={`${preferredModelRegistry?.name} has no active registered models. Register a model in this registry, or select a different registry.`} + description={`${ + preferredModelRegistry?.name ?? '' + } has no active registered models. Register a model in this registry, or select a different registry.`} primaryActionText="Register model" - secondaryActionText="View archived models" + secondaryActionText={ + archiveRegisteredModels.length !== 0 ? 'View archived models' : undefined + } primaryActionOnClick={() => { navigate(registerModelUrl(preferredModelRegistry?.name)); }} @@ -66,6 +75,7 @@ const RegisteredModelListView: React.FC = ({ const filteredRegisteredModels = filterRegisteredModels( unfilteredRegisteredModels, + modelVersions, search, searchType, ); @@ -78,8 +88,8 @@ const RegisteredModelListView: React.FC = ({ setSearch('')} - deleteLabelGroup={() => setSearch('')} + deleteLabel={resetFilters} + deleteLabelGroup={resetFilters} categoryName="Keyword" > = ({ icon={} /> - + {isMUITheme() ? ( = ({ onChange={(_, searchValue) => { setSearch(searchValue); }} - onClear={() => setSearch('')} + onClear={resetFilters} style={{ minWidth: '200px' }} data-testid="registered-model-table-search" /> @@ -136,7 +146,12 @@ const RegisteredModelListView: React.FC = ({ refresh={refresh} clearFilters={resetFilters} registeredModels={filteredRegisteredModels} - toolbarContent={} + toolbarContent={ + + } /> ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx index 6ef939886..c842fc1e4 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx @@ -23,10 +23,16 @@ const RegisteredModelTable: React.FC = ({ columns={rmColumns} toolbarContent={toolbarContent} defaultSortColumn={2} - enablePagination="compact" + onClearFilters={clearFilters} + enablePagination emptyTableView={} rowRenderer={(rm) => ( - + )} /> ); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx index feab14d6c..cc5e3dd91 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx @@ -19,12 +19,14 @@ import { ModelVersionsTab } from '~/app/pages/modelRegistry/screens/ModelVersion type RegisteredModelTableRowProps = { registeredModel: RegisteredModel; isArchiveRow?: boolean; + hasDeploys?: boolean; refresh: () => void; }; const RegisteredModelTableRow: React.FC = ({ registeredModel: rm, isArchiveRow, + hasDeploys = false, refresh, }) => { const { apiState } = React.useContext(ModelRegistryContext); @@ -49,15 +51,24 @@ const RegisteredModelTableRow: React.FC = ({ } }, }, - isArchiveRow - ? { - title: 'Restore model', - onClick: () => setIsRestoreModalOpen(true), - } - : { - title: 'Archive model', - onClick: () => setIsArchiveModalOpen(true), - }, + ...(isArchiveRow + ? [ + { + title: 'Restore model', + onClick: () => setIsRestoreModalOpen(true), + }, + ] + : [ + { isSeparator: true }, + { + title: 'Archive model', + onClick: () => setIsArchiveModalOpen(true), + isAriaDisabled: hasDeploys, + tooltipProps: hasDeploys + ? { content: 'Models with deployed versions cannot be archived.' } + : undefined, + }, + ]), ]; return ( @@ -95,38 +106,40 @@ const RegisteredModelTableRow: React.FC = ({ - setIsArchiveModalOpen(false)} - onSubmit={() => - apiState.api - .patchRegisteredModel( - {}, - { - state: ModelState.ARCHIVED, - }, - rm.id, - ) - .then(refresh) - } - isOpen={isArchiveModalOpen} - registeredModelName={rm.name} - /> - setIsRestoreModalOpen(false)} - onSubmit={() => - apiState.api - .patchRegisteredModel( - {}, - { - state: ModelState.LIVE, - }, - rm.id, - ) - .then(() => navigate(registeredModelUrl(rm.id, preferredModelRegistry?.name))) - } - isOpen={isRestoreModalOpen} - registeredModelName={rm.name} - /> + {isArchiveModalOpen ? ( + setIsArchiveModalOpen(false)} + onSubmit={() => + apiState.api + .patchRegisteredModel( + {}, + { + state: ModelState.ARCHIVED, + }, + rm.id, + ) + .then(refresh) + } + registeredModelName={rm.name} + /> + ) : null} + {isRestoreModalOpen ? ( + setIsRestoreModalOpen(false)} + onSubmit={() => + apiState.api + .patchRegisteredModel( + {}, + { + state: ModelState.LIVE, + }, + rm.id, + ) + .then(() => navigate(registeredModelUrl(rm.id, preferredModelRegistry?.name))) + } + registeredModelName={rm.name} + /> + ) : null} ); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts index 7ca6bce8e..af228730b 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts @@ -28,9 +28,9 @@ export const rmColumns: SortableData[] = [ label: 'Owner', sortable: true, info: { - tooltip: 'The owner is the user who registered the model.', - tooltipProps: { - isContentLeftAligned: true, + popover: 'The owner is the user who registered the model.', + popoverProps: { + position: 'left', }, }, }, diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx index 3209d51e2..66b3a4677 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx @@ -22,10 +22,12 @@ import { type RegisteredModelsTableToolbarProps = { toggleGroupItems?: React.ReactNode; + onClearAllFilters?: () => void; }; const RegisteredModelsTableToolbar: React.FC = ({ toggleGroupItems: tableToggleGroupItems, + onClearAllFilters, }) => { const navigate = useNavigate(); const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); @@ -35,7 +37,7 @@ const RegisteredModelsTableToolbar: React.FC const tooltipRef = React.useRef(null); return ( - + } breakpoint="xl"> {tableToggleGroupItems} @@ -45,7 +47,7 @@ const RegisteredModelsTableToolbar: React.FC isOpen={isRegisterNewVersionOpen} onSelect={() => setIsRegisterNewVersionOpen(false)} onOpenChange={(isOpen) => setIsRegisterNewVersionOpen(isOpen)} - toggle={(toggleRef: React.Ref) => ( + toggle={(toggleRef) => ( - - {rm.name} - - - - + + {rm.name} + ) } @@ -84,7 +80,7 @@ const RegisteredModelsArchiveDetails: React.FC - {rm !== null && ( + {rm !== null && isRestoreModalOpen ? ( setIsRestoreModalOpen(false)} onSubmit={() => @@ -98,10 +94,9 @@ const RegisteredModelsArchiveDetails: React.FC navigate(registeredModelUrl(rm.id, preferredModelRegistry?.name))) } - isOpen={isRestoreModalOpen} registeredModelName={rm.name} /> - )} + ) : null} ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx index 0311e5cbb..566e6b7e0 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx @@ -3,8 +3,9 @@ import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; import { Link } from 'react-router-dom'; import ApplicationsPage from '~/shared/components/ApplicationsPage'; import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; -import { filterArchiveModels } from '~/app/pages/modelRegistry/screens/utils'; +import { filterArchiveModels } from '~/app/utils'; import useRegisteredModels from '~/app/hooks/useRegisteredModels'; +import useModelVersions from '~/app/hooks/useModelVersions'; import RegisteredModelsArchiveListView from './RegisteredModelsArchiveListView'; type RegisteredModelsArchiveProps = Omit< @@ -14,7 +15,16 @@ type RegisteredModelsArchiveProps = Omit< const RegisteredModelsArchive: React.FC = ({ ...pageProps }) => { const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); - const [registeredModels, loaded, loadError, refresh] = useRegisteredModels(); + const [registeredModels, modelsLoaded, modelsLoadError, refreshModels] = useRegisteredModels(); + const [modelVersions, versionsLoaded, versionsLoadError, refreshVersions] = useModelVersions(); + + const loaded = modelsLoaded && versionsLoaded; + const loadError = modelsLoadError || versionsLoadError; + + const refresh = React.useCallback(() => { + refreshModels(); + refreshVersions(); + }, [refreshModels, refreshVersions]); return ( = ({ ...pa } - title={`Archived models of ${preferredModelRegistry?.name}`} + title={`Archived models of ${preferredModelRegistry?.name ?? ''}`} loadError={loadError} loaded={loaded} provideChildrenPadding > diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx index 74eeb07d7..a49aa4aa5 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx @@ -8,33 +8,35 @@ import { ToolbarItem, ToolbarToggleGroup, } from '@patternfly/react-core'; -import { FilterIcon } from '@patternfly/react-icons'; -import { RegisteredModel } from '~/app/types'; +import { FilterIcon, SearchIcon } from '@patternfly/react-icons'; +import { ModelVersion, RegisteredModel } from '~/app/types'; import { SearchType } from '~/shared/components/DashboardSearchField'; import { filterRegisteredModels } from '~/app/pages/modelRegistry/screens/utils'; import EmptyModelRegistryState from '~/app/pages/modelRegistry/screens/components/EmptyModelRegistryState'; import SimpleSelect from '~/shared/components/SimpleSelect'; -import { asEnumMember } from '~/app/utils'; +import { asEnumMember } from '~/shared/utilities/utils'; import FormFieldset from '~/app/pages/modelRegistry/screens/components/FormFieldset'; import { isMUITheme } from '~/shared/utilities/const'; import RegisteredModelsArchiveTable from './RegisteredModelsArchiveTable'; type RegisteredModelsArchiveListViewProps = { registeredModels: RegisteredModel[]; + modelVersions: ModelVersion[]; refresh: () => void; }; const RegisteredModelsArchiveListView: React.FC = ({ registeredModels: unfilteredRegisteredModels, + modelVersions, refresh, }) => { const [searchType, setSearchType] = React.useState(SearchType.KEYWORD); const [search, setSearch] = React.useState(''); - const searchTypes = [SearchType.KEYWORD, SearchType.AUTHOR]; - + const searchTypes = [SearchType.KEYWORD, SearchType.OWNER]; const filteredRegisteredModels = filterRegisteredModels( unfilteredRegisteredModels, + modelVersions, search, searchType, ); @@ -42,6 +44,7 @@ const RegisteredModelsArchiveListView: React.FC} rowRenderer={(rm) => ( diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/__tests__/utils.spec.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/__tests__/utils.spec.ts index 77c5726eb..7760389e7 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/__tests__/utils.spec.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/__tests__/utils.spec.ts @@ -297,28 +297,39 @@ describe('filterModelVersions', () => { describe('filterRegisteredModels', () => { const registeredModels: RegisteredModel[] = [ - mockRegisteredModel({ name: 'Test 1', state: ModelState.ARCHIVED }), + mockRegisteredModel({ name: 'Test 1', state: ModelState.ARCHIVED, owner: 'Alice' }), mockRegisteredModel({ name: 'Test 2', description: 'Description2', + owner: 'Bob', }), - mockRegisteredModel({ name: 'Test 3', state: ModelState.ARCHIVED }), - mockRegisteredModel({ name: 'Test 4', state: ModelState.ARCHIVED }), - mockRegisteredModel({ name: 'Test 5' }), + mockRegisteredModel({ name: 'Test 3', state: ModelState.ARCHIVED, owner: 'Charlie' }), + mockRegisteredModel({ name: 'Test 4', state: ModelState.ARCHIVED, owner: 'Alice' }), + mockRegisteredModel({ name: 'Test 5', owner: 'Bob' }), ]; test('filters by name', () => { - const filtered = filterRegisteredModels(registeredModels, 'Test 1', SearchType.KEYWORD); + const filtered = filterRegisteredModels(registeredModels, [], 'Test 1', SearchType.KEYWORD); expect(filtered).toEqual([registeredModels[0]]); }); test('filters by description', () => { - const filtered = filterRegisteredModels(registeredModels, 'Description2', SearchType.KEYWORD); + const filtered = filterRegisteredModels( + registeredModels, + [], + 'Description2', + SearchType.KEYWORD, + ); expect(filtered).toEqual([registeredModels[1]]); }); + test('filters by owner', () => { + const filtered = filterRegisteredModels(registeredModels, [], 'Alice', SearchType.OWNER); + expect(filtered).toEqual([registeredModels[0], registeredModels[3]]); + }); + test('does not filter when search is empty', () => { - const filtered = filterRegisteredModels(registeredModels, '', SearchType.KEYWORD); + const filtered = filterRegisteredModels(registeredModels, [], '', SearchType.KEYWORD); expect(filtered).toEqual(registeredModels); }); }); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx index f4e018cb6..8fae89be0 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx @@ -1,34 +1,25 @@ import * as React from 'react'; -import { - Alert, - Form, - FormGroup, - Modal, - ModalBody, - ModalHeader, - TextInput, -} from '@patternfly/react-core'; +import { Flex, FlexItem, Stack, StackItem, TextInput } from '@patternfly/react-core'; +import { Modal } from '@patternfly/react-core/deprecated'; import DashboardModalFooter from '~/shared/components/DashboardModalFooter'; import { useNotification } from '~/app/hooks/useNotification'; interface ArchiveModelVersionModalProps { onCancel: () => void; onSubmit: () => void; - isOpen: boolean; modelVersionName: string; } export const ArchiveModelVersionModal: React.FC = ({ onCancel, onSubmit, - isOpen, modelVersionName, }) => { + const notification = useNotification(); const [isSubmitting, setIsSubmitting] = React.useState(false); const [error, setError] = React.useState(); const [confirmInputValue, setConfirmInputValue] = React.useState(''); const isDisabled = confirmInputValue.trim() !== modelVersionName || isSubmitting; - const notification = useNotification(); const onClose = React.useCallback(() => { setConfirmInputValue(''); @@ -49,34 +40,37 @@ export const ArchiveModelVersionModal: React.FC = } finally { setIsSubmitting(false); } - }, [notification, modelVersionName, onSubmit, onClose]); - - const description = ( - <> - {modelVersionName} will be archived and unavailable for use unless it is restored. -
-
- Type {modelVersionName} to confirm archiving: - - ); + }, [onSubmit, onClose, notification, modelVersionName]); return ( + } data-testid="archive-model-version-modal" > - - -
- {error && ( - - {error.message} - - )} - - {description} + + + {modelVersionName} will be archived and unavailable for use unless it is restored. + + + + + Type {modelVersionName} to confirm archiving: + = } }} /> - -
-
- + +
+ ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx index e5de53054..2a6738ed8 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx @@ -1,27 +1,18 @@ import * as React from 'react'; -import { - Alert, - Form, - FormGroup, - Modal, - ModalBody, - ModalHeader, - TextInput, -} from '@patternfly/react-core'; +import { Flex, FlexItem, Stack, StackItem, TextInput } from '@patternfly/react-core'; +import { Modal } from '@patternfly/react-core/deprecated'; import DashboardModalFooter from '~/shared/components/DashboardModalFooter'; import { useNotification } from '~/app/hooks/useNotification'; interface ArchiveRegisteredModelModalProps { onCancel: () => void; onSubmit: () => void; - isOpen: boolean; registeredModelName: string; } export const ArchiveRegisteredModelModal: React.FC = ({ onCancel, onSubmit, - isOpen, registeredModelName, }) => { const notification = useNotification(); @@ -49,36 +40,38 @@ export const ArchiveRegisteredModelModal: React.FC - {registeredModelName} and all of its versions will be archived and unavailable for use - unless it is restored. -
-
- Type {registeredModelName} to confirm archiving: - - ); + }, [onSubmit, onClose, notification, registeredModelName]); return ( + } data-testid="archive-registered-model-modal" > - - -
- {error && ( - - {error.message} - - )} - - {description} + + + {registeredModelName} and all of its versions will be archived and unavailable for + use unless it is restored. + + + + + Type {registeredModelName} to confirm archiving: + - -
-
- + +
+ ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelLabels.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelLabels.tsx index 10983cc11..081ba5d30 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelLabels.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelLabels.tsx @@ -65,11 +65,11 @@ const ModelLabels: React.FC = ({ name, customProperties }) => ); - const labelModal = ( + const labelModal = isLabelModalOpen ? ( setIsLabelModalOpen(false)} description={ @@ -100,7 +100,7 @@ const ModelLabels: React.FC = ({ name, customProperties }) => {labelsComponent(filteredLabels, '50ch')} - ); + ) : null; if (Object.keys(customProperties).length === 0) { return '-'; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx index e7dc7a341..f1f382564 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx @@ -1,24 +1,22 @@ import * as React from 'react'; -import { Form, Modal, ModalHeader, ModalBody, Alert } from '@patternfly/react-core'; +import { Modal } from '@patternfly/react-core/deprecated'; import DashboardModalFooter from '~/shared/components/DashboardModalFooter'; import { useNotification } from '~/app/hooks/useNotification'; interface RestoreModelVersionModalProps { onCancel: () => void; onSubmit: () => void; - isOpen: boolean; modelVersionName: string; } export const RestoreModelVersionModal: React.FC = ({ onCancel, onSubmit, - isOpen, modelVersionName, }) => { + const notification = useNotification(); const [isSubmitting, setIsSubmitting] = React.useState(false); const [error, setError] = React.useState(); - const notification = useNotification(); const onClose = React.useCallback(() => { onCancel(); @@ -38,40 +36,28 @@ export const RestoreModelVersionModal: React.FC = } finally { setIsSubmitting(false); } - }, [notification, modelVersionName, onSubmit, onClose]); - - const description = ( - <> - {modelVersionName} will be restored and returned to the versions list. - - ); + }, [onSubmit, onClose, notification, modelVersionName]); return ( + } data-testid="restore-model-version-modal" > - - -
- {error && ( - - {error.message} - - )} -
- {description} -
- + {modelVersionName} will be restored and returned to the versions list.
); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx index e1348b4ee..8eb3e23a1 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx @@ -1,19 +1,17 @@ import * as React from 'react'; -import { Alert, Form, ModalHeader, Modal, ModalBody } from '@patternfly/react-core'; +import { Modal } from '@patternfly/react-core/deprecated'; import DashboardModalFooter from '~/shared/components/DashboardModalFooter'; import { useNotification } from '~/app/hooks/useNotification'; interface RestoreRegisteredModelModalProps { onCancel: () => void; onSubmit: () => void; - isOpen: boolean; registeredModelName: string; } export const RestoreRegisteredModelModal: React.FC = ({ onCancel, onSubmit, - isOpen, registeredModelName, }) => { const notification = useNotification(); @@ -38,40 +36,29 @@ export const RestoreRegisteredModelModal: React.FC - {registeredModelName} and all of its versions will be restored and returned to the - registered models list. - - ); + }, [onSubmit, onClose, notification, registeredModelName]); return ( + } data-testid="restore-registered-model-modal" > - - -
- {error && ( - - {error.message} - - )} -
- {description} -
- + {registeredModelName} and all of its versions will be restored and returned to the + registered models list.
); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/routeUtils.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/routeUtils.ts index 30d531ec5..0a6d34862 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/routeUtils.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/routeUtils.ts @@ -1,23 +1,28 @@ -export const modelRegistryUrl = (preferredModelRegistry?: string): string => +export const modelRegistryUrl = (preferredModelRegistry = ''): string => `/model-registry/${preferredModelRegistry}`; export const registeredModelsUrl = (preferredModelRegistry?: string): string => `${modelRegistryUrl(preferredModelRegistry)}/registeredModels`; -export const registeredModelUrl = (rmId?: string, preferredModelRegistry?: string): string => +export const registeredModelUrl = (rmId = '', preferredModelRegistry?: string): string => `${registeredModelsUrl(preferredModelRegistry)}/${rmId}`; export const registeredModelArchiveUrl = (preferredModelRegistry?: string): string => `${registeredModelsUrl(preferredModelRegistry)}/archive`; export const registeredModelArchiveDetailsUrl = ( - rmId?: string, + rmId = '', preferredModelRegistry?: string, ): string => `${registeredModelArchiveUrl(preferredModelRegistry)}/${rmId}`; export const modelVersionListUrl = (rmId?: string, preferredModelRegistry?: string): string => `${registeredModelUrl(rmId, preferredModelRegistry)}/versions`; +export const archiveModelVersionListUrl = ( + rmId?: string, + preferredModelRegistry?: string, +): string => `${registeredModelArchiveDetailsUrl(rmId, preferredModelRegistry)}/versions`; + export const modelVersionUrl = ( mvId: string, rmId?: string, @@ -27,6 +32,12 @@ export const modelVersionUrl = ( export const modelVersionArchiveUrl = (rmId?: string, preferredModelRegistry?: string): string => `${modelVersionListUrl(rmId, preferredModelRegistry)}/archive`; +export const archiveModelVersionDetailsUrl = ( + mvId: string, + rmId?: string, + preferredModelRegistry?: string, +): string => `${archiveModelVersionListUrl(rmId, preferredModelRegistry)}/${mvId}`; + export const modelVersionArchiveDetailsUrl = ( mvId: string, rmId?: string, @@ -44,13 +55,8 @@ export const registerVersionForModelUrl = ( preferredModelRegistry?: string, ): string => `${registeredModelUrl(rmId, preferredModelRegistry)}/registerVersion`; -export const archiveModelVersionListUrl = ( - rmId?: string, - preferredModelRegistry?: string, -): string => `${registeredModelArchiveDetailsUrl(rmId, preferredModelRegistry)}/versions`; - -export const archiveModelVersionDetailsUrl = ( +export const modelVersionDeploymentsUrl = ( mvId: string, rmId?: string, preferredModelRegistry?: string, -): string => `${archiveModelVersionListUrl(rmId, preferredModelRegistry)}/${mvId}`; +): string => `${modelVersionUrl(mvId, rmId, preferredModelRegistry)}/deployments`; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts index 964cd1ea0..4696acaee 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts @@ -3,7 +3,6 @@ import { ModelRegistryCustomProperties, ModelRegistryMetadataType, ModelRegistryStringCustomProperties, - ModelState, ModelVersion, RegisteredModel, } from '~/app/types'; @@ -51,6 +50,12 @@ export const getProperties = ( ): ModelRegistryStringCustomProperties => { const initial: ModelRegistryStringCustomProperties = {}; return Object.keys(customProperties).reduce((acc, key) => { + // _lastModified is a property that is required to update the timestamp on the backend and we have a workaround for it. It should be resolved by + // backend team + if (key === '_lastModified') { + return acc; + } + const prop = customProperties[key]; if (prop.metadataType === ModelRegistryMetadataType.STRING && prop.string_value !== '') { return { ...acc, [key]: prop }; @@ -87,8 +92,10 @@ export const filterModelVersions = ( unfilteredModelVersions: ModelVersion[], search: string, searchType: SearchType, -): ModelVersion[] => - unfilteredModelVersions.filter((mv: ModelVersion) => { +): ModelVersion[] => { + const searchLower = search.toLowerCase(); + + return unfilteredModelVersions.filter((mv: ModelVersion) => { if (!search) { return true; } @@ -96,21 +103,20 @@ export const filterModelVersions = ( switch (searchType) { case SearchType.KEYWORD: return ( - mv.name.toLowerCase().includes(search.toLowerCase()) || - (mv.description && mv.description.toLowerCase().includes(search.toLowerCase())) + mv.name.toLowerCase().includes(searchLower) || + (mv.description && mv.description.toLowerCase().includes(searchLower)) || + getLabels(mv.customProperties).some((label) => label.toLowerCase().includes(searchLower)) ); - case SearchType.AUTHOR: - return ( - mv.author && - (mv.author.toLowerCase().includes(search.toLowerCase()) || - (mv.author && mv.author.toLowerCase().includes(search.toLowerCase()))) - ); + case SearchType.AUTHOR: { + return mv.author && mv.author.toLowerCase().includes(searchLower); + } default: return true; } }); +}; export const sortModelVersionsByCreateTime = (registeredModels: ModelVersion[]): ModelVersion[] => registeredModels.toSorted((a, b) => { @@ -121,82 +127,46 @@ export const sortModelVersionsByCreateTime = (registeredModels: ModelVersion[]): export const filterRegisteredModels = ( unfilteredRegisteredModels: RegisteredModel[], + unfilteredModelVersions: ModelVersion[], search: string, searchType: SearchType, -): RegisteredModel[] => - unfilteredRegisteredModels.filter((rm: RegisteredModel) => { +): RegisteredModel[] => { + const searchLower = search.toLowerCase(); + + return unfilteredRegisteredModels.filter((rm: RegisteredModel) => { if (!search) { return true; } + const modelVersions = unfilteredModelVersions.filter((mv) => mv.registeredModelId === rm.id); + switch (searchType) { - case SearchType.KEYWORD: - return ( - rm.name.toLowerCase().includes(search.toLowerCase()) || - (rm.description && rm.description.toLowerCase().includes(search.toLowerCase())) + case SearchType.KEYWORD: { + const matchesModel = + rm.name.toLowerCase().includes(searchLower) || + (rm.description && rm.description.toLowerCase().includes(searchLower)) || + getLabels(rm.customProperties).some((label) => label.toLowerCase().includes(searchLower)); + + const matchesVersion = modelVersions.some( + (mv: ModelVersion) => + mv.name.toLowerCase().includes(searchLower) || + (mv.description && mv.description.toLowerCase().includes(searchLower)) || + getLabels(mv.customProperties).some((label) => + label.toLowerCase().includes(searchLower), + ), ); - case SearchType.OWNER: - return rm.owner && rm.owner.toLowerCase().includes(search.toLowerCase()); + return matchesModel || matchesVersion; + } + case SearchType.OWNER: { + return rm.owner && rm.owner.toLowerCase().includes(searchLower); + } default: return true; } }); - -export const objectStorageFieldsToUri = (fields: ObjectStorageFields): string | null => { - const { endpoint, bucket, region, path } = fields; - if (!endpoint || !bucket || !path) { - return null; - } - const searchParams = new URLSearchParams(); - searchParams.set('endpoint', endpoint); - if (region) { - searchParams.set('defaultRegion', region); - } - return `s3://${bucket}/${path}?${searchParams.toString()}`; }; -export const getLastCreatedItem = ( - items?: T[], -): T | undefined => - items?.toSorted( - ({ createTimeSinceEpoch: createTimeA }, { createTimeSinceEpoch: createTimeB }) => { - if (!createTimeA || !createTimeB) { - return 0; - } - return Number(createTimeB) - Number(createTimeA); - }, - )[0]; - -export const filterArchiveVersions = (modelVersions: ModelVersion[]): ModelVersion[] => - modelVersions.filter((mv) => mv.state === ModelState.ARCHIVED); - -export const filterLiveVersions = (modelVersions: ModelVersion[]): ModelVersion[] => - modelVersions.filter((mv) => mv.state === ModelState.LIVE); - -export const filterArchiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => - registeredModels.filter((rm) => rm.state === ModelState.ARCHIVED); - -export const filterLiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => - registeredModels.filter((rm) => rm.state === ModelState.LIVE); - -export const uriToObjectStorageFields = (uri: string): ObjectStorageFields | null => { - try { - const urlObj = new URL(uri); - // Some environments include the first token after the protocol (our bucket) in the pathname and some have it as the hostname - const [bucket, ...pathSplit] = `${urlObj.hostname}/${urlObj.pathname}` - .split('/') - .filter(Boolean); - const path = pathSplit.join('/'); - const searchParams = new URLSearchParams(urlObj.search); - const endpoint = searchParams.get('endpoint'); - const region = searchParams.get('defaultRegion'); - if (endpoint && bucket && path) { - return { endpoint, bucket, region: region || undefined, path }; - } - return null; - } catch { - return null; - } -}; +// export const getServerAddress = (resource: ServiceKind): string => +// resource.metadata.annotations?.['routing.opendatahub.io/external-address-rest'] || ''; diff --git a/clients/ui/frontend/src/app/pages/notFound/NotFound.tsx b/clients/ui/frontend/src/app/pages/notFound/NotFound.tsx deleted file mode 100644 index 794b06422..000000000 --- a/clients/ui/frontend/src/app/pages/notFound/NotFound.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import * as React from 'react'; -import { ExclamationTriangleIcon } from '@patternfly/react-icons'; -import { - Button, - EmptyState, - EmptyStateBody, - EmptyStateFooter, - PageSection, -} from '@patternfly/react-core'; -import { useNavigate } from 'react-router-dom'; - -const NotFound: React.FunctionComponent = () => { - function GoHomeBtn() { - const navigate = useNavigate(); - - function handleClick() { - navigate('/'); - } - - return ; - } - - return ( - - - - We didn't find a page that matches the address you navigated to. - - - - - - - ); -}; - -export { NotFound }; diff --git a/clients/ui/frontend/src/app/pages/settings/ModelRegistriesTable.tsx b/clients/ui/frontend/src/app/pages/settings/ModelRegistriesTable.tsx index e05cacd6a..e9096cb3c 100644 --- a/clients/ui/frontend/src/app/pages/settings/ModelRegistriesTable.tsx +++ b/clients/ui/frontend/src/app/pages/settings/ModelRegistriesTable.tsx @@ -9,12 +9,21 @@ type ModelRegistriesTableProps = { }; const ModelRegistriesTable: React.FC = ({ modelRegistries }) => ( - // TODO: [Model Registry RBAC] Add toolbar once we manage permissions + // TODO: [Midstream] Complete once we have permissions } + rowRenderer={(mr) => ( + {}} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onEditRegistry={() => {}} + /> + )} variant="compact" /> ); diff --git a/clients/ui/frontend/src/app/pages/settings/ModelRegistriesTableRow.tsx b/clients/ui/frontend/src/app/pages/settings/ModelRegistriesTableRow.tsx index 6289a6c61..f1bc0b10d 100644 --- a/clients/ui/frontend/src/app/pages/settings/ModelRegistriesTableRow.tsx +++ b/clients/ui/frontend/src/app/pages/settings/ModelRegistriesTableRow.tsx @@ -1,22 +1,92 @@ import React from 'react'; -import { Td, Tr } from '@patternfly/react-table'; +import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; +import { Button, Tooltip } from '@patternfly/react-core'; +import { useNavigate } from 'react-router'; import { ModelRegistry } from '~/app/types'; +import ResourceNameTooltip from '~/shared/components/ResourceNameTooltip'; +import { convertToK8sResourceCommon } from '~/app/utils'; +import { isPlatformDefault } from '~/shared/utilities/const'; +import { ModelRegistryTableRowStatus } from './ModelRegistryTableRowStatus'; type ModelRegistriesTableRowProps = { modelRegistry: ModelRegistry; + // roleBindings: ContextResourceData; // TODO: [Midstream] Filter role bindings for this model registry + onEditRegistry: (obj: ModelRegistry) => void; + onDeleteRegistry: (obj: ModelRegistry) => void; }; -const ModelRegistriesTableRow: React.FC = ({ modelRegistry: mr }) => ( - <> +const ModelRegistriesTableRow: React.FC = ({ + modelRegistry: mr, + // roleBindings, // TODO: [Midstream] Filter role bindings for this model registry + onEditRegistry, + onDeleteRegistry, +}) => { + const navigate = useNavigate(); + const filteredRoleBindings = []; // TODO: [Midstream] Filter role bindings for this model registry + + return ( + + {isPlatformDefault() && ( + + )} + {isPlatformDefault() && ( + + )} - -); - -// TODO: [Model Registry RBAC] Get rest of columns once we manage permissions + ); +}; export default ModelRegistriesTableRow; diff --git a/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx index 6e320bb2a..95068a0bb 100644 --- a/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx +++ b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx @@ -10,18 +10,20 @@ import ModelRegistriesTable from './ModelRegistriesTable'; const ModelRegistrySettings: React.FC = () => { const queryParams = useQueryParamNamespaces(); + const [modelRegistries, mrloaded, loadError] = useModelRegistries(queryParams); + // TODO: [Midstream] Implement this when adding logic for rules review + const loaded = mrloaded; //&& roleBindings.loaded; - const [modelRegistries, loaded, loadError] = useModelRegistries(queryParams); return ( <> } - description="List all the model registries deployed in your environment." + description="Manage model registry settings for all users in your organization." loaded={loaded} loadError={loadError} errorMessage="Unable to load model registries." @@ -34,7 +36,9 @@ const ModelRegistrySettings: React.FC = () => { variant={EmptyStateVariant.lg} data-testid="mr-settings-empty-state" > - To get started, create a model registry. + + To get started, create a model registry. You can manage permissions after creation. + } provideChildrenPadding diff --git a/clients/ui/frontend/src/app/pages/settings/ModelRegistryTableRowStatus.tsx b/clients/ui/frontend/src/app/pages/settings/ModelRegistryTableRowStatus.tsx new file mode 100644 index 000000000..e6468ae88 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/settings/ModelRegistryTableRowStatus.tsx @@ -0,0 +1,164 @@ +import React from 'react'; + +import { Label, Popover, Stack, StackItem } from '@patternfly/react-core'; +import { + CheckCircleIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + InProgressIcon, +} from '@patternfly/react-icons'; + +import { K8sCondition } from '~/shared/k8sTypes'; + +enum ModelRegistryStatus { + Progressing = 'Progressing', + Degraded = 'Degraded', + Available = 'Available', + IstioAvailable = 'IstioAvailable', + GatewayAvailable = 'GatewayAvailable', +} + +enum ModelRegistryStatusLabel { + Progressing = 'Progressing', + Available = 'Available', + Degrading = 'Degrading', + Unavailable = 'Unavailable', +} + +enum ConditionStatus { + True = 'True', + False = 'False', +} +interface ModelRegistryTableRowStatusProps { + conditions: K8sCondition[] | undefined; +} + +export const ModelRegistryTableRowStatus: React.FC = ({ + conditions, +}) => { + const conditionsMap = + conditions?.reduce((acc: Record, condition) => { + acc[condition.type] = condition; + return acc; + }, {}) ?? {}; + let statusLabel: string = ModelRegistryStatusLabel.Progressing; + let icon = ; + let status: React.ComponentProps['status']; + let popoverMessages: string[] = []; + let popoverTitle = ''; + + if (Object.values(conditionsMap).length) { + const { + [ModelRegistryStatus.Available]: availableCondition, + [ModelRegistryStatus.Progressing]: progressCondition, + [ModelRegistryStatus.Degraded]: degradedCondition, + } = conditionsMap; + + popoverMessages = + availableCondition?.status === ConditionStatus.False + ? Object.values(conditionsMap).reduce((messages: string[], condition) => { + if (condition?.status === ConditionStatus.False && condition.message) { + messages.push(condition.message); + } + return messages; + }, []) + : []; + + // Unavailable + if ( + availableCondition?.status === ConditionStatus.False && + !popoverMessages.some((message) => message.includes('ContainerCreating')) + ) { + statusLabel = ModelRegistryStatusLabel.Unavailable; + icon = ; + status = 'warning'; + } + // Degrading + else if (degradedCondition?.status === ConditionStatus.True) { + statusLabel = ModelRegistryStatusLabel.Degrading; + icon = ; + popoverTitle = 'Service is degrading'; + } + // Available + else if (availableCondition?.status === ConditionStatus.True) { + statusLabel = ModelRegistryStatusLabel.Available; + icon = ; + status = 'success'; + } + // Progressing + else if (progressCondition?.status === ConditionStatus.True) { + statusLabel = ModelRegistryStatusLabel.Progressing; + icon = ; + status = 'info'; + } + } + // Handle popover logic for Unavailable status + if (statusLabel === ModelRegistryStatusLabel.Unavailable) { + const { + [ModelRegistryStatus.IstioAvailable]: istioAvailableCondition, + [ModelRegistryStatus.GatewayAvailable]: gatewayAvailableCondition, + } = conditionsMap; + + if ( + istioAvailableCondition?.status === ConditionStatus.False && + gatewayAvailableCondition?.status === ConditionStatus.False + ) { + popoverTitle = 'Istio resources and Istio Gateway resources are both unavailable'; + } else if (istioAvailableCondition?.status === ConditionStatus.False) { + popoverTitle = 'Istio resources are unavailable'; + } else if (gatewayAvailableCondition?.status === ConditionStatus.False) { + popoverTitle = 'Istio Gateway resources are unavailable'; + } else if ( + istioAvailableCondition?.status === ConditionStatus.True && + gatewayAvailableCondition?.status === ConditionStatus.True + ) { + popoverTitle = 'Deployment is unavailable'; + } else { + popoverTitle = 'Service is unavailable'; + } + } + + const isClickable = popoverTitle && popoverMessages.length; + + const label = ( + + ); + + return popoverTitle && popoverMessages.length ? ( + , + } + : { alertSeverityVariant: 'danger', headerIcon: })} + bodyContent={ + + {popoverMessages.map((message, index) => ( + {message} + ))} + + } + > + {label} + + ) : ( + label + ); +}; diff --git a/clients/ui/frontend/src/app/pages/settings/columns.ts b/clients/ui/frontend/src/app/pages/settings/columns.ts index 36ae2ea29..57092e765 100644 --- a/clients/ui/frontend/src/app/pages/settings/columns.ts +++ b/clients/ui/frontend/src/app/pages/settings/columns.ts @@ -1,5 +1,6 @@ -import { SortableData } from '~/shared/components/table'; +import { kebabTableColumn, SortableData } from '~/shared/components/table'; import { ModelRegistry } from '~/app/types'; +import { isPlatformDefault } from '~/shared/utilities/const'; export const modelRegistryColumns: SortableData[] = [ { @@ -8,16 +9,19 @@ export const modelRegistryColumns: SortableData[] = [ sortable: (a, b) => a.name.localeCompare(b.name), width: 30, }, - // TODO: [Model Registry RBAC] Add once we manage permissions - // { - // field: 'status', - // label: 'Status', - // sortable: false, - // }, - // { - // field: 'manage permissions', - // label: '', - // sortable: false, - // }, - // kebabTableColumn(), + { + field: 'status', + label: 'Status', + sortable: false, + }, + ...(isPlatformDefault() + ? [ + { + field: 'manage permissions', + label: '', + sortable: false, + }, + kebabTableColumn(), + ] + : []), ]; diff --git a/clients/ui/frontend/src/app/types.ts b/clients/ui/frontend/src/app/types.ts index a2c1dafb0..0a2ee0da9 100644 --- a/clients/ui/frontend/src/app/types.ts +++ b/clients/ui/frontend/src/app/types.ts @@ -162,6 +162,8 @@ export type GetRegisteredModel = ( export type GetModelVersion = (opts: APIOptions, modelversionId: string) => Promise; +export type GetListModelVersions = (opts: APIOptions) => Promise; + export type GetListRegisteredModels = (opts: APIOptions) => Promise; export type GetModelVersionsByRegisteredModel = ( @@ -186,17 +188,25 @@ export type PatchModelVersion = ( modelversionId: string, ) => Promise; +export type PatchModelArtifact = ( + opts: APIOptions, + data: Partial, + modelartifactId: string, +) => Promise; + export type ModelRegistryAPIs = { createRegisteredModel: CreateRegisteredModel; createModelVersionForRegisteredModel: CreateModelVersionForRegisteredModel; createModelArtifactForModelVersion: CreateModelArtifactForModelVersion; getRegisteredModel: GetRegisteredModel; getModelVersion: GetModelVersion; + listModelVersions: GetListModelVersions; listRegisteredModels: GetListRegisteredModels; getModelVersionsByRegisteredModel: GetModelVersionsByRegisteredModel; getModelArtifactsByModelVersion: GetModelArtifactsByModelVersion; patchRegisteredModel: PatchRegisteredModel; patchModelVersion: PatchModelVersion; + patchModelArtifact: PatchModelArtifact; }; export type Notification = { diff --git a/clients/ui/frontend/src/app/utils.ts b/clients/ui/frontend/src/app/utils.ts index cc2e412e3..7da7d2c78 100644 --- a/clients/ui/frontend/src/app/utils.ts +++ b/clients/ui/frontend/src/app/utils.ts @@ -1,45 +1,86 @@ -import { SearchType } from '~/shared/components/DashboardSearchField'; -import { RegisteredModel } from '~/app/types'; - -export const asEnumMember = ( - member: T[keyof T] | string | number | undefined | null, - e: T, -): T[keyof T] | null => (isEnumMember(member, e) ? member : null); - -export const isEnumMember = ( - member: T[keyof T] | string | number | undefined | unknown | null, - e: T, -): member is T[keyof T] => { - if (member != null) { - return Object.entries(e) - .filter(([key]) => Number.isNaN(Number(key))) - .map(([, value]) => value) - .includes(member); +import { ModelRegistry, ModelState, ModelVersion, RegisteredModel } from '~/app/types'; +import { K8sResourceCommon } from '~/shared/types'; + +export type ObjectStorageFields = { + endpoint: string; + bucket: string; + region?: string; + path: string; +}; + +export const objectStorageFieldsToUri = (fields: ObjectStorageFields): string | null => { + const { endpoint, bucket, region, path } = fields; + if (!endpoint || !bucket || !path) { + return null; } - return false; + const searchParams = new URLSearchParams(); + searchParams.set('endpoint', endpoint); + if (region) { + searchParams.set('defaultRegion', region); + } + return `s3://${bucket}/${path}?${searchParams.toString()}`; }; -export const filterRegisteredModels = ( - unfilteredRegisteredModels: RegisteredModel[], - search: string, - searchType: SearchType, -): RegisteredModel[] => - unfilteredRegisteredModels.filter((rm: RegisteredModel) => { - if (!search) { - return true; +export const uriToObjectStorageFields = (uri: string): ObjectStorageFields | null => { + try { + const urlObj = new URL(uri); + // Some environments include the first token after the protocol (our bucket) in the pathname and some have it as the hostname + const [bucket, ...pathSplit] = `${urlObj.hostname}/${urlObj.pathname}` + .split('/') + .filter(Boolean); + const path = pathSplit.join('/'); + const searchParams = new URLSearchParams(urlObj.search); + const endpoint = searchParams.get('endpoint'); + const region = searchParams.get('defaultRegion'); + if (endpoint && bucket && path) { + return { endpoint, bucket, region: region || undefined, path }; } + return null; + } catch { + return null; + } +}; - switch (searchType) { - case SearchType.KEYWORD: - return ( - rm.name.toLowerCase().includes(search.toLowerCase()) || - (rm.description && rm.description.toLowerCase().includes(search.toLowerCase())) - ); +export const getLastCreatedItem = ( + items?: T[], +): T | undefined => + items?.toSorted( + ({ createTimeSinceEpoch: createTimeA }, { createTimeSinceEpoch: createTimeB }) => { + if (!createTimeA || !createTimeB) { + return 0; + } + return Number(createTimeB) - Number(createTimeA); + }, + )[0]; - case SearchType.OWNER: - return rm.owner && rm.owner.toLowerCase().includes(search.toLowerCase()); +export const filterArchiveVersions = (modelVersions: ModelVersion[]): ModelVersion[] => + modelVersions.filter((mv) => mv.state === ModelState.ARCHIVED); - default: - return true; - } - }); +export const filterLiveVersions = (modelVersions: ModelVersion[]): ModelVersion[] => + modelVersions.filter((mv) => mv.state === ModelState.LIVE); + +export const filterArchiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => + registeredModels.filter((rm) => rm.state === ModelState.ARCHIVED); + +export const filterLiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => + registeredModels.filter((rm) => rm.state === ModelState.LIVE); + +export const convertToK8sResourceCommon = (modelRegistry: ModelRegistry): K8sResourceCommon => ({ + apiVersion: 'v1', + kind: 'ModelRegistry', + metadata: { + name: modelRegistry.name, + }, + spec: { + // Add any additional fields from ModelRegistry to K8sResourceCommon spec if needed + }, + status: { + conditions: [ + { + type: 'Degrading', + status: 'True', + lastTransitionTime: new Date().toISOString(), + }, + ], + }, +}); diff --git a/clients/ui/frontend/src/app/utils/updateTimestamps.ts b/clients/ui/frontend/src/app/utils/updateTimestamps.ts new file mode 100644 index 000000000..ca03bac0c --- /dev/null +++ b/clients/ui/frontend/src/app/utils/updateTimestamps.ts @@ -0,0 +1,84 @@ +import { ModelRegistryAPIs, ModelState, ModelRegistryMetadataType } from '~/app/types'; + +type MinimalModelRegistryAPI = Pick; + +export const bumpModelVersionTimestamp = async ( + api: ModelRegistryAPIs, + modelVersionId: string, +): Promise => { + if (!modelVersionId) { + throw new Error('Model version ID is required'); + } + + try { + const currentTime = new Date().toISOString(); + await api.patchModelVersion( + {}, + { + // This is a workaround to update the timestamp on the backend. There is a bug opened for model registry team + // to fix this issue. + state: ModelState.LIVE, + customProperties: { + _lastModified: { + metadataType: ModelRegistryMetadataType.STRING, + // eslint-disable-next-line camelcase + string_value: currentTime, + }, + }, + }, + modelVersionId, + ); + } catch (error) { + throw new Error( + `Failed to update model version timestamp: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +}; + +export const bumpRegisteredModelTimestamp = async ( + api: MinimalModelRegistryAPI, + registeredModelId: string, +): Promise => { + if (!registeredModelId) { + throw new Error('Registered model ID is required'); + } + + try { + const currentTime = new Date().toISOString(); + await api.patchRegisteredModel( + {}, + { + state: ModelState.LIVE, + customProperties: { + // This is a workaround to update the timestamp on the backend. There is a bug opened for model registry team + // to fix this issue. + _lastModified: { + metadataType: ModelRegistryMetadataType.STRING, + // eslint-disable-next-line camelcase + string_value: currentTime, + }, + }, + }, + registeredModelId, + ); + } catch (error) { + throw new Error( + `Failed to update registered model timestamp: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +}; + +export const bumpBothTimestamps = async ( + api: ModelRegistryAPIs, + modelVersionId: string, + registeredModelId: string, +): Promise => { + await Promise.all([ + bumpModelVersionTimestamp(api, modelVersionId), + bumpRegisteredModelTimestamp(api, registeredModelId), + ]); +}; diff --git a/clients/ui/frontend/src/shared/components/DashboardDescriptionListGroup.tsx b/clients/ui/frontend/src/shared/components/DashboardDescriptionListGroup.tsx index a9ab8b55e..0bc340634 100644 --- a/clients/ui/frontend/src/shared/components/DashboardDescriptionListGroup.tsx +++ b/clients/ui/frontend/src/shared/components/DashboardDescriptionListGroup.tsx @@ -8,13 +8,20 @@ import { DescriptionListTerm, Flex, FlexItem, + Popover, Split, SplitItem, } from '@patternfly/react-core'; import text from '@patternfly/react-styles/css/utilities/Text/text'; -import { CheckIcon, PencilAltIcon, TimesIcon } from '@patternfly/react-icons'; +import { + CheckIcon, + PencilAltIcon, + TimesIcon, + OutlinedQuestionCircleIcon, +} from '@patternfly/react-icons'; -import '~/shared/components/DashboardDescriptionListGroup.scss'; +import './DashboardDescriptionListGroup.scss'; +import DashboardPopupIconButton from '~/shared/components/dashboard/DashboardPopupIconButton'; type EditableProps = { isEditing: boolean; @@ -23,21 +30,27 @@ type EditableProps = { onEditClick: () => void; onSaveEditsClick: () => void; onDiscardEditsClick: () => void; + editButtonTestId?: string; + saveButtonTestId?: string; + cancelButtonTestId?: string; + discardButtonTestId?: string; }; export type DashboardDescriptionListGroupProps = { title: string; - tooltip?: React.ReactNode; + popover?: React.ReactNode; action?: React.ReactNode; isEmpty?: boolean; contentWhenEmpty?: React.ReactNode; children: React.ReactNode; + groupTestId?: string; + isSaveDisabled?: boolean; } & (({ isEditable: true } & EditableProps) | ({ isEditable?: false } & Partial)); const DashboardDescriptionListGroup: React.FC = (props) => { const { title, - tooltip, + popover, action, isEmpty, contentWhenEmpty, @@ -49,9 +62,14 @@ const DashboardDescriptionListGroup: React.FC + {action || isEditable ? ( @@ -62,35 +80,32 @@ const DashboardDescriptionListGroup: React.FC + isDisabled={isSavingEdits || isSaveDisabled} + /> + /> ) : ( - - + // make sure alert uses the full width + + {error && ( + + + {error.message} + + + )} + + + + + + + + + + + + + ); export default DashboardModalFooter; diff --git a/clients/ui/frontend/src/shared/components/DashboardSearchField.tsx b/clients/ui/frontend/src/shared/components/DashboardSearchField.tsx index 82f8f4359..f74d5a81e 100644 --- a/clients/ui/frontend/src/shared/components/DashboardSearchField.tsx +++ b/clients/ui/frontend/src/shared/components/DashboardSearchField.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { InputGroup, InputGroupItem, SearchInput } from '@patternfly/react-core'; import SimpleSelect from '~/shared/components/SimpleSelect'; -import { asEnumMember } from '~/app/utils'; +import { asEnumMember } from '~/shared/utilities/utils'; // List all the possible search fields here export enum SearchType { diff --git a/clients/ui/frontend/src/shared/components/EditableLabelsDescriptionListGroup.tsx b/clients/ui/frontend/src/shared/components/EditableLabelsDescriptionListGroup.tsx index 392987637..bbdfc7d67 100644 --- a/clients/ui/frontend/src/shared/components/EditableLabelsDescriptionListGroup.tsx +++ b/clients/ui/frontend/src/shared/components/EditableLabelsDescriptionListGroup.tsx @@ -1,220 +1,248 @@ -import * as React from 'react'; -import { - Button, - Form, - FormGroup, - FormHelperText, - HelperText, - HelperTextItem, - Label, - LabelGroup, - TextInput, -} from '@patternfly/react-core'; -import { Modal } from '@patternfly/react-core/deprecated'; -import { ExclamationCircleIcon } from '@patternfly/react-icons'; -import DashboardDescriptionListGroup, { - DashboardDescriptionListGroupProps, -} from '~/shared/components/DashboardDescriptionListGroup'; - -type EditableTextDescriptionListGroupProps = Partial< - Pick -> & { +import React, { useState } from 'react'; +import { Label, LabelGroup, Alert, AlertVariant } from '@patternfly/react-core'; +import spacing from '@patternfly/react-styles/css/utilities/Spacing/spacing'; +import DashboardDescriptionListGroup from '~/shared/components/DashboardDescriptionListGroup'; + +interface EditableLabelsProps { labels: string[]; - saveEditedLabels: (labels: string[]) => Promise; - allExistingKeys?: string[]; + onLabelsChange: (labels: string[]) => Promise; isArchive?: boolean; -}; + title?: string; + contentWhenEmpty?: string; + allExistingKeys: string[]; +} -const EditableLabelsDescriptionListGroup: React.FC = ({ +export const EditableLabelsDescriptionListGroup: React.FC = ({ title = 'Labels', contentWhenEmpty = 'No labels', labels, - saveEditedLabels, + onLabelsChange, isArchive, - allExistingKeys = labels, + allExistingKeys, }) => { - const [isEditing, setIsEditing] = React.useState(false); - const [unsavedLabels, setUnsavedLabels] = React.useState(labels); - const [isSavingEdits, setIsSavingEdits] = React.useState(false); + const [isEditing, setIsEditing] = useState(false); + const [isSavingEdits, setIsSavingEdits] = useState(false); + const [hasSavedEdits, setHasSavedEdits] = useState(false); + const [unsavedLabels, setUnsavedLabels] = useState(labels); - const editUnsavedLabel = (newText: string, index: number) => { - if (isSavingEdits) { + const validateLabels = (): string[] => { + const errors: string[] = []; + + const duplicatesMap = new Map(); + unsavedLabels.forEach((label) => { + duplicatesMap.set(label, (duplicatesMap.get(label) || 0) + 1); + }); + + const duplicateLabels: string[] = []; + duplicatesMap.forEach((count, label) => { + if (count > 1) { + duplicateLabels.push(label); + } + }); + + if (duplicateLabels.length > 0) { + if (duplicateLabels.length === 1) { + const label = duplicateLabels[0] ?? ''; + errors.push(`**${label}** already exists as a label. Ensure that each label is unique.`); + } else { + const lastLabel = duplicateLabels.pop() ?? ''; + const formattedLabels = duplicateLabels.map((label) => `**${label}**`).join(', '); + errors.push( + `${formattedLabels} and **${lastLabel}** already exist as labels. Ensure that each label is unique.`, + ); + } + } + unsavedLabels.forEach((label) => { + if (allExistingKeys.includes(label) && !labels.includes(label)) { + errors.push( + `**${label}** already exists as a property key. Labels cannot use the same name as existing properties.`, + ); + } + if (label.length > 63) { + errors.push(`**${label}** can't exceed 63 characters`); + } + }); + + return errors; + }; + + const handleEditComplete = ( + _event: MouseEvent | KeyboardEvent, + newText: string, + currentLabel?: string, + ) => { + if (!newText) { return; } - const copy = [...unsavedLabels]; - copy[index] = newText; - setUnsavedLabels(copy); + + setUnsavedLabels((prev) => { + if (currentLabel) { + const index = prev.indexOf(currentLabel); + if (index === -1) { + return [...prev, newText]; + } + + const newLabels = [...prev]; + newLabels[index] = newText; + return newLabels; + } + return [...prev, newText]; + }); }; + const removeUnsavedLabel = (text: string) => { if (isSavingEdits) { return; } setUnsavedLabels(unsavedLabels.filter((label) => label !== text)); }; - const addUnsavedLabel = (text: string) => { + + const addNewLabel = () => { if (isSavingEdits) { return; } - setUnsavedLabels([...unsavedLabels, text]); - }; + const baseLabel = 'New Label'; + let counter = 1; + let newLabel = baseLabel; - // Don't allow a label that matches a non-label property key or another label (as they stand before saving) - // Note that this means if you remove a label and add it back before saving, that is valid - const reservedKeys = [ - ...allExistingKeys.filter((key) => !labels.includes(key)), - ...unsavedLabels, - ]; - - const [isAddLabelModalOpen, setIsAddLabelModalOpen] = React.useState(false); - const [addLabelInputValue, setAddLabelInputValue] = React.useState(''); - const addLabelInputRef = React.useRef(null); - let addLabelValidationError: string | null = null; - if (reservedKeys.includes(addLabelInputValue)) { - addLabelValidationError = 'Label must not match an existing label or property key'; - } else if (addLabelInputValue.length > 63) { - addLabelValidationError = "Label text can't exceed 63 characters"; - } - - const toggleAddLabelModal = () => { - setAddLabelInputValue(''); - setIsAddLabelModalOpen(!isAddLabelModalOpen); + while (unsavedLabels.includes(newLabel)) { + newLabel = `${baseLabel} ${counter}`; + counter++; + } + + setUnsavedLabels((prev) => { + const updated = [...prev, newLabel]; + return updated; + }); }; - React.useEffect(() => { - if (isAddLabelModalOpen && addLabelInputRef.current) { - addLabelInputRef.current.focus(); + + const labelErrors = validateLabels(); + const shouldBeRed = (label: string, index: number): boolean => { + const firstIndex = unsavedLabels.findIndex((l) => l === label); + + if (firstIndex !== index) { + return true; } - }, [isAddLabelModalOpen]); - - const addLabelModalSubmitDisabled = !addLabelInputValue || !!addLabelValidationError; - const submitAddLabelModal = (event?: React.FormEvent) => { - event?.preventDefault(); - if (!addLabelModalSubmitDisabled) { - addUnsavedLabel(addLabelInputValue); - toggleAddLabelModal(); + + if (allExistingKeys.includes(label) && !labels.includes(label)) { + return true; } + + return false; }; + // Add a ref for the alert + const alertRef = React.useRef(null); + return ( - <> - Add label - ) + ) : undefined } > {unsavedLabels.map((label, index) => ( ))} + {labelErrors.length > 0 && ( + +
    + {labelErrors.map((error, index) => ( +
  • + {error + .split('**') + .map((part, i) => (i % 2 === 0 ? part : {part}))} +
  • + ))} +
+
+ )} + + } + onEditClick={() => { + setUnsavedLabels(labels); + setIsEditing(true); + }} + onSaveEditsClick={async () => { + if (labelErrors.length > 0) { + return; } - onEditClick={() => { - setUnsavedLabels(labels); - setIsEditing(true); - }} - onSaveEditsClick={async () => { - setIsSavingEdits(true); - try { - await saveEditedLabels(unsavedLabels); - } finally { - setIsSavingEdits(false); - } + setIsSavingEdits(true); + try { + await onLabelsChange(unsavedLabels); + } finally { + setHasSavedEdits(true); + setIsSavingEdits(false); setIsEditing(false); - }} - onDiscardEditsClick={() => { - setUnsavedLabels(labels); - setIsEditing(false); - }} - > - - {labels.map((label) => ( - - ))} - -
- - Save - , - , - ]} + } + }} + onDiscardEditsClick={() => { + setUnsavedLabels(labels); + setIsEditing(false); + }} + > + -
- - , value: string) => - setAddLabelInputValue(value) - } - ref={addLabelInputRef} - isRequired - validated={addLabelValidationError ? 'error' : 'default'} - /> - {addLabelValidationError && ( - - - } variant="error"> - {addLabelValidationError} - - - - )} - - -
- + {labels.map((label) => ( + + ))} + + ); }; - -export default EditableLabelsDescriptionListGroup; diff --git a/clients/ui/frontend/src/shared/components/EditableTextDescriptionListGroup.tsx b/clients/ui/frontend/src/shared/components/EditableTextDescriptionListGroup.tsx index be1527d79..112ede6fc 100644 --- a/clients/ui/frontend/src/shared/components/EditableTextDescriptionListGroup.tsx +++ b/clients/ui/frontend/src/shared/components/EditableTextDescriptionListGroup.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ExpandableSection, TextArea } from '@patternfly/react-core'; +import { ExpandableSection, TextArea, TextInput } from '@patternfly/react-core'; import DashboardDescriptionListGroup, { DashboardDescriptionListGroupProps, } from '~/shared/components/DashboardDescriptionListGroup'; @@ -12,8 +12,9 @@ type EditableTextDescriptionListGroupProps = Pick< > & { value: string; saveEditedValue: (value: string) => Promise; - testid?: string; + baseTestId?: string; isArchive?: boolean; + editableVariant: 'TextInput' | 'TextArea'; }; const EditableTextDescriptionListGroup: React.FC = ({ @@ -22,23 +23,36 @@ const EditableTextDescriptionListGroup: React.FC { const [isEditing, setIsEditing] = React.useState(false); const [unsavedValue, setUnsavedValue] = React.useState(value); const [isSavingEdits, setIsSavingEdits] = React.useState(false); const [isTextExpanded, setIsTextExpanded] = React.useState(false); - const editableTextArea = ( -
- {mr.displayName || mr.name} + + {mr.displayName || mr.name} + {mr.description &&

{mr.description}

}
+ + + {filteredRoleBindings.length === 0 ? ( + + + + ) : ( + + )} + + { + onEditRegistry(mr); + }, + }, + { + title: 'Delete model registry', + disabled: !isPlatformDefault(), + onClick: () => { + onDeleteRegistry(mr); + }, + }, + ]} + /> +