diff --git a/.github/workflows/build-and-push-async-upload.yml b/.github/workflows/build-and-push-async-upload.yml index 11267f91d6..131857c9eb 100644 --- a/.github/workflows/build-and-push-async-upload.yml +++ b/.github/workflows/build-and-push-async-upload.yml @@ -35,13 +35,13 @@ jobs: uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.IMG_REGISTRY }} username: ${{ env.REGISTRY_USER }} @@ -71,7 +71,7 @@ jobs: - name: Build and push Docker image id: build-push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./jobs/async-upload platforms: ${{ env.PLATFORMS }} diff --git a/.github/workflows/build-and-push-controller-image.yml b/.github/workflows/build-and-push-controller-image.yml index f2734e1f4a..06d5fd86a3 100644 --- a/.github/workflows/build-and-push-controller-image.yml +++ b/.github/workflows/build-and-push-controller-image.yml @@ -43,9 +43,9 @@ jobs: # checkout branch - uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # set image version - name: Set main-branch environment if: env.BUILD_CONTEXT == 'main' @@ -59,7 +59,7 @@ jobs: echo "VERSION=${{ github.ref_name }}" >> $GITHUB_ENV # docker login - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.IMG_REGISTRY }} username: ${{ env.DOCKER_USER }} @@ -75,7 +75,7 @@ jobs: type=raw,value=main,enable=${{ env.BUILD_CONTEXT == 'main' }} - name: Build and push Docker image id: build-push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./cmd/controller/Dockerfile.controller diff --git a/.github/workflows/build-and-push-csi-image.yml b/.github/workflows/build-and-push-csi-image.yml index 3d546431fd..46439214d8 100644 --- a/.github/workflows/build-and-push-csi-image.yml +++ b/.github/workflows/build-and-push-csi-image.yml @@ -41,9 +41,9 @@ jobs: # checkout branch - uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # set image version - name: Set main-branch environment if: env.BUILD_CONTEXT == 'main' @@ -57,7 +57,7 @@ jobs: echo "VERSION=${{ github.ref_name }}" >> $GITHUB_ENV # docker login - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.IMG_REGISTRY }} username: ${{ env.DOCKER_USER }} @@ -73,7 +73,7 @@ jobs: type=raw,value=main,enable=${{ env.BUILD_CONTEXT == 'main' }} - name: Build and push Docker image id: build-push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./cmd/csi/Dockerfile.csi diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml index 8abb5ce988..0e582d16d3 100644 --- a/.github/workflows/build-and-push-image.yml +++ b/.github/workflows/build-and-push-image.yml @@ -45,10 +45,10 @@ jobs: - uses: actions/checkout@v6 # Set up QEMU for multi-architecture builds - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 # Set up Docker Buildx - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # set image version - name: Set main-branch environment if: env.BUILD_CONTEXT == 'main' @@ -61,7 +61,7 @@ jobs: run: | echo "VERSION=${{ github.ref_name }}" >> $GITHUB_ENV - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.IMG_REGISTRY }} username: ${{ env.DOCKER_USER }} @@ -77,7 +77,7 @@ jobs: type=raw,value=main,enable=${{ env.BUILD_CONTEXT == 'main' }} - name: Build and push Docker image id: build-push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: ${{ env.PLATFORMS }} diff --git a/.github/workflows/build-and-push-ui-images-standalone.yml b/.github/workflows/build-and-push-ui-images-standalone.yml index e418aab695..f7eb149fc5 100644 --- a/.github/workflows/build-and-push-ui-images-standalone.yml +++ b/.github/workflows/build-and-push-ui-images-standalone.yml @@ -34,13 +34,13 @@ jobs: uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.IMG_REGISTRY }} username: ${{ env.DOCKER_USER }} @@ -74,7 +74,7 @@ jobs: - name: Build and push Docker image id: build-push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./clients/ui file: ./clients/ui/Dockerfile.standalone diff --git a/.github/workflows/build-and-push-ui-images.yml b/.github/workflows/build-and-push-ui-images.yml index be471a0017..40c122c979 100644 --- a/.github/workflows/build-and-push-ui-images.yml +++ b/.github/workflows/build-and-push-ui-images.yml @@ -34,13 +34,13 @@ jobs: uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.IMG_REGISTRY }} username: ${{ env.DOCKER_USER }} @@ -74,7 +74,7 @@ jobs: - name: Build and push Docker image id: build-push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./clients/ui platforms: ${{ env.PLATFORMS }} diff --git a/.github/workflows/trivy-image-scanning.yaml b/.github/workflows/trivy-image-scanning.yaml index 5a642362f3..0d6f5616ed 100644 --- a/.github/workflows/trivy-image-scanning.yaml +++ b/.github/workflows/trivy-image-scanning.yaml @@ -34,7 +34,7 @@ jobs: echo "Sanitized image name: ${SANITIZED_NAME}" - name: trivy scan for github security tab - uses: aquasecurity/trivy-action@0.34.2 + uses: aquasecurity/trivy-action@0.35.0 with: image-ref: '${{ matrix.IMAGE }}' format: 'sarif' diff --git a/catalog/clients/python/tests/test_models.py b/catalog/clients/python/tests/test_models.py index 62e3a285cf..a6c2cf8219 100644 --- a/catalog/clients/python/tests/test_models.py +++ b/catalog/clients/python/tests/test_models.py @@ -140,7 +140,8 @@ def test_models_custom_properties_has_valid_structure( custom_properties = model.get("customProperties") if errors := _validate_custom_property_structure(custom_properties=custom_properties, kind_cluster=kind_cluster): - all_errors.append(f"Model '{model_name}': {'\n'.join(errors)}") + err_lines = "\n".join(errors) + all_errors.append(f"Model '{model_name}': {err_lines}") assert not all_errors, "\n".join(all_errors) diff --git a/catalog/internal/catalog/modelcatalog/api_test.go b/catalog/internal/catalog/modelcatalog/api_test.go index b43e911276..0704dc8f50 100644 --- a/catalog/internal/catalog/modelcatalog/api_test.go +++ b/catalog/internal/catalog/modelcatalog/api_test.go @@ -417,8 +417,8 @@ func TestLoadCatalogSourcesWithMockRepositories(t *testing.T) { savedModel := savedModels[0] if savedModel.GetAttributes() == nil || savedModel.GetAttributes().Name == nil { t.Error("Saved model should have attributes with name") - } else if *savedModel.GetAttributes().Name != "test-model" { - t.Errorf("Expected model name 'test-model', got '%s'", *savedModel.GetAttributes().Name) + } else if DisplayName(savedModel) != "test-model" { + t.Errorf("Expected model name 'test-model', got '%s'", DisplayName(savedModel)) } } @@ -609,8 +609,8 @@ func TestLoadCatalogSourcesWithNilEnabled(t *testing.T) { savedModel := savedModels[0] if savedModel.GetAttributes() == nil || savedModel.GetAttributes().Name == nil { t.Error("Saved model should have attributes with name") - } else if *savedModel.GetAttributes().Name != "test-model-nil-enabled" { - t.Errorf("Expected model name 'test-model-nil-enabled', got '%s'", *savedModel.GetAttributes().Name) + } else if DisplayName(savedModel) != "test-model-nil-enabled" { + t.Errorf("Expected model name 'test-model-nil-enabled', got '%s'", DisplayName(savedModel)) } } } diff --git a/catalog/internal/catalog/modelcatalog/db_catalog.go b/catalog/internal/catalog/modelcatalog/db_catalog.go index 36c55e663a..51cc584106 100644 --- a/catalog/internal/catalog/modelcatalog/db_catalog.go +++ b/catalog/internal/catalog/modelcatalog/db_catalog.go @@ -41,8 +41,10 @@ func NewDBCatalog(services service.Services, sources *SourceCollection) APIProvi } func (d *dbCatalogImpl) GetModel(ctx context.Context, modelName string, sourceID string) (*apimodels.CatalogModel, error) { + // Resolve by namespaced identifier: sourceId:modelName + namespacedName := sourceID + ":" + modelName modelsList, err := d.catalogModelRepository.List(models.CatalogModelListOptions{ - Name: &modelName, + Name: &namespacedName, SourceIDs: &[]string{sourceID}, }) if err != nil { @@ -238,9 +240,11 @@ func (d *dbCatalogImpl) GetFilterOptions(ctx context.Context) (*apimodels.Filter } func (d *dbCatalogImpl) GetPerformanceArtifacts(ctx context.Context, modelName string, sourceID string, params ListPerformanceArtifactsParams) (apimodels.CatalogArtifactList, error) { + // Resolve by namespaced identifier: sourceId:modelName + namespacedName := sourceID + ":" + modelName // Get the model to validate it exists and get its ID modelsList, err := d.catalogModelRepository.List(models.CatalogModelListOptions{ - Name: &modelName, + Name: &namespacedName, SourceIDs: &[]string{sourceID}, }) if err != nil { @@ -305,7 +309,10 @@ func mapDBModelToAPIModel(m models.CatalogModel) apimodels.CatalogModel { res.Id = &id if m.GetAttributes() != nil { - res.Name = *m.GetAttributes().Name + storedName := *m.GetAttributes().Name + // Return display name: strip "sourceId:" prefix so API shows model name only. + // Source is already exposed via source_id. + res.Name = DisplayNameFromStoredName(storedName) res.ExternalId = m.GetAttributes().ExternalID if m.GetAttributes().CreateTimeSinceEpoch != nil { @@ -627,9 +634,14 @@ func (d *dbCatalogImpl) FindModelsWithRecommendedLatency( filterQuery = strings.ReplaceAll(filterQuery, "artifacts.", "") } + // Use stored (namespaced) name for lookup: sourceId:modelName + namespacedName := "" + if model.GetAttributes() != nil && model.GetAttributes().Name != nil { + namespacedName = *model.GetAttributes().Name + } latency, err := d.performanceService.GetMinimumRecommendedLatency( ctx, - apiModel.Name, + namespacedName, sourceID, paretoParams, filterQuery, diff --git a/catalog/internal/catalog/modelcatalog/db_catalog_test.go b/catalog/internal/catalog/modelcatalog/db_catalog_test.go index bf6f21fcb4..395d1b0517 100644 --- a/catalog/internal/catalog/modelcatalog/db_catalog_test.go +++ b/catalog/internal/catalog/modelcatalog/db_catalog_test.go @@ -70,11 +70,11 @@ func TestDBCatalog(t *testing.T) { }) t.Run("TestGetModel_Success", func(t *testing.T) { - // Create test model + // Create test model with namespaced name (sourceId:modelName) as stored in DB testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("test-get-model"), + Name: apiutils.Of("test-source-id:test-get-model"), ExternalID: apiutils.Of("test-get-model-ext"), }, Properties: &[]mr_models.Properties{ @@ -86,7 +86,7 @@ func TestDBCatalog(t *testing.T) { savedModel, err := catalogModelRepo.Save(testModel) require.NoError(t, err) - // Test GetModel + // Test GetModel (API passes display name and source_id; backend resolves by namespaced name) retrievedModel, err := dbCatalog.GetModel(ctx, "test-get-model", "test-source-id") require.NoError(t, err) require.NotNil(t, retrievedModel) @@ -184,7 +184,7 @@ func TestDBCatalog(t *testing.T) { model := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of(fmt.Sprintf("pagination-test-model-%d", i)), + Name: apiutils.Of(fmt.Sprintf("pagination-test-source:pagination-test-model-%d", i)), ExternalID: apiutils.Of(fmt.Sprintf("pagination-test-%d", i)), }, Properties: &[]mr_models.Properties{ @@ -217,7 +217,7 @@ func TestDBCatalog(t *testing.T) { model1 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("BERT-base-model"), + Name: apiutils.Of("query-test-source:BERT-base-model"), ExternalID: apiutils.Of("bert-base-1"), }, Properties: &[]mr_models.Properties{ @@ -231,7 +231,7 @@ func TestDBCatalog(t *testing.T) { model2 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("GPT-3.5-turbo"), + Name: apiutils.Of("query-test-source:GPT-3.5-turbo"), ExternalID: apiutils.Of("gpt-35-turbo-1"), }, Properties: &[]mr_models.Properties{ @@ -245,7 +245,7 @@ func TestDBCatalog(t *testing.T) { model3 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("ResNet-50-image"), + Name: apiutils.Of("query-test-source:ResNet-50-image"), ExternalID: apiutils.Of("resnet-50-1"), }, Properties: &[]mr_models.Properties{ @@ -356,7 +356,7 @@ func TestDBCatalog(t *testing.T) { model1 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("TensorFlow-ResNet50"), + Name: apiutils.Of("filterquery-test-source:TensorFlow-ResNet50"), ExternalID: apiutils.Of("tf-resnet50-1"), }, Properties: &[]mr_models.Properties{ @@ -372,7 +372,7 @@ func TestDBCatalog(t *testing.T) { model2 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("PyTorch-BERT"), + Name: apiutils.Of("filterquery-test-source:PyTorch-BERT"), ExternalID: apiutils.Of("pt-bert-1"), }, Properties: &[]mr_models.Properties{ @@ -388,7 +388,7 @@ func TestDBCatalog(t *testing.T) { model3 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("Scikit-learn-LogisticRegression"), + Name: apiutils.Of("filterquery-test-source:Scikit-learn-LogisticRegression"), ExternalID: apiutils.Of("sk-lr-1"), }, Properties: &[]mr_models.Properties{ @@ -408,9 +408,9 @@ func TestDBCatalog(t *testing.T) { _, err = catalogModelRepo.Save(model3) require.NoError(t, err) - // Test: Basic name filtering with exact match + // Test: Basic name filtering with exact match (filter uses stored namespaced name: source_id:model_name) params := ListModelsParams{ - FilterQuery: "name = \"TensorFlow-ResNet50\"", + FilterQuery: "name = \"filterquery-test-source:TensorFlow-ResNet50\"", SourceIDs: sourceIDs, PageSize: 10, OrderBy: model.ORDERBYFIELD_NAME, @@ -437,8 +437,8 @@ func TestDBCatalog(t *testing.T) { assert.Equal(t, int32(1), result.Size, "Should return 1 model with case-insensitive ILIKE match") assert.Contains(t, result.Items[0].Name, "Tensor") - // Test: OR logic - params.FilterQuery = "name = \"TensorFlow-ResNet50\" OR name = \"PyTorch-BERT\"" + // Test: OR logic (use namespaced names for exact match) + params.FilterQuery = "name = \"filterquery-test-source:TensorFlow-ResNet50\" OR name = \"filterquery-test-source:PyTorch-BERT\"" result, err = dbCatalog.ListModels(ctx, params) require.NoError(t, err) assert.Equal(t, int32(2), result.Size, "Should return 2 models with OR logic") @@ -492,8 +492,8 @@ func TestDBCatalog(t *testing.T) { require.NoError(t, err) assert.Equal(t, int32(2), result.Size, "Should return 2 models with complex query") - // Test: No matches - params.FilterQuery = "name = \"NonExistentModel\"" + // Test: No matches (non-existent name; stored names are namespaced) + params.FilterQuery = "name = \"filterquery-test-source:NonExistentModel\"" result, err = dbCatalog.ListModels(ctx, params) require.NoError(t, err) assert.Equal(t, int32(0), result.Size, "Should return 0 models for non-existent name") @@ -525,7 +525,7 @@ func TestDBCatalog(t *testing.T) { testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("artifact-test-model"), + Name: apiutils.Of("artifact-test-source:artifact-test-model"), ExternalID: apiutils.Of("artifact-test-model-ext"), }, Properties: &[]mr_models.Properties{ @@ -622,7 +622,7 @@ func TestDBCatalog(t *testing.T) { testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("custom-props-model"), + Name: apiutils.Of("custom-props-source:custom-props-model"), ExternalID: apiutils.Of("custom-props-model-ext"), }, Properties: &[]mr_models.Properties{ @@ -697,7 +697,7 @@ func TestDBCatalog(t *testing.T) { ID: apiutils.Of(int32(123)), TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("mapping-test-model"), + Name: apiutils.Of("test-source:mapping-test-model"), ExternalID: apiutils.Of("mapping-test-ext"), CreateTimeSinceEpoch: apiutils.Of(int64(1234567890)), LastUpdateTimeSinceEpoch: apiutils.Of(int64(1234567891)), @@ -814,7 +814,7 @@ func TestDBCatalog(t *testing.T) { model1 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("filter-options-model-1"), + Name: apiutils.Of("filter-test-source:filter-options-model-1"), ExternalID: apiutils.Of("filter-opt-1"), }, Properties: &[]mr_models.Properties{ @@ -831,7 +831,7 @@ func TestDBCatalog(t *testing.T) { model2 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("filter-options-model-2"), + Name: apiutils.Of("filter-test-source:filter-options-model-2"), ExternalID: apiutils.Of("filter-opt-2"), }, Properties: &[]mr_models.Properties{ @@ -849,7 +849,7 @@ func TestDBCatalog(t *testing.T) { model3 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("filter-options-model-3"), + Name: apiutils.Of("filter-test-source:filter-options-model-3"), ExternalID: apiutils.Of("filter-opt-3"), }, Properties: &[]mr_models.Properties{ @@ -979,7 +979,7 @@ func TestDBCatalog(t *testing.T) { testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("perf-test-model"), + Name: apiutils.Of("perf-test-source:perf-test-model"), ExternalID: apiutils.Of("perf-test-model-ext"), }, Properties: &[]mr_models.Properties{ @@ -1049,7 +1049,7 @@ func TestDBCatalog(t *testing.T) { testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("rps-test-model"), + Name: apiutils.Of("rps-test-source:rps-test-model"), ExternalID: apiutils.Of("rps-test-model-ext"), }, Properties: &[]mr_models.Properties{ @@ -1122,7 +1122,7 @@ func TestDBCatalog(t *testing.T) { testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("dedup-test-model"), + Name: apiutils.Of("dedup-test-source:dedup-test-model"), ExternalID: apiutils.Of("dedup-test-model-ext"), }, Properties: &[]mr_models.Properties{ @@ -1214,7 +1214,7 @@ func TestDBCatalog(t *testing.T) { testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("filterquery-artifact-test-model"), + Name: apiutils.Of("filterquery-test-source:filterquery-artifact-test-model"), ExternalID: apiutils.Of("filterquery-artifact-test-model-ext"), }, Properties: &[]mr_models.Properties{ @@ -1452,7 +1452,7 @@ func TestDBCatalog_GetPerformanceArtifactsWithService(t *testing.T) { testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("performance-test-model"), + Name: apiutils.Of("test-source:performance-test-model"), ExternalID: apiutils.Of("perf-model-123"), }, Properties: &[]mr_models.Properties{ @@ -1967,7 +1967,7 @@ func TestFindModelsWithRecommendedLatency(t *testing.T) { model1 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("latency-model-1"), + Name: apiutils.Of("latency-test-source:latency-model-1"), ExternalID: apiutils.Of("latency-model-1-ext"), }, Properties: &[]mr_models.Properties{ @@ -1979,7 +1979,7 @@ func TestFindModelsWithRecommendedLatency(t *testing.T) { model2 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("latency-model-2"), + Name: apiutils.Of("latency-test-source:latency-model-2"), ExternalID: apiutils.Of("latency-model-2-ext"), }, Properties: &[]mr_models.Properties{ @@ -1991,7 +1991,7 @@ func TestFindModelsWithRecommendedLatency(t *testing.T) { model3 := &models.CatalogModelImpl{ TypeID: apiutils.Of(int32(catalogModelTypeID)), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("latency-model-3"), + Name: apiutils.Of("latency-test-source:latency-model-3"), ExternalID: apiutils.Of("latency-model-3-ext"), }, Properties: &[]mr_models.Properties{ diff --git a/catalog/internal/catalog/modelcatalog/loader.go b/catalog/internal/catalog/modelcatalog/loader.go index b6f19f62af..e69e20b776 100644 --- a/catalog/internal/catalog/modelcatalog/loader.go +++ b/catalog/internal/catalog/modelcatalog/loader.go @@ -429,13 +429,14 @@ func (l *ModelLoader) readProviderRecords(ctx context.Context) <-chan ModelProvi continue } + // Set source_id and namespaced name on every returned model. + l.setModelSourceID(r.Model, sourceID) + if attr := r.Model.GetAttributes(); attr != nil && attr.Name != nil { + // Use namespaced name (source_id:model_name)so removeOrphanedModelsFromSource matches DB (which stores namespaced names). modelNames = append(modelNames, *attr.Name) } - // Set source_id on every returned model. - l.setModelSourceID(r.Model, sourceID) - ch <- r } @@ -475,15 +476,26 @@ func (l *ModelLoader) setModelSourceID(model models.CatalogModel, sourceID strin } } + found := false for i := range *props { if (*props)[i].Name == "source_id" { // Already has a source_id, just update it (*props)[i].StringValue = &sourceID - return + found = true + break } } + if !found { + *props = append(*props, mrmodels.NewStringProperty("source_id", sourceID, false)) + } - *props = append(*props, mrmodels.NewStringProperty("source_id", sourceID, false)) + // Prepend sourceId to the model name so DB uniqueness is (sourceId, modelName). + // Format: sourceId:modelName — must run for every model so DB and removeOrphanedModelsFromSource stay consistent. + attr := model.GetAttributes() + if attr != nil && attr.Name != nil && *attr.Name != "" { + namespacedName := sourceID + ":" + *attr.Name + attr.Name = &namespacedName + } } func (l *ModelLoader) removeModelsFromMissingSources(allKnownSourceIDs mapset.Set[string]) error { diff --git a/catalog/internal/catalog/modelcatalog/names.go b/catalog/internal/catalog/modelcatalog/names.go new file mode 100644 index 0000000000..ed7dbe8c31 --- /dev/null +++ b/catalog/internal/catalog/modelcatalog/names.go @@ -0,0 +1,26 @@ +package modelcatalog + +import ( + "strings" + + "github.com/kubeflow/model-registry/catalog/internal/catalog/modelcatalog/models" +) + +// DisplayNameFromStoredName returns the model display name from the stored (namespaced) name. +// Stored names use the format "sourceId:modelName" for DB uniqueness; this strips the prefix +// so callers get the name without the source id prepended. +func DisplayNameFromStoredName(storedName string) string { + if idx := strings.Index(storedName, ":"); idx >= 0 { + return storedName[idx+1:] + } + return storedName +} + +// DisplayName returns the display name for a catalog model (name without "sourceId:" prefix). +// Use this when reading entities from the DB to get the user-facing model name. +func DisplayName(m models.CatalogModel) string { + if m == nil || m.GetAttributes() == nil || m.GetAttributes().Name == nil { + return "" + } + return DisplayNameFromStoredName(*m.GetAttributes().Name) +} diff --git a/catalog/internal/catalog/modelcatalog/performance_artifacts_integration_test.go b/catalog/internal/catalog/modelcatalog/performance_artifacts_integration_test.go index d6b2074a09..7516a01ac3 100644 --- a/catalog/internal/catalog/modelcatalog/performance_artifacts_integration_test.go +++ b/catalog/internal/catalog/modelcatalog/performance_artifacts_integration_test.go @@ -49,11 +49,11 @@ func TestIntegration_PreservedRecommendationAlgorithm(t *testing.T) { provider := NewDBCatalog(services, nil) - // Create test model + // Create test model (stored name must be namespaced: sourceId:modelName) testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(catalogModelTypeID), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("algorithm-test-model"), + Name: apiutils.Of("algorithm-test-source:algorithm-test-model"), ExternalID: apiutils.Of("alg-test-model-789"), }, Properties: &[]dbmodels.Properties{ @@ -307,10 +307,11 @@ func TestIntegration_ServiceLayerBehavior(t *testing.T) { provider := NewDBCatalog(services, nil) + // Stored name must be namespaced: sourceId:modelName testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(catalogModelTypeID), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("service-test-model"), + Name: apiutils.Of("service-test-source:service-test-model"), ExternalID: apiutils.Of("service-model-123"), }, Properties: &[]dbmodels.Properties{ @@ -470,10 +471,11 @@ func TestIntegration_ConfigurableProperties(t *testing.T) { provider := NewDBCatalog(services, nil) + // Stored name must be namespaced: sourceId:modelName testModel := &models.CatalogModelImpl{ TypeID: apiutils.Of(catalogModelTypeID), Attributes: &models.CatalogModelAttributes{ - Name: apiutils.Of("configurable-props-model"), + Name: apiutils.Of("configurable-props-source:configurable-props-model"), ExternalID: apiutils.Of("config-model-999"), }, Properties: &[]dbmodels.Properties{ diff --git a/catalog/internal/catalog/modelcatalog/performance_metrics.go b/catalog/internal/catalog/modelcatalog/performance_metrics.go index 7acf4df5cb..f32c584a44 100644 --- a/catalog/internal/catalog/modelcatalog/performance_metrics.go +++ b/catalog/internal/catalog/modelcatalog/performance_metrics.go @@ -241,35 +241,35 @@ func (pml *PerformanceMetricsLoader) Load(ctx context.Context, record ModelProvi return nil } - modelName := *attrs.Name - glog.Infof("Loading performance metrics for %s", modelName) - - // Look up the model directory in the cache - dirPath, found := pml.modelDirCache[modelName] + // Namespaced name is source_id:model_name + namespacedName := *attrs.Name + // Resolve directory from cache: cache is keyed by metadata.ID (display name) + displayName := DisplayNameFromStoredName(namespacedName) + dirPath, found := pml.modelDirCache[displayName] if !found { - glog.V(2).Infof("No performance metrics directory found for model %s", modelName) + glog.V(2).Infof("No performance metrics directory found for model %s", namespacedName) return nil } - glog.V(2).Infof("Found cached directory for model %s: %s", modelName, dirPath) + glog.V(2).Infof("Found cached directory for model %s: %s", namespacedName, dirPath) - // Process this specific model directory using the cached path - artifactsCreated, err := processModelDirectory(dirPath, pml.modelRepo, pml.metricsArtifactRepo, pml.modelTypeID, pml.metricsArtifactTypeID) + // Process this specific model directory using the cached path (use namespaced name for DB lookup) + artifactsCreated, err := processModelDirectory(dirPath, pml.modelRepo, pml.metricsArtifactRepo, pml.modelTypeID, pml.metricsArtifactTypeID, namespacedName) if err != nil { - return fmt.Errorf("failed to process metrics for model %s: %v", modelName, err) + return fmt.Errorf("failed to process metrics for model %s: %v", namespacedName, err) } if artifactsCreated > 0 { - glog.Infof("Loaded %d performance metrics artifacts for model %s", artifactsCreated, modelName) + glog.Infof("Loaded %d performance metrics artifacts for model %s", artifactsCreated, namespacedName) } return nil } // processModelDirectory processes a single model directory containing metadata.json and metric files -// Only processes metrics for models that already exist in the database -// Returns the number of artifacts created and any error encountered -func processModelDirectory(dirPath string, modelRepo dbmodels.CatalogModelRepository, metricsArtifactRepo dbmodels.CatalogMetricsArtifactRepository, modelTypeID int32, metricsArtifactTypeID int32) (int, error) { +// Only processes metrics for models that already exist in the database. +// namespacedModelName is the stored model name (sourceId:modelName) used for GetByName lookup. +func processModelDirectory(dirPath string, modelRepo dbmodels.CatalogModelRepository, metricsArtifactRepo dbmodels.CatalogMetricsArtifactRepository, modelTypeID int32, metricsArtifactTypeID int32, namespacedModelName string) (int, error) { // Read and parse metadata.json to extract the model ID metadataPath := filepath.Join(dirPath, "metadata.json") metadataData, err := os.ReadFile(metadataPath) @@ -283,26 +283,26 @@ func processModelDirectory(dirPath string, modelRepo dbmodels.CatalogModelReposi return 0, fmt.Errorf("failed to parse metadata file %s: %v", metadataPath, err) } - // Check if the model already exists - only process metrics for existing models - existingModel, err := modelRepo.GetByName(metadata.ID) + // Check if the model already exists - only process metrics for existing models (look up by namespaced name) + existingModel, err := modelRepo.GetByName(namespacedModelName) if err != nil { return 0, fmt.Errorf("failed to check for existing model: %v", err) } // Skip processing if model doesn't exist if existingModel == nil { - glog.V(2).Infof("Model %s does not exist in database, skipping metrics processing", metadata.ID) + glog.V(2).Infof("Model %s does not exist in database, skipping metrics processing", namespacedModelName) return 0, nil } // Enrich the model with metadata before processing metrics artifacts if err := enrichCatalogModelFromMetadata(existingModel, metadata, modelRepo); err != nil { - glog.Warningf("Failed to enrich model %s with metadata: %v", metadata.ID, err) + glog.Warningf("Failed to enrich model %s with metadata: %v", namespacedModelName, err) // Continue processing - don't fail the whole operation } modelID := *existingModel.GetID() - glog.V(2).Infof("Found existing model %s with ID %d, processing metrics", metadata.ID, modelID) + glog.V(2).Infof("Found existing model %s with ID %d, processing metrics", namespacedModelName, modelID) // Use batch processing for all artifacts return processModelArtifactsBatch(dirPath, modelID, metadata.ID, metadata.OverallAccuracy, metricsArtifactRepo, metricsArtifactTypeID) diff --git a/catalog/internal/catalog/modelcatalog/service/catalog_model.go b/catalog/internal/catalog/modelcatalog/service/catalog_model.go index 5f64362645..d91b4abbf1 100644 --- a/catalog/internal/catalog/modelcatalog/service/catalog_model.go +++ b/catalog/internal/catalog/modelcatalog/service/catalog_model.go @@ -59,10 +59,21 @@ func (r *CatalogModelRepositoryImpl) Save(model models.CatalogModel) (models.Cat attr := model.GetAttributes() if model.GetID() == nil && attr != nil && attr.Name != nil { - existing, err := r.lookupModelByName(*attr.Name) + lookupName := *attr.Name + // When source_id is present, use namespaced name (sourceId:modelName) for uniqueness and storage. + // Only prefix if not already in sourceId:modelName form (avoid double-prefix; allow display names containing ":"). + if sourceID := getSourceIDFromProperties(model); sourceID != "" { + prefix := sourceID + ":" + if !strings.HasPrefix(lookupName, prefix) { + namespacedName := prefix + lookupName + attr.Name = &namespacedName + lookupName = namespacedName + } + } + existing, err := r.lookupModelByName(lookupName) if err != nil { if !errors.Is(err, ErrCatalogModelNotFound) { - return nil, fmt.Errorf("error finding existing model named %s: %w", *attr.Name, err) + return nil, fmt.Errorf("error finding existing model named %s: %w", lookupName, err) } } else { model.SetID(existing.ID) @@ -72,6 +83,19 @@ func (r *CatalogModelRepositoryImpl) Save(model models.CatalogModel) (models.Cat return r.GenericRepository.Save(model, nil) } +// getSourceIDFromProperties returns the source_id property value if present. +func getSourceIDFromProperties(model models.CatalogModel) string { + if model.GetProperties() == nil { + return "" + } + for _, p := range *model.GetProperties() { + if p.Name == "source_id" && p.StringValue != nil { + return *p.StringValue + } + } + return "" +} + // ApplyStandardPagination overrides the base implementation to use catalog-specific allowed columns func (r *CatalogModelRepositoryImpl) ApplyStandardPagination(query *gorm.DB, listOptions *models.CatalogModelListOptions, entities any) *gorm.DB { pageSize := listOptions.GetPageSize() diff --git a/catalog/internal/catalog/modelcatalog/service/catalog_model_test.go b/catalog/internal/catalog/modelcatalog/service/catalog_model_test.go index 31de84b84a..cfc887425f 100644 --- a/catalog/internal/catalog/modelcatalog/service/catalog_model_test.go +++ b/catalog/internal/catalog/modelcatalog/service/catalog_model_test.go @@ -171,6 +171,50 @@ func TestCatalogModelRepository(t *testing.T) { assert.ErrorIs(t, err, ErrCatalogModelNotFound) }) + t.Run("TestSameNameDifferentSources", func(t *testing.T) { + // Allow multiple models with the same display name as long as they have different sources. + sharedName := "shared-model-name" + modelA := &models.CatalogModelImpl{ + Attributes: &models.CatalogModelAttributes{ + Name: apiutils.Of(sharedName), + ExternalID: apiutils.Of("ext-a"), + }, + Properties: &[]dbmodels.Properties{ + {Name: "source_id", StringValue: apiutils.Of("source-a")}, + }, + } + modelB := &models.CatalogModelImpl{ + Attributes: &models.CatalogModelAttributes{ + Name: apiutils.Of(sharedName), + ExternalID: apiutils.Of("ext-b"), + }, + Properties: &[]dbmodels.Properties{ + {Name: "source_id", StringValue: apiutils.Of("source-b")}, + }, + } + + savedA, err := repo.Save(modelA) + require.NoError(t, err) + require.NotNil(t, savedA.GetID()) + + savedB, err := repo.Save(modelB) + require.NoError(t, err) + require.NotNil(t, savedB.GetID()) + + assert.NotEqual(t, *savedA.GetID(), *savedB.GetID(), "same display name from different sources must be distinct rows") + + // Lookup by namespaced name returns the correct model + retrievedA, err := repo.GetByName("source-a:" + sharedName) + require.NoError(t, err) + assert.Equal(t, *savedA.GetID(), *retrievedA.GetID()) + assert.Equal(t, "source-a:"+sharedName, *retrievedA.GetAttributes().Name) + + retrievedB, err := repo.GetByName("source-b:" + sharedName) + require.NoError(t, err) + assert.Equal(t, *savedB.GetID(), *retrievedB.GetID()) + assert.Equal(t, "source-b:"+sharedName, *retrievedB.GetAttributes().Name) + }) + t.Run("TestUpdateWithID", func(t *testing.T) { // First create a model catalogModel := &models.CatalogModelImpl{ @@ -905,7 +949,8 @@ func TestCatalogModelRepository(t *testing.T) { // Verify model from source2 still exists retrieved, err := repo.GetByID(*saved3.GetID()) require.NoError(t, err) - assert.Equal(t, "model-source-2", *retrieved.GetAttributes().Name) + // Repository returns stored name (namespaced: sourceId:modelName) + assert.Equal(t, sourceID2+":model-source-2", *retrieved.GetAttributes().Name) }) t.Run("TestDeleteByID", func(t *testing.T) { diff --git a/clients/ui/api/openapi/mod-arch.yaml b/clients/ui/api/openapi/mod-arch.yaml index 64742226f2..8509de8502 100644 --- a/clients/ui/api/openapi/mod-arch.yaml +++ b/clients/ui/api/openapi/mod-arch.yaml @@ -1275,6 +1275,7 @@ paths: - $ref: "#/components/parameters/modelRegistryName" - $ref: "#/components/parameters/kubeflowUserId" - $ref: "#/components/parameters/modelTransferJobName" + - $ref: "#/components/parameters/jobNamespace" responses: "200": $ref: "#/components/responses/ModelTransferJobResponse" @@ -1330,6 +1331,7 @@ paths: - $ref: "#/components/parameters/modelRegistryName" - $ref: "#/components/parameters/kubeflowUserId" - $ref: "#/components/parameters/modelTransferJobName" + - $ref: "#/components/parameters/jobNamespace" responses: "204": description: Successfully deleted @@ -2938,9 +2940,6 @@ components: password: type: string description: OCI registry password (request only, never returned) - email: - type: string - description: OCI registry email (request only, never returned) secretName: type: string description: Name of created K8s secret (response only) @@ -2951,11 +2950,17 @@ components: ModelTransferJob: description: A model transfer job entity. type: object + required: + - name + - jobDisplayName + - namespace properties: id: type: string name: type: string + jobDisplayName: + type: string description: type: string source: @@ -3728,7 +3733,7 @@ components: required: true jobNamespace: name: jobNamespace - description: The namespace where the job's pods are running, used to fetch pod events. + description: The namespace where the ModelTransferJob runs. schema: type: string in: query diff --git a/clients/ui/bff/README.md b/clients/ui/bff/README.md index cbdb57d185..9471fa048f 100644 --- a/clients/ui/bff/README.md +++ b/clients/ui/bff/README.md @@ -411,8 +411,8 @@ curl -i -H "Authorization: Bearer $TOKEN" "http://localhost:4000/api/v1/model_re ``` # GET api/v1/model_registry/model-registry/model_transfer_jobs/{job_name} -curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/test-job?namespace=kubeflow" -curl -i -H "Authorization: Bearer $TOKEN" "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/test-job?namespace=kubeflow" +curl -i -H "kubeflow-userid: user@example.com" "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/test-job?namespace=kubeflow&jobNamespace=kubeflow" +curl -i -H "Authorization: Bearer $TOKEN" "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/test-job?namespace=kubeflow&jobNamespace=kubeflow" ``` ``` @@ -483,19 +483,19 @@ curl -i \ -H "kubeflow-userid: user@example.com" \ -H "Content-Type: application/json" \ -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/my-job?namespace=kubeflow" \ - -d '{"data": {"name": "my-job-2"}}' + -d '{"data": {"name": "my-job-2", "namespace": "default", "jobDisplayName": "test-job"}}' curl -i -H "Authorization: Bearer $TOKEN" \ -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-002?namespace=bella-namespace" \ -H "Content-Type: application/json" \ --d '{"data": {"name": "my-job"}}' +-d '{"data": {"name": "my-job", "namespace": "default", "jobDisplayName": "test-job"}}' ``` ``` # DELETE api/v1/model_registry/model-registry/model_transfer_jobs/{job_name} -curl -i -H "kubeflow-userid: user@example.com" -X DELETE "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow" -curl -i -H "Authorization: Bearer $TOKEN" -X DELETE "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow" +curl -i -H "kubeflow-userid: user@example.com" -X DELETE "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow&jobNamespace=kubeflow" +curl -i -H "Authorization: Bearer $TOKEN" -X DELETE "http://localhost:4000/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow&jobNamespace=kubeflow" ``` ### Pagination diff --git a/clients/ui/bff/go.mod b/clients/ui/bff/go.mod index 67d1d6452c..ff0731bd73 100644 --- a/clients/ui/bff/go.mod +++ b/clients/ui/bff/go.mod @@ -7,15 +7,15 @@ require ( github.com/google/uuid v1.6.0 github.com/julienschmidt/httprouter v1.3.0 github.com/kubeflow/model-registry/pkg/openapi v0.3.2 - github.com/onsi/ginkgo/v2 v2.27.1 + github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 github.com/rs/cors v1.11.1 - github.com/stretchr/testify v1.11.0 + github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 - k8s.io/api v0.34.2 - k8s.io/apimachinery v0.34.2 - k8s.io/client-go v0.34.2 - sigs.k8s.io/controller-runtime v0.22.4 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 + sigs.k8s.io/controller-runtime v0.23.3 ) require ( @@ -32,7 +32,6 @@ require ( github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect @@ -42,39 +41,37 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.63.0 // indirect - github.com/prometheus/procfs v0.16.0 // indirect - github.com/spf13/pflag v1.0.7 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/x448/float16 v0.8.4 // indirect - go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/tools v0.38.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.8 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/apiextensions-apiserver v0.35.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/clients/ui/bff/go.sum b/clients/ui/bff/go.sum index 81669c6117..0e00360055 100644 --- a/clients/ui/bff/go.sum +++ b/clients/ui/bff/go.sum @@ -38,8 +38,6 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -57,8 +55,6 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -79,37 +75,33 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s= -github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= -github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -120,63 +112,34 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= -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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -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= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= @@ -184,33 +147,33 @@ google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= -k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= -k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= -k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= -k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= 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-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= -sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/clients/ui/bff/internal/api/middleware.go b/clients/ui/bff/internal/api/middleware.go index aec3c8cd4a..cb92548fb5 100644 --- a/clients/ui/bff/internal/api/middleware.go +++ b/clients/ui/bff/internal/api/middleware.go @@ -94,6 +94,7 @@ func (app *App) AttachModelCatalogRESTClient(next func(http.ResponseWriter, *htt namespace, ok := r.Context().Value(constants.NamespaceHeaderParameterKey).(string) if !ok || namespace == "" { app.badRequestResponse(w, r, fmt.Errorf("missing namespace in the context")) + return } client, err := app.kubernetesClientFactory.GetClient(r.Context()) diff --git a/clients/ui/bff/internal/api/model_transfer_job_handler.go b/clients/ui/bff/internal/api/model_transfer_job_handler.go index ef5f5d502d..95df221103 100644 --- a/clients/ui/bff/internal/api/model_transfer_job_handler.go +++ b/clients/ui/bff/internal/api/model_transfer_job_handler.go @@ -73,13 +73,18 @@ func (app *App) GetModelTransferJobHandler(w http.ResponseWriter, r *http.Reques return } + jobNamespace, err := getRequiredJobNamespace(r) + if err != nil { + app.badRequestResponse(w, r, err) + return + } modelRegistryID := ps.ByName(ModelRegistryId) if modelRegistryID == "" { app.badRequestResponse(w, r, fmt.Errorf("model registry name is required")) return } - modelTransferJob, err := app.repositories.ModelRegistry.GetModelTransferJob(ctx, client, namespace, jobName, modelRegistryID) + modelTransferJob, err := app.repositories.ModelRegistry.GetModelTransferJob(ctx, client, jobNamespace, jobName, modelRegistryID) if err != nil { if errors.Is(err, repositories.ErrJobNotFound) { @@ -246,13 +251,19 @@ func (app *App) DeleteModelTransferJobHandler(w http.ResponseWriter, r *http.Req return } + jobNamespace, err := getRequiredJobNamespace(r) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + modelRegistryID := ps.ByName(ModelRegistryId) if modelRegistryID == "" { app.badRequestResponse(w, r, fmt.Errorf("model registry name is required")) return } - deletedJob, err := app.repositories.ModelRegistry.DeleteModelTransferJob(ctx, client, namespace, jobName, modelRegistryID) + deletedJob, err := app.repositories.ModelRegistry.DeleteModelTransferJob(ctx, client, jobNamespace, jobName, modelRegistryID) if err != nil { if errors.Is(err, repositories.ErrJobNotFound) { app.notFoundResponse(w, r) @@ -297,9 +308,9 @@ func (app *App) GetModelTransferJobEventsHandler(w http.ResponseWriter, r *http. return } - jobNamespace := r.URL.Query().Get("jobNamespace") - if jobNamespace == "" { - app.badRequestResponse(w, r, fmt.Errorf("jobNamespace query parameter is required")) + jobNamespace, err := getRequiredJobNamespace(r) + if err != nil { + app.badRequestResponse(w, r, err) return } @@ -323,3 +334,11 @@ func (app *App) GetModelTransferJobEventsHandler(w http.ResponseWriter, r *http. return } } + +func getRequiredJobNamespace(r *http.Request) (string, error) { + jobNamespace := r.URL.Query().Get("jobNamespace") + if jobNamespace == "" { + return "", fmt.Errorf("missing required query parameter: jobNamespace") + } + return jobNamespace, nil +} diff --git a/clients/ui/bff/internal/api/model_transfer_job_handler_test.go b/clients/ui/bff/internal/api/model_transfer_job_handler_test.go index 4b740581b6..e4d2cf05aa 100644 --- a/clients/ui/bff/internal/api/model_transfer_job_handler_test.go +++ b/clients/ui/bff/internal/api/model_transfer_job_handler_test.go @@ -48,7 +48,7 @@ var _ = Describe("TestModelTransferJob", func() { It("GET single job returns 200 when job exists", func() { envelope, rs, err := setupApiTest[ModelTransferJobEnvelope]( http.MethodGet, - "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow", + "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow&jobNamespace=kubeflow", nil, kubernetesMockedStaticClientFactory, requestIdentity, @@ -64,7 +64,7 @@ var _ = Describe("TestModelTransferJob", func() { It("GET single job returns 404 for non-existent job", func() { _, rs, err := setupApiTest[Envelope[any, any]]( http.MethodGet, - "/api/v1/model_registry/model-registry/model_transfer_jobs/does-not-exist?namespace=kubeflow", + "/api/v1/model_registry/model-registry/model_transfer_jobs/does-not-exist?namespace=kubeflow&jobNamespace=kubeflow", nil, kubernetesMockedStaticClientFactory, requestIdentity, @@ -87,10 +87,23 @@ var _ = Describe("TestModelTransferJob", func() { Expect(rs.StatusCode).To(Equal(http.StatusBadRequest)) }) + It("GET single job returns 400 when jobNamespace is missing", func() { + _, rs, err := setupApiTest[Envelope[any, any]]( + http.MethodGet, + "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow", + nil, + kubernetesMockedStaticClientFactory, + requestIdentity, + "kubeflow", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest)) + }) + It("GET single job returns 404 when job exists but belongs to different registry", func() { _, rs, err := setupApiTest[Envelope[any, any]]( http.MethodGet, - "/api/v1/model_registry/other-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow", + "/api/v1/model_registry/other-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow&jobNamespace=kubeflow", nil, kubernetesMockedStaticClientFactory, requestIdentity, @@ -103,7 +116,7 @@ var _ = Describe("TestModelTransferJob", func() { It("GET single job returns 404 when namespace has no jobs", func() { _, rs, err := setupApiTest[Envelope[any, any]]( http.MethodGet, - "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=no-namespace", + "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=no-namespace&jobNamespace=no-namespace", nil, kubernetesMockedStaticClientFactory, requestIdentity, @@ -118,7 +131,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 201 on success", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job-create", + Name: "test-job-create", + JobDisplayName: "Test job create", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -169,7 +184,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 404 when model registry not found in namespace", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job-404", + Name: "test-job-404", + JobDisplayName: "Test job 404", + Namespace: "no-namespace", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -204,6 +221,7 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for missing job name", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ + Namespace: "kubeflow", // Name is missing Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, @@ -238,7 +256,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for invalid job name (too long)", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "this-job-name-is-way-too-long-and-exceeds-the-fifty-character-limit-for-dns", + Name: "this-job-name-is-way-too-long-and-exceeds-the-63-char-label-limit", + JobDisplayName: "Long name job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -272,7 +292,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for invalid job name (invalid characters)", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "INVALID_NAME!!!", + Name: "INVALID_NAME!!!", + JobDisplayName: "Invalid name job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -306,7 +328,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for missing source type", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ // Type is missing Bucket: "test-bucket", @@ -340,7 +364,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for S3 source missing bucket", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, // Bucket is missing @@ -374,7 +400,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for S3 source missing AWS credentials", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -407,7 +435,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for URI source missing URI", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, // URI is missing @@ -438,7 +468,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for missing destination credentials", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -471,7 +503,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for missing upload intent", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -505,7 +539,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for create_model intent missing model name", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -539,7 +575,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for create_version intent missing model ID", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -573,7 +611,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for update_artifact intent missing artifact ID", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -606,7 +646,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 201 for URI source type", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "uri-source-job", + Name: "uri-source-job", + JobDisplayName: "URI source job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://huggingface.co/test/model.safetensors", @@ -638,7 +680,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 201 for create_version intent with model ID", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "version-job", + Name: "version-job", + JobDisplayName: "Version job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model.bin", @@ -669,7 +713,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 201 for update_artifact intent with artifact ID", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "artifact-job", + Name: "artifact-job", + JobDisplayName: "Artifact job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model.bin", @@ -699,7 +745,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for invalid source type", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: "invalid_type", }, @@ -729,7 +777,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for invalid destination type", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model", @@ -760,7 +810,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for invalid upload intent", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model", @@ -791,7 +843,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for S3 source missing key", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeS3, Bucket: "test-bucket", @@ -825,7 +879,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for OCI destination missing URI", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model", @@ -856,7 +912,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for OCI destination missing password", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model", @@ -887,7 +945,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for create_model intent missing version name", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model", @@ -918,7 +978,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST returns 400 for create_version intent missing version name", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "test-job", + Name: "test-job", + JobDisplayName: "Test job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model", @@ -949,7 +1011,9 @@ var _ = Describe("TestModelTransferJob", func() { It("POST extracts registry from destination URI when not provided", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "registry-extract-job", + Name: "registry-extract-job", + JobDisplayName: "Registry extract job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model", @@ -984,7 +1048,9 @@ var _ = Describe("TestModelTransferJob", func() { It("PATCH returns 200 on success", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "new-job-name", + Name: "new-job-name", + JobDisplayName: "New job name", + Namespace: "kubeflow", }, } _, rs, err := setupApiTest[ModelTransferJobEnvelope]( @@ -1002,7 +1068,9 @@ var _ = Describe("TestModelTransferJob", func() { It("PATCH returns 404 for non-existent job", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "new-job-name", + Name: "new-job-name", + JobDisplayName: "New job name", + Namespace: "kubeflow", }, } _, rs, err := setupApiTest[Envelope[any, any]]( @@ -1020,6 +1088,7 @@ var _ = Describe("TestModelTransferJob", func() { It("PATCH returns 400 for missing new job name", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ + Namespace: "kubeflow", // Name is missing }, } @@ -1035,10 +1104,32 @@ var _ = Describe("TestModelTransferJob", func() { Expect(rs.StatusCode).To(Equal(http.StatusBadRequest)) }) + It("PATCH returns 400 when namespace is missing in request body (retry requires job namespace)", func() { + // URL has namespace in query, but retry must receive job namespace in body + payload := ModelTransferJobEnvelope{ + Data: &models.ModelTransferJob{ + Name: "new-job-name", + // Namespace intentionally omitted - required for retry + }, + } + _, rs, err := setupApiTest[Envelope[any, any]]( + http.MethodPatch, + "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow", + payload, + kubernetesMockedStaticClientFactory, + requestIdentity, + "kubeflow", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest)) + }) + It("PATCH returns 400 for invalid new job name", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "INVALID_NAME!!!", + Name: "INVALID_NAME!!!", + JobDisplayName: "Invalid name job", + Namespace: "kubeflow", }, } _, rs, err := setupApiTest[Envelope[any, any]]( @@ -1053,10 +1144,54 @@ var _ = Describe("TestModelTransferJob", func() { Expect(rs.StatusCode).To(Equal(http.StatusBadRequest)) }) + It("PATCH returns 400 for new job name too long (exceeds 63-char label limit)", func() { + payload := ModelTransferJobEnvelope{ + Data: &models.ModelTransferJob{ + // 65 chars: exceeds Kubernetes label value limit (63) + Name: "this-job-name-is-way-too-long-and-exceeds-the-63-char-label-limit", + JobDisplayName: "Long name job", + Namespace: "kubeflow", + }, + } + _, rs, err := setupApiTest[Envelope[any, any]]( + http.MethodPatch, + "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow", + payload, + kubernetesMockedStaticClientFactory, + requestIdentity, + "kubeflow", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest)) + }) + + It("PATCH returns 400 when job has not failed (retry only for failed jobs)", func() { + // transfer-job-004 is Running in test env; retry is only allowed for failed jobs. + payload := ModelTransferJobEnvelope{ + Data: &models.ModelTransferJob{ + Name: "retry-attempt-job", + JobDisplayName: "Retry attempt job", + Namespace: "kubeflow", + }, + } + _, rs, err := setupApiTest[Envelope[any, any]]( + http.MethodPatch, + "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-004?namespace=kubeflow", + payload, + kubernetesMockedStaticClientFactory, + requestIdentity, + "kubeflow", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest)) + }) + It("PATCH with deleteOldJob=true returns 200 on success", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "new-job-after-delete", + Name: "new-job-after-delete", + JobDisplayName: "New job after delete", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model.bin", @@ -1087,7 +1222,9 @@ var _ = Describe("TestModelTransferJob", func() { It("PATCH returns 400 when namespace is missing", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "new-job", + Name: "new-job", + JobDisplayName: "New job", + Namespace: "kubeflow", Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceTypeURI, URI: "https://test.com/model.bin", @@ -1134,7 +1271,9 @@ var _ = Describe("TestModelTransferJob", func() { It("PATCH returns 400 when new job name equals old job name", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "transfer-job-001", + Name: "transfer-job-001", + JobDisplayName: "Transfer job 001", + Namespace: "kubeflow", }, } _, rs, err := setupApiTest[Envelope[any, any]]( @@ -1152,7 +1291,9 @@ var _ = Describe("TestModelTransferJob", func() { It("PATCH returns 404 when job exists but belongs to different registry", func() { payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "new-job-name", + Name: "new-job-name", + JobDisplayName: "New job name", + Namespace: "kubeflow", }, } _, rs, err := setupApiTest[Envelope[any, any]]( @@ -1172,7 +1313,7 @@ var _ = Describe("TestModelTransferJob", func() { It("DELETE returns 200 on success", func() { _, rs, err := setupApiTest[Envelope[any, any]]( http.MethodDelete, - "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow", + "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow&jobNamespace=kubeflow", nil, kubernetesMockedStaticClientFactory, requestIdentity, @@ -1185,7 +1326,7 @@ var _ = Describe("TestModelTransferJob", func() { It("DELETE returns 404 for non-existent job", func() { _, rs, err := setupApiTest[Envelope[any, any]]( http.MethodDelete, - "/api/v1/model_registry/model-registry/model_transfer_jobs/does-not-exist?namespace=kubeflow", + "/api/v1/model_registry/model-registry/model_transfer_jobs/does-not-exist?namespace=kubeflow&jobNamespace=kubeflow", nil, kubernetesMockedStaticClientFactory, requestIdentity, @@ -1198,7 +1339,7 @@ var _ = Describe("TestModelTransferJob", func() { It("DELETE returns 404 when job exists but belongs to different registry", func() { _, rs, err := setupApiTest[Envelope[any, any]]( http.MethodDelete, - "/api/v1/model_registry/other-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow", + "/api/v1/model_registry/other-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow&jobNamespace=kubeflow", nil, kubernetesMockedStaticClientFactory, requestIdentity, @@ -1207,6 +1348,19 @@ var _ = Describe("TestModelTransferJob", func() { Expect(err).NotTo(HaveOccurred()) Expect(rs.StatusCode).To(Equal(http.StatusNotFound)) }) + + It("DELETE returns 400 when jobNamespace is missing", func() { + _, rs, err := setupApiTest[Envelope[any, any]]( + http.MethodDelete, + "/api/v1/model_registry/model-registry/model_transfer_jobs/transfer-job-001?namespace=kubeflow", + nil, + kubernetesMockedStaticClientFactory, + requestIdentity, + "kubeflow", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest)) + }) }) Context("fetching model transfer job events", func() { @@ -1326,7 +1480,8 @@ var _ = Describe("TestModelTransferJob retry metadata preservation", func() { // - ModelVersionName: Version One payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "retry-job-001", + Name: "retry-job-001", + Namespace: "kubeflow", }, } envelope, rs, err := setupApiTest[ModelTransferJobEnvelope]( @@ -1373,7 +1528,8 @@ var _ = Describe("TestModelTransferJob retry metadata preservation", func() { // so that deleting the old job doesn't affect the new job's secrets. payload := ModelTransferJobEnvelope{ Data: &models.ModelTransferJob{ - Name: "retry-job-with-creds", + Name: "retry-job-with-creds", + Namespace: "kubeflow", }, } envelope, rs, err := setupApiTest[ModelTransferJobEnvelope]( diff --git a/clients/ui/bff/internal/integrations/kubernetes/k8mocks/base_testenv.go b/clients/ui/bff/internal/integrations/kubernetes/k8mocks/base_testenv.go index e17bf508b5..0a8a27b17b 100644 --- a/clients/ui/bff/internal/integrations/kubernetes/k8mocks/base_testenv.go +++ b/clients/ui/bff/internal/integrations/kubernetes/k8mocks/base_testenv.go @@ -814,10 +814,9 @@ func createModelTransferJob(k8sClient kubernetes.Interface, ctx context.Context, }, Type: corev1.SecretTypeDockerConfigJson, StringData: map[string]string{ - ".dockerconfigjson": `{"auths":{"quay.io":{"auth":"bW9jazptb2Nr","email":"test@example.com"}}}`, + ".dockerconfigjson": `{"auths":{"quay.io":{"auth":"bW9jazptb2Nr"}}}`, "username": "", "password": "", - "email": "", "registry": "", }, } @@ -839,6 +838,7 @@ func createModelTransferJob(k8sClient kubernetes.Interface, ctx context.Context, "modelregistry.kubeflow.org/model-registry-name": registryName, }, Annotations: map[string]string{ + "modelregistry.kubeflow.org/display-name": "Transfer job 001", "modelregistry.kubeflow.org/registered-model-id": "1", "modelregistry.kubeflow.org/model-name": "Model One", "modelregistry.kubeflow.org/model-version-id": "1", @@ -927,7 +927,10 @@ func createModelTransferJob(k8sClient kubernetes.Interface, ctx context.Context, } createdJob1.Status = batchv1.JobStatus{ - Active: 1, + Failed: 1, + Conditions: []batchv1.JobCondition{ + {Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Message: "Simulated failure for testing retry"}, + }, } _, err = k8sClient.BatchV1().Jobs(namespace).UpdateStatus(ctx, createdJob1, metav1.UpdateOptions{}) if err != nil { @@ -967,6 +970,7 @@ func createModelTransferJob(k8sClient kubernetes.Interface, ctx context.Context, "modelregistry.kubeflow.org/model-registry-name": registryName, }, Annotations: map[string]string{ + "modelregistry.kubeflow.org/display-name": "Transfer job 002", "modelregistry.kubeflow.org/registered-model-id": "2", "modelregistry.kubeflow.org/model-name": "Model Two", "modelregistry.kubeflow.org/model-version-id": "3", @@ -1043,6 +1047,7 @@ func createModelTransferJob(k8sClient kubernetes.Interface, ctx context.Context, "modelregistry.kubeflow.org/model-registry-name": registryName, }, Annotations: map[string]string{ + "modelregistry.kubeflow.org/display-name": "Transfer job 003", "modelregistry.kubeflow.org/registered-model-id": "1", "modelregistry.kubeflow.org/model-name": "Model One", "modelregistry.kubeflow.org/model-version-id": "2", @@ -1087,6 +1092,44 @@ func createModelTransferJob(k8sClient kubernetes.Interface, ctx context.Context, return fmt.Errorf("failed to update job3 status: %w", err) } + job4 := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "transfer-job-004", + Namespace: namespace, + Labels: map[string]string{ + "modelregistry.kubeflow.org/job-type": "async-upload", + "modelregistry.kubeflow.org/job-id": "004", + "modelregistry.kubeflow.org/model-registry-name": registryName, + }, + Annotations: map[string]string{ + "modelregistry.kubeflow.org/display-name": "Transfer job 004", + "modelregistry.kubeflow.org/configmap-name": "transfer-job-001-config", + "modelregistry.kubeflow.org/dest-secret": "transfer-job-001-dest-secret", + "modelregistry.kubeflow.org/source-secret": "transfer-job-001-source-secret", + }, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + {Name: "async-upload", Image: "ghcr.io/kubeflow/model-registry/job/async-upload:latest"}, + }, + }, + }, + }, + } + createdJob4, err := k8sClient.BatchV1().Jobs(namespace).Create(ctx, job4, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create job4: %w", err) + } + createdJob4.Status = batchv1.JobStatus{Active: 1} + _, err = k8sClient.BatchV1().Jobs(namespace).UpdateStatus(ctx, createdJob4, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update job4 status: %w", err) + } + return nil } diff --git a/clients/ui/bff/internal/integrations/kubernetes/shared_k8s_client.go b/clients/ui/bff/internal/integrations/kubernetes/shared_k8s_client.go index 395a316702..18dbddf341 100644 --- a/clients/ui/bff/internal/integrations/kubernetes/shared_k8s_client.go +++ b/clients/ui/bff/internal/integrations/kubernetes/shared_k8s_client.go @@ -346,7 +346,7 @@ func (kc *SharedClientLogic) GetAllModelTransferJobs( labelSelector := "modelregistry.kubeflow.org/job-type=async-upload,modelregistry.kubeflow.org/model-registry-name=" + modelRegistryID - modelTransferJobList, err := kc.Client.BatchV1().Jobs(namespace).List(ctx, metav1.ListOptions{ + modelTransferJobList, err := kc.Client.BatchV1().Jobs(metav1.NamespaceAll).List(ctx, metav1.ListOptions{ LabelSelector: labelSelector, }) diff --git a/clients/ui/bff/internal/mocks/static_data_mock.go b/clients/ui/bff/internal/mocks/static_data_mock.go index 6491e0fc78..0f88bbd66e 100644 --- a/clients/ui/bff/internal/mocks/static_data_mock.go +++ b/clients/ui/bff/internal/mocks/static_data_mock.go @@ -2400,6 +2400,10 @@ func GetMcpServerMocks() []models.McpServer { URI: "oci://ghcr.io/prometheus-community/prometheus-mcp:0.9.2", }, }, + Readme: stringToPointer("# Prometheus MCP Server\n\nThe Prometheus MCP Server enables AI assistants to query Prometheus metrics and alerts using natural language.\n\n## Quickstart\n\n```bash\nnpx -y @prometheus-community/prometheus-mcp-server\n```\n\n## Features\n\n- Execute PromQL queries\n- Retrieve active alerts\n- Query metric metadata\n- Range queries with configurable time windows\n"), + SourceCode: stringToPointer("prometheus-community/prometheus-mcp"), + RepositoryURL: stringToPointer("https://github.com/prometheus-community/prometheus-mcp"), + LastUpdated: stringToPointer("1706745600000"), } kubernetesMcp := models.McpServer{ @@ -2429,6 +2433,10 @@ func GetMcpServerMocks() []models.McpServer { URI: "oci://ghcr.io/cncf/kubernetes-mcp:1.2.0", }, }, + Readme: stringToPointer("# Kubernetes MCP Server\n\nThe Kubernetes MCP Server allows AI Assistants to interact with Kubernetes clusters, bringing real-time cluster management directly into your development workflow.\n\n**Note:** This product is not officially supported by CNCF.\n\nIf you need help, please contact us via GitHub Issues if you have feature requests, questions, or need help.\n\n## Quickstart\n\nYou can add this MCP server to your MCP Client like VSCode, Claude, Cursor, Amazon Q, Windsurf, ChatGPT, or GitHub Copilot via the command `npx -y @cncf/kubernetes-mcp-server` (type: stdio). For more details, please refer to the configuration section below.\n\n## Use Cases\n\n- **Real-time cluster management** - Query pod, deployment, and service status\n- **Resource operations** - Create, update, and delete Kubernetes resources\n- **Health monitoring** - Check cluster health and resource utilization\n- **Namespace management** - List and manage namespaces and their contents\n- **Debugging support** - Get pod logs and events for troubleshooting\n\n## Tools\n\n| Tool | Description |\n|------|-------------|\n| `get_pods` | List pods in a namespace |\n| `get_deployments` | List deployments |\n| `get_services` | List services |\n| `get_namespaces` | List all namespaces |\n| `get_pod_logs` | Retrieve pod logs |\n| `get_events` | Get cluster events |\n| `apply_manifest` | Apply a Kubernetes manifest |\n| `delete_resource` | Delete a Kubernetes resource |\n\n## Configuration\n\n```json\n{\n \"mcpServers\": {\n \"kubernetes\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@cncf/kubernetes-mcp-server\"],\n \"env\": {\n \"KUBECONFIG\": \"/path/to/kubeconfig\"\n }\n }\n }\n}\n```\n\n## Requirements\n\n- Node.js 18+\n- Valid kubeconfig with cluster access\n- kubectl CLI (optional, for fallback operations)\n"), + SourceCode: stringToPointer("cncf/kubernetes-mcp-server"), + RepositoryURL: stringToPointer("https://github.com/cncf/kubernetes-mcp-server"), + LastUpdated: stringToPointer("1709913600000"), } elasticMcp := models.McpServer{ @@ -2480,6 +2488,8 @@ func GetMcpServerMocks() []models.McpServer { SourceCode: stringToPointer("dynatrace-oss/dynatrace-mcp-server"), RepositoryURL: stringToPointer("https://github.com/dynatrace-oss/dynatrace-mcp-server"), DocumentationURL: stringToPointer("https://github.com/dynatrace-oss/dynatrace-mcp-server/blob/main/README.md"), + Readme: stringToPointer("# Dynatrace MCP Server\n\nThe local Dynatrace MCP server allows AI Assistants to interact with the Dynatrace observability platform, bringing real-time observability data directly into your development workflow.\n\n**Note:** This product is not officially supported by Dynatrace.\n\nIf you need help, please contact us via GitHub Issues if you have feature requests, questions, or need help.\n\n## Quickstart\n\nYou can add this MCP server to your MCP Client like VSCode, Claude, Cursor, Amazon Q, Windsurf, ChatGPT, or GitHub Copilot via the command `npx -y @dynatrace-oss/dynatrace-mcp-server` (type: stdio). For more details, please refer to the configuration section below.\n\nFurthermore, you need to configure the URL to a Dynatrace environment:\n\n- `DT_ENVIRONMENT` (string, e.g., https://abc12345.apps.dynatrace.com) - URL to your Dynatrace Platform (do not use Dynatrace classic URLs like abc12345.live.dynatrace.com)\n\nOnce we are done, we recommend looking into example prompts, like \"Get all details of the entity 'my-service'\" or \"Show me error logs\". Please mind that these prompts lead to executing DQL statements which may incur costs in accordance to your licence.\n\n## Use Cases\n\n- **Real-time observability** - Fetch production-level data for early detection and proactive monitoring\n- **Contextual debugging** - Fix issues with full context from monitored exceptions, logs, and anomalies\n- **Security insights** - Get detailed vulnerability analysis and security problem tracking\n- **Natural language queries** - Use AI-powered DQL generation and explanation\n- **Multi-phase incident investigation** - Systematic 4-phase approach with automated impact assessment\n\n## Tools\n\n| Tool | Description |\n|------|-------------|\n| `execute_dql` | Execute Dynatrace Query Language (DQL) queries |\n| `get_problems` | Retrieve current problems and incidents |\n| `get_service_health` | Get health status of services |\n| `get_vulnerabilities` | Retrieve security vulnerability data |\n| `create_maintenance_window` | Create a maintenance window to suppress alerts |\n"), + LastUpdated: stringToPointer("1704067200000"), } return []models.McpServer{prometheusMcp, kubernetesMcp, elasticMcp, dynatraceMcp} diff --git a/clients/ui/bff/internal/models/model_transfer_job.go b/clients/ui/bff/internal/models/model_transfer_job.go index 99fe876c61..83626a2a28 100644 --- a/clients/ui/bff/internal/models/model_transfer_job.go +++ b/clients/ui/bff/internal/models/model_transfer_job.go @@ -53,7 +53,6 @@ type ModelTransferJobDestination struct { Type ModelTransferJobDestinationType `json:"type"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` - Email string `json:"email,omitempty"` URI string `json:"uri,omitempty"` Registry string `json:"registry,omitempty"` } @@ -70,6 +69,7 @@ type ModelTransferJobEvent struct { type ModelTransferJob struct { Id string `json:"id"` Name string `json:"name"` + JobDisplayName string `json:"jobDisplayName"` Description string `json:"description,omitempty"` Source ModelTransferJobSource `json:"source"` Destination ModelTransferJobDestination `json:"destination"` diff --git a/clients/ui/bff/internal/repositories/model_transfer_jobs.go b/clients/ui/bff/internal/repositories/model_transfer_jobs.go index 8b93dab1db..d1b9a7d481 100644 --- a/clients/ui/bff/internal/repositories/model_transfer_jobs.go +++ b/clients/ui/bff/internal/repositories/model_transfer_jobs.go @@ -42,6 +42,21 @@ func recoverEnumFromAnnotation[T ~string](target *T, annotations map[string]stri } } +func isK8sJobFailed(job *batchv1.Job) bool { + if job == nil { + return false + } + if job.Status.Failed > 0 { + return true + } + for _, c := range job.Status.Conditions { + if c.Type == batchv1.JobFailed && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} + func (m *ModelRegistryRepository) GetAllModelTransferJobs(ctx context.Context, client k8s.KubernetesClientInterface, namespace string, modelRegistryID string) (*models.ModelTransferJobList, error) { if modelRegistryID == "" { return &models.ModelTransferJobList{Items: []models.ModelTransferJob{}, Size: 0, PageSize: 0}, nil @@ -55,36 +70,43 @@ func (m *ModelRegistryRepository) GetAllModelTransferJobs(ctx context.Context, c } transferJobs := make([]models.ModelTransferJob, 0, len(jobList.Items)) - jobNames := make([]string, 0, len(jobList.Items)) + jobNamesByNamespace := make(map[string][]string) for _, job := range jobList.Items { if job.DeletionTimestamp != nil { continue } transferJobs = append(transferJobs, convertK8sJobToModel(&job)) - jobNames = append(jobNames, job.Name) - } + jobNamesByNamespace[job.Namespace] = append(jobNamesByNamespace[job.Namespace], job.Name) + } + + if len(transferJobs) > 0 { + type terminationResult struct { + RegisteredModel *struct { + ID string `json:"id"` + } `json:"RegisteredModel"` + ModelVersion *struct { + ID string `json:"id"` + } `json:"ModelVersion"` + ModelArtifact *struct { + ID string `json:"id"` + } `json:"ModelArtifact"` + } - if len(jobNames) > 0 { - podList, err := client.GetTransferJobPods(ctx, namespace, jobNames) - if err != nil { - logger.Warn("failed to fetch pods for transfer jobs", "error", err) - } else if len(podList.Items) > 0 { - type terminationResult struct { - RegisteredModel *struct { - ID string `json:"id"` - } `json:"RegisteredModel"` - ModelVersion *struct { - ID string `json:"id"` - } `json:"ModelVersion"` - ModelArtifact *struct { - ID string `json:"id"` - } `json:"ModelArtifact"` - } + podErrorsByJob := make(map[string]string) + podTerminationByJob := make(map[string]*terminationResult) - podErrorsByJob := make(map[string]string) - podTerminationByJob := make(map[string]*terminationResult) + for ns, jobNames := range jobNamesByNamespace { + podList, err := client.GetTransferJobPods(ctx, ns, jobNames) + if err != nil { + logger.Warn("failed to fetch pods for transfer jobs", "namespace", ns, "error", err) + continue + } + if len(podList.Items) == 0 { + continue + } for _, pod := range podList.Items { jobName := pod.Labels["job-name"] + key := ns + "/" + jobName for _, cs := range pod.Status.ContainerStatuses { if cs.State.Waiting != nil { @@ -96,7 +118,7 @@ func (m *ModelRegistryRepository) GetAllModelTransferJobs(ctx context.Context, c if msg == "" { msg = reason } - podErrorsByJob[jobName] = fmt.Sprintf("%s: %s", reason, msg) + podErrorsByJob[key] = fmt.Sprintf("%s: %s", reason, msg) break } } @@ -106,39 +128,40 @@ func (m *ModelRegistryRepository) GetAllModelTransferJobs(ctx context.Context, c if msg == "" { msg = cs.State.Terminated.Reason } - podErrorsByJob[jobName] = fmt.Sprintf("Container exited with code %d: %s", cs.State.Terminated.ExitCode, msg) + podErrorsByJob[key] = fmt.Sprintf("Container exited with code %d: %s", cs.State.Terminated.ExitCode, msg) } if cs.State.Terminated.Message != "" { var result terminationResult if err := json.Unmarshal([]byte(cs.State.Terminated.Message), &result); err == nil { - podTerminationByJob[jobName] = &result + podTerminationByJob[key] = &result } } break } } } + } - for i := range transferJobs { - if errMsg, ok := podErrorsByJob[transferJobs[i].Name]; ok { - if transferJobs[i].Status == models.ModelTransferJobStatusRunning || - transferJobs[i].Status == models.ModelTransferJobStatusPending { - transferJobs[i].Status = models.ModelTransferJobStatusFailed - if transferJobs[i].ErrorMessage == "" { - transferJobs[i].ErrorMessage = errMsg - } + for i := range transferJobs { + key := transferJobs[i].Namespace + "/" + transferJobs[i].Name + if errMsg, ok := podErrorsByJob[key]; ok { + if transferJobs[i].Status == models.ModelTransferJobStatusRunning || + transferJobs[i].Status == models.ModelTransferJobStatusPending { + transferJobs[i].Status = models.ModelTransferJobStatusFailed + if transferJobs[i].ErrorMessage == "" { + transferJobs[i].ErrorMessage = errMsg } } - if result, ok := podTerminationByJob[transferJobs[i].Name]; ok { - if result.RegisteredModel != nil && result.RegisteredModel.ID != "" { - transferJobs[i].RegisteredModelId = result.RegisteredModel.ID - } - if result.ModelVersion != nil && result.ModelVersion.ID != "" { - transferJobs[i].ModelVersionId = result.ModelVersion.ID - } - if result.ModelArtifact != nil && result.ModelArtifact.ID != "" { - transferJobs[i].ModelArtifactId = result.ModelArtifact.ID - } + } + if result, ok := podTerminationByJob[key]; ok { + if result.RegisteredModel != nil && result.RegisteredModel.ID != "" { + transferJobs[i].RegisteredModelId = result.RegisteredModel.ID + } + if result.ModelVersion != nil && result.ModelVersion.ID != "" { + transferJobs[i].ModelVersionId = result.ModelVersion.ID + } + if result.ModelArtifact != nil && result.ModelArtifact.ID != "" { + transferJobs[i].ModelArtifactId = result.ModelArtifact.ID } } } @@ -265,41 +288,41 @@ func (m *ModelRegistryRepository) createModelTransferJobResources( var configMapName, sourceSecretName, destSecretName string destSecretName = existingDestSecretName - configMap := buildModelMetadataConfigMap(jobName+"-metadata-configmap-", namespace, payload, jobID, jobName) - configMapCreated, err := client.CreateConfigMap(ctx, namespace, configMap) + configMap := buildModelMetadataConfigMap(jobName+"-metadata-configmap-", payload, jobID, jobName) + configMapCreated, err := client.CreateConfigMap(ctx, payload.Namespace, configMap) if err != nil { return nil, fmt.Errorf("failed to create metadata configmap: %w", err) } configMapName = configMapCreated.Name if payload.Source.Type == models.ModelTransferJobSourceTypeS3 { - sourceSecret := buildSourceSecret(jobName+"-source-creds-", namespace, payload, jobID) - sourceSecretCreated, err := client.CreateSecret(ctx, namespace, sourceSecret) + sourceSecret := buildSourceSecret(jobName+"-source-creds-", payload, jobID) + sourceSecretCreated, err := client.CreateSecret(ctx, payload.Namespace, sourceSecret) if err != nil { - cleanupCreatedResources(ctx, client, namespace, configMapName, "", "") + cleanupCreatedResources(ctx, client, payload.Namespace, configMapName, "", "") return nil, fmt.Errorf("failed to create source secret: %w", err) } sourceSecretName = sourceSecretCreated.Name } if existingDestSecretName == "" { - destSecret, err := buildDestinationSecret(jobName+"-dest-creds-", namespace, payload, jobID) + destSecret, err := buildDestinationSecret(jobName+"-dest-creds-", payload, jobID) if err != nil { - cleanupCreatedResources(ctx, client, namespace, configMapName, sourceSecretName, "") + cleanupCreatedResources(ctx, client, payload.Namespace, configMapName, sourceSecretName, "") return nil, fmt.Errorf("failed to build destination secret: %w", err) } - destSecretCreated, err := client.CreateSecret(ctx, namespace, destSecret) + destSecretCreated, err := client.CreateSecret(ctx, payload.Namespace, destSecret) if err != nil { - cleanupCreatedResources(ctx, client, namespace, configMapName, sourceSecretName, "") + cleanupCreatedResources(ctx, client, payload.Namespace, configMapName, sourceSecretName, "") return nil, fmt.Errorf("failed to create destination secret: %w", err) } destSecretName = destSecretCreated.Name } - job := buildK8sJob(jobName, namespace, jobID, payload, configMapName, sourceSecretName, destSecretName, modelRegistryAddress, modelRegistryID) - jobCreated, err := client.CreateModelTransferJob(ctx, namespace, job) + job := buildK8sJob(jobName, jobID, payload, configMapName, sourceSecretName, destSecretName, modelRegistryAddress, modelRegistryID) + jobCreated, err := client.CreateModelTransferJob(ctx, payload.Namespace, job) if err != nil { - cleanupCreatedResources(ctx, client, namespace, configMapName, sourceSecretName, destSecretName) + cleanupCreatedResources(ctx, client, payload.Namespace, configMapName, sourceSecretName, destSecretName) if apierrors.IsAlreadyExists(err) { return nil, fmt.Errorf("%w: job '%s' already exists", ErrJobValidationFailed, jobName) } @@ -308,8 +331,8 @@ func (m *ModelRegistryRepository) createModelTransferJobResources( if jobCreated == nil { logger.Error("created job is nil - unexpected K8s client behavior") - cleanupCreatedResources(ctx, client, namespace, configMapName, sourceSecretName, destSecretName) - if err := client.DeleteModelTransferJob(ctx, namespace, jobName); err != nil && !apierrors.IsNotFound(err) { + cleanupCreatedResources(ctx, client, payload.Namespace, configMapName, sourceSecretName, destSecretName) + if err := client.DeleteModelTransferJob(ctx, payload.Namespace, jobName); err != nil && !apierrors.IsNotFound(err) { logger.Warn("failed to cleanup job after nil response", "jobName", jobName, "error", err) } return nil, fmt.Errorf("unexpected Kubernetes API behavior: created job object was nil") @@ -322,15 +345,15 @@ func (m *ModelRegistryRepository) createModelTransferJobResources( UID: jobCreated.UID, } - if err := client.PatchConfigMapOwnerReference(ctx, namespace, configMapName, ownerRef); err != nil { + if err := client.PatchConfigMapOwnerReference(ctx, payload.Namespace, configMapName, ownerRef); err != nil { logger.Warn("failed to set ownerReference on configmap", "error", err) } if sourceSecretName != "" { - if err := client.PatchSecretOwnerReference(ctx, namespace, sourceSecretName, ownerRef); err != nil { + if err := client.PatchSecretOwnerReference(ctx, payload.Namespace, sourceSecretName, ownerRef); err != nil { logger.Warn("failed to set ownerReference on source secret", "error", err) } } - if err := client.PatchSecretOwnerReference(ctx, namespace, destSecretName, ownerRef); err != nil { + if err := client.PatchSecretOwnerReference(ctx, payload.Namespace, destSecretName, ownerRef); err != nil { logger.Warn("failed to set ownerReference on destination secret", "error", err) } @@ -350,12 +373,16 @@ func (m *ModelRegistryRepository) UpdateModelTransferJob( logger := helper.GetContextLogger(ctx) + if newPayload.Namespace == "" { + return nil, fmt.Errorf("%w: namespace is required in the request body for retry", ErrJobValidationFailed) + } + newJobName := newPayload.Name if newJobName == "" { return nil, fmt.Errorf("%w: new job name is required", ErrJobValidationFailed) } - if len(newJobName) > 50 { - return nil, fmt.Errorf("%w: job name must be 50 characters or less", ErrJobValidationFailed) + if len(newJobName) > 63 { + return nil, fmt.Errorf("%w: job name must be 63 characters or less", ErrJobValidationFailed) } if errs := validation.IsDNS1123Subdomain(newJobName); len(errs) > 0 { return nil, fmt.Errorf("%w: invalid job name: %s", ErrJobValidationFailed, strings.Join(errs, ", ")) @@ -364,7 +391,7 @@ func (m *ModelRegistryRepository) UpdateModelTransferJob( return nil, fmt.Errorf("%w: new job name must be different from old job name", ErrJobValidationFailed) } - oldJob, err := client.GetModelTransferJob(ctx, namespace, oldJobName) + oldJob, err := client.GetModelTransferJob(ctx, newPayload.Namespace, oldJobName) if err != nil { if apierrors.IsNotFound(err) { return nil, fmt.Errorf("%w: %s", ErrJobNotFound, oldJobName) @@ -382,6 +409,10 @@ func (m *ModelRegistryRepository) UpdateModelTransferJob( return nil, fmt.Errorf("%w: %s", ErrJobNotFound, oldJobName) } + if !isK8sJobFailed(oldJob) { + return nil, fmt.Errorf("%w: retry is only allowed for failed jobs; current job has not failed", ErrJobValidationFailed) + } + oldConfigMapName := oldAnnotations["modelregistry.kubeflow.org/configmap-name"] if oldConfigMapName == "" { return nil, fmt.Errorf("old job missing required annotation: configmap-name (job may not have been created via this API)") @@ -410,21 +441,25 @@ func (m *ModelRegistryRepository) UpdateModelTransferJob( recoverFromAnnotation(&newPayload.ModelArtifactId, oldAnnotations, "modelregistry.kubeflow.org/model-artifact-id") recoverFromAnnotation(&newPayload.Author, oldAnnotations, "modelregistry.kubeflow.org/author") recoverFromAnnotation(&newPayload.Description, oldAnnotations, "modelregistry.kubeflow.org/description") + recoverFromAnnotation(&newPayload.JobDisplayName, oldAnnotations, "modelregistry.kubeflow.org/display-name") + if newPayload.JobDisplayName == "" { + newPayload.JobDisplayName = oldJobName + } - oldConfigMap, err := client.GetConfigMap(ctx, namespace, oldConfigMapName) + oldConfigMap, err := client.GetConfigMap(ctx, newPayload.Namespace, oldConfigMapName) if err != nil { logger.Warn("failed to get old configmap", "name", oldConfigMapName, "error", err) } var oldSourceSecret *corev1.Secret if oldSourceSecretName != "" { - oldSourceSecret, err = client.GetSecret(ctx, namespace, oldSourceSecretName) + oldSourceSecret, err = client.GetSecret(ctx, newPayload.Namespace, oldSourceSecretName) if err != nil { logger.Warn("failed to get old source secret", "name", oldSourceSecretName, "error", err) } } - oldDestSecret, err := client.GetSecret(ctx, namespace, oldDestSecretName) + oldDestSecret, err := client.GetSecret(ctx, newPayload.Namespace, oldDestSecretName) if err != nil { return nil, fmt.Errorf("failed to get old dest secret: %w", err) } @@ -458,11 +493,11 @@ func (m *ModelRegistryRepository) UpdateModelTransferJob( var existingDestSecretName string if reuseDestCreds { jobID := uuid.NewString() - clonedSecret := cloneDestSecretFromExisting(newPayload.Name+"-dest-creds-", namespace, jobID, oldDestSecret) + clonedSecret := cloneDestSecretFromExisting(newPayload.Name+"-dest-creds-", newPayload.Namespace, jobID, oldDestSecret) if clonedSecret == nil { return nil, fmt.Errorf("could not clone destination secret for reuse") } - destSecretCreated, err := client.CreateSecret(ctx, namespace, clonedSecret) + destSecretCreated, err := client.CreateSecret(ctx, newPayload.Namespace, clonedSecret) if err != nil { return nil, fmt.Errorf("failed to create cloned destination secret: %w", err) } @@ -520,7 +555,7 @@ func (m *ModelRegistryRepository) UpdateModelTransferJob( result, err := m.createModelTransferJobResources(ctx, client, namespace, newPayload, modelRegistryID, existingDestSecretName) if err != nil { if reuseDestCreds && existingDestSecretName != "" { - if delErr := client.DeleteSecret(ctx, namespace, existingDestSecretName); delErr != nil { + if delErr := client.DeleteSecret(ctx, newPayload.Namespace, existingDestSecretName); delErr != nil { logger.Warn("failed to cleanup cloned destination secret after create failure", "name", existingDestSecretName, "error", delErr) } } @@ -528,7 +563,7 @@ func (m *ModelRegistryRepository) UpdateModelTransferJob( } if deleteOldJob { - if err := client.DeleteModelTransferJob(ctx, namespace, oldJobName); err != nil { + if err := client.DeleteModelTransferJob(ctx, newPayload.Namespace, oldJobName); err != nil { logger.Warn("failed to delete old job", "name", oldJobName, "error", err) } } @@ -577,7 +612,7 @@ func (m *ModelRegistryRepository) getModelRegistryAddress(ctx context.Context, c return modelRegistry.ServerAddress, nil } -func buildK8sJob(jobName, namespace, jobID string, payload models.ModelTransferJob, +func buildK8sJob(jobName, jobID string, payload models.ModelTransferJob, configMapName, sourceSecretName, destSecretName, modelRegistryAddress, modelRegistryID string) *batchv1.Job { backoffLimit := int32(3) @@ -623,11 +658,17 @@ func buildK8sJob(jobName, namespace, jobID string, payload models.ModelTransferJ {Name: "MODEL_SYNC_METADATA_CONFIGMAP_PATH", Value: "/etc/model-metadata"}, {Name: "MODEL_SYNC_MODEL_UPLOAD_INTENT", Value: string(payload.UploadIntent)}, } + + if payload.UploadIntent == models.ModelTransferJobUploadIntentCreateVersion && payload.RegisteredModelId != "" { + envVars = append(envVars, corev1.EnvVar{Name: "MODEL_SYNC_MODEL_ID", Value: payload.RegisteredModelId}) + } + if payload.Destination.Type == models.ModelTransferJobDestinationTypeOCI && payload.Destination.Registry == "quay.io" { envVars = append(envVars, corev1.EnvVar{Name: "MODEL_SYNC_DESTINATION_OCI_BASE_IMAGE", Value: "quay.io/quay/busybox:latest"}) } annotations := map[string]string{ + "modelregistry.kubeflow.org/display-name": payload.JobDisplayName, "modelregistry.kubeflow.org/source-type": string(payload.Source.Type), "modelregistry.kubeflow.org/dest-type": string(payload.Destination.Type), "modelregistry.kubeflow.org/dest-uri": payload.Destination.URI, @@ -678,7 +719,7 @@ func buildK8sJob(jobName, namespace, jobID string, payload models.ModelTransferJ return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: jobName, - Namespace: namespace, + Namespace: payload.Namespace, Labels: map[string]string{ "modelregistry.kubeflow.org/job-type": "async-upload", "modelregistry.kubeflow.org/job-id": jobID, @@ -761,9 +802,10 @@ func convertK8sJobToModel(job *batchv1.Job) models.ModelTransferJob { } return models.ModelTransferJob{ - Id: labels["modelregistry.kubeflow.org/job-id"], - Name: job.Name, - Description: annotations["modelregistry.kubeflow.org/description"], + Id: labels["modelregistry.kubeflow.org/job-id"], + Name: job.Name, + JobDisplayName: annotations["modelregistry.kubeflow.org/display-name"], + Description: annotations["modelregistry.kubeflow.org/description"], Source: models.ModelTransferJobSource{ Type: models.ModelTransferJobSourceType(annotations["modelregistry.kubeflow.org/source-type"]), Bucket: annotations["modelregistry.kubeflow.org/source-bucket"], @@ -793,7 +835,7 @@ func convertK8sJobToModel(job *batchv1.Job) models.ModelTransferJob { } } -func buildModelMetadataConfigMap(generateNamePrefix, namespace string, payload models.ModelTransferJob, jobID string, jobName string) *corev1.ConfigMap { +func buildModelMetadataConfigMap(generateNamePrefix string, payload models.ModelTransferJob, jobID string, jobName string) *corev1.ConfigMap { data := map[string]string{ "ModelVersion.name": payload.ModelVersionName, "ModelVersion.author": payload.Author, @@ -843,7 +885,7 @@ func buildModelMetadataConfigMap(generateNamePrefix, namespace string, payload m return &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ GenerateName: generateNamePrefix, - Namespace: namespace, + Namespace: payload.Namespace, Labels: map[string]string{ "modelregistry.kubeflow.org/job-type": "async-upload", "modelregistry.kubeflow.org/job-id": jobID, @@ -853,7 +895,7 @@ func buildModelMetadataConfigMap(generateNamePrefix, namespace string, payload m } } -func buildSourceSecret(generateNamePrefix, namespace string, payload models.ModelTransferJob, jobID string) *corev1.Secret { +func buildSourceSecret(generateNamePrefix string, payload models.ModelTransferJob, jobID string) *corev1.Secret { stringData := map[string]string{ "AWS_ACCESS_KEY_ID": payload.Source.AwsAccessKeyId, "AWS_SECRET_ACCESS_KEY": payload.Source.AwsSecretAccessKey, @@ -867,7 +909,7 @@ func buildSourceSecret(generateNamePrefix, namespace string, payload models.Mode return &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ GenerateName: generateNamePrefix, - Namespace: namespace, + Namespace: payload.Namespace, Labels: map[string]string{ "modelregistry.kubeflow.org/job-type": "async-upload", "modelregistry.kubeflow.org/job-id": jobID, @@ -878,7 +920,7 @@ func buildSourceSecret(generateNamePrefix, namespace string, payload models.Mode } } -func buildDestinationSecret(generateNamePrefix, namespace string, payload models.ModelTransferJob, jobID string) (*corev1.Secret, error) { +func buildDestinationSecret(generateNamePrefix string, payload models.ModelTransferJob, jobID string) (*corev1.Secret, error) { // NOTE: Due to async-upload bug, auth is NOT base64 encoded here auth := fmt.Sprintf("%s:%s", payload.Destination.Username, payload.Destination.Password) @@ -887,13 +929,13 @@ func buildDestinationSecret(generateNamePrefix, namespace string, payload models registry, _ = extractRegistryFromURI(payload.Destination.URI) } - dockerConfig := fmt.Sprintf(`{"auths":{"%s":{"auth":"%s","email":"%s"}}}`, - registry, auth, payload.Destination.Email) + dockerConfig := fmt.Sprintf(`{"auths":{"%s":{"auth":"%s"}}}`, + registry, auth) return &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ GenerateName: generateNamePrefix, - Namespace: namespace, + Namespace: payload.Namespace, Labels: map[string]string{ "modelregistry.kubeflow.org/job-type": "async-upload", "modelregistry.kubeflow.org/job-id": jobID, @@ -908,10 +950,19 @@ func buildDestinationSecret(generateNamePrefix, namespace string, payload models func validateCreatePayload(payload models.ModelTransferJob, skipDestCredsValidation bool) error { if payload.Name == "" { - return fmt.Errorf("%w: job name is required", ErrJobValidationFailed) + return fmt.Errorf("%w: job resource name is required", ErrJobValidationFailed) } - if len(payload.Name) > 50 { - return fmt.Errorf("%w: job name must be 50 characters or less", ErrJobValidationFailed) + + if payload.JobDisplayName == "" { + return fmt.Errorf("%w: job display name is required", ErrJobValidationFailed) + } + + if payload.Namespace == "" { + return fmt.Errorf("%w: job namespace is required", ErrJobValidationFailed) + } + + if len(payload.Name) > 63 { + return fmt.Errorf("%w: job name must be 63 characters or less (label limit)", ErrJobValidationFailed) } if errs := validation.IsDNS1123Subdomain(payload.Name); len(errs) > 0 { diff --git a/clients/ui/frontend/src/__mocks__/mockModelTransferJob.ts b/clients/ui/frontend/src/__mocks__/mockModelTransferJob.ts index 76e322f25b..fd68102f8b 100644 --- a/clients/ui/frontend/src/__mocks__/mockModelTransferJob.ts +++ b/clients/ui/frontend/src/__mocks__/mockModelTransferJob.ts @@ -10,6 +10,8 @@ import { export const mockModelTransferJob = (partial?: Partial): ModelTransferJob => ({ id: '1', name: 'model-transfer-job-1', + jobDisplayName: 'model-transfer-job-1', + namespace: 'test-namespace', description: 'Transfer job for fraud detection model', source: { type: ModelTransferJobSourceType.S3, diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/mcpCatalog.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/mcpCatalog.ts new file mode 100644 index 0000000000..e885efaac2 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/mcpCatalog.ts @@ -0,0 +1,127 @@ +import { appChrome } from './appChrome'; + +class McpCatalog { + visit() { + cy.visit('/mcp-catalog'); + this.wait(); + } + + private wait() { + cy.contains('MCP Catalog').should('exist'); + cy.testA11y(); + } + + tabEnabled() { + appChrome.findNavItem('MCP Catalog').should('exist'); + return this; + } + + findPageTitle() { + return cy.contains('MCP Catalog'); + } + + findPageDescription() { + return cy.contains( + 'Discover and manage MCP servers and tools available for your organization.', + ); + } + + findFilter(filterKey: string) { + return cy.findByTestId(`mcp-filter-${filterKey}`); + } + + findFilterShowMore(filterKey: string) { + return cy.findByTestId(`mcp-filter-${filterKey}-show-more`); + } + + findFilterCheckbox(filterKey: string, value: string) { + return cy.findByTestId(`mcp-filter-${filterKey}-${value}`); + } + + findMcpCatalogCards() { + return cy.get('[data-testid^="mcp-catalog-card-"]'); + } + + findMcpCatalogCard(serverId: string) { + return cy.findByTestId(`mcp-catalog-card-${serverId}`); + } + + findCardDetailLink(serverId: string) { + return cy.findByTestId(`mcp-catalog-card-detail-link-${serverId}`); + } +} + +class McpServerDetails { + visit(serverId: string) { + cy.visit(`/mcp-catalog/${serverId}`); + this.wait(); + } + + private wait() { + cy.findByTestId('app-page-title').should('exist'); + cy.testA11y(); + } + + findBreadcrumbCatalogLink() { + return cy.get('.pf-v6-c-breadcrumb').contains('MCP Catalog'); + } + + findBreadcrumbServerName() { + return cy.findByTestId('breadcrumb-server-name'); + } + + findDeployButton() { + return cy.findByTestId('deploy-mcp-server-button'); + } + + findDescription() { + return cy.findByTestId('mcp-server-description'); + } + + findReadmeMarkdown() { + return cy.findByTestId('mcp-server-readme-markdown'); + } + + findNoReadme() { + return cy.findByTestId('mcp-server-no-readme'); + } + + findVersion() { + return cy.findByTestId('mcp-server-version'); + } + + findDeploymentMode() { + return cy.findByTestId('mcp-server-deployment-mode'); + } + + findTransportType() { + return cy.findByTestId('mcp-server-transport-type'); + } + + findProvider() { + return cy.findByTestId('mcp-server-provider'); + } + + findLicense() { + return cy.findByTestId('mcp-server-license'); + } + + findLicenseLink() { + return cy.findByTestId('mcp-server-license-link'); + } + + findLabels() { + return cy.get('[data-testid="mcp-server-detail-label"]'); + } + + findArtifactCopy() { + return cy.get('[data-testid="mcp-server-artifact-copy"]'); + } + + findSourceCodeLink() { + return cy.findByTestId('mcp-server-source-code-link'); + } +} + +export const mcpCatalog = new McpCatalog(); +export const mcpServerDetails = new McpServerDetails(); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelCatalog.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelCatalog.ts index 13eed3c7b7..050f6fff4a 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelCatalog.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelCatalog.ts @@ -285,6 +285,14 @@ class ModelCatalog { return this; } + closeFilterChip(filterKey: string, value: string) { + cy.get(`[data-testid="${filterKey}-filter-chip-${value}"]`) + .closest('.pf-v6-c-label') + .find('button') + .click(); + return this; + } + // Model card content helpers for toggle-based display findValidatedModelBenchmarksCount() { return cy.findAllByTestId('validated-model-benchmarks'); @@ -333,7 +341,9 @@ class ModelCatalog { } findEmptyStateResetFiltersButton() { - return this.findModelCatalogEmptyState().findByRole('button', { name: /Reset filters/i }); + return this.findModelCatalogEmptyState().findByRole('button', { + name: /Reset all (defaults|filters)/i, + }); } clickApplyFilter() { diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerAndStoreFields.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerAndStoreFields.ts index 33f540f80b..4e547bfcdf 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerAndStoreFields.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registerAndStoreFields.ts @@ -120,6 +120,28 @@ class RegisterAndStoreFields { return cy.findByTestId('namespace-registry-access-alert'); } + findNamespaceLoadError() { + return cy.findByTestId('namespace-load-error'); + } + + shouldShowNamespaceLoadError() { + this.findNamespaceLoadError() + .should('be.visible') + .and('contain.text', 'Failed to load namespaces'); + return this; + } + + findNamespaceAccessCheckError() { + return cy.findByTestId('namespace-registry-access-error'); + } + + shouldShowNamespaceAccessCheckError() { + this.findNamespaceAccessCheckError() + .should('be.visible') + .and('contain.text', 'Could not verify namespace access'); + return this; + } + shouldShowNamespaceLabel() { this.findNamespaceFormGroup().find('label').should('contain.text', 'Namespace'); return this; diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpCatalog.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpCatalog.cy.ts new file mode 100644 index 0000000000..f1bec6d30b --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpCatalog.cy.ts @@ -0,0 +1,56 @@ +import { mcpCatalog } from '~/__tests__/cypress/cypress/pages/mcpCatalog'; +import { initMcpCatalogIntercepts } from './mcpCatalogTestUtils'; + +describe('MCP Catalog Page', () => { + beforeEach(() => { + initMcpCatalogIntercepts(); + }); + + it('MCP Catalog tab should be enabled in nav', () => { + mcpCatalog.visit(); + mcpCatalog.tabEnabled(); + }); + + it('should display page title and description', () => { + mcpCatalog.visit(); + mcpCatalog.findPageTitle().should('be.visible'); + mcpCatalog.findPageDescription().should('be.visible'); + }); + + it('should display MCP server cards', () => { + mcpCatalog.visit(); + cy.get('[data-testid^="mcp-catalog-card-"]', { timeout: 15000 }).should( + 'have.length.at.least', + 1, + ); + }); + + it('should display sidebar filters', () => { + mcpCatalog.visit(); + mcpCatalog.findFilter('deploymentMode').should('be.visible'); + mcpCatalog.findFilter('supportedTransports').should('be.visible'); + mcpCatalog.findFilter('license').should('be.visible'); + mcpCatalog.findFilter('labels').should('be.visible'); + mcpCatalog.findFilter('securityVerification').should('be.visible'); + }); + + it('should display Deployment mode filter with Local and Remote options', () => { + mcpCatalog.visit(); + mcpCatalog.findFilterCheckbox('deploymentMode', 'Local').should('be.visible'); + mcpCatalog.findFilterCheckbox('deploymentMode', 'Remote').should('be.visible'); + }); + + it('filter Show more should expand labels list', () => { + mcpCatalog.visit(); + mcpCatalog.findFilterShowMore('labels').scrollIntoView(); + mcpCatalog.findFilterShowMore('labels').click(); + cy.findByTestId('mcp-filter-labels-show-less').scrollIntoView(); + cy.findByTestId('mcp-filter-labels-show-less').should('be.visible'); + }); + + it('should display known mock server cards', () => { + mcpCatalog.visit(); + cy.get('[data-testid="mcp-catalog-card-1"]', { timeout: 15000 }).should('be.visible'); + cy.get('[data-testid="mcp-catalog-card-2"]', { timeout: 15000 }).should('be.visible'); + }); +}); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpCatalogTestUtils.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpCatalogTestUtils.ts new file mode 100644 index 0000000000..3a3ce0e2f4 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpCatalogTestUtils.ts @@ -0,0 +1,49 @@ +import { mockModArchResponse } from 'mod-arch-core'; +import { mockMcpServers } from '~/app/pages/mcpCatalog/mocks/mockMcpServers'; +import { mockMcpCatalogFilterOptions } from '~/app/pages/mcpCatalog/mocks/mockMcpCatalogFilterOptions'; +import { mockCatalogSource, mockCatalogSourceList } from '~/__mocks__'; +import { MODEL_CATALOG_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; + +export const MCP_SERVERS_RESPONSE = { + items: mockMcpServers, + size: mockMcpServers.length, + pageSize: 10, + nextPageToken: '', +}; + +export const MCP_SERVERS_PATH = `/model-registry/api/${MODEL_CATALOG_API_VERSION}/model_catalog/mcp_servers`; + +export const MCP_FILTER_OPTIONS_PATH = `/model-registry/api/${MODEL_CATALOG_API_VERSION}/model_catalog/mcp_servers_filter_options`; + +export const initMcpCatalogIntercepts = (): void => { + cy.intercept('GET', '*mcp_servers*', mockModArchResponse(MCP_SERVERS_RESPONSE)); + cy.intercept( + 'GET', + `**/api/${MODEL_CATALOG_API_VERSION}/model_catalog/mcp_servers*`, + mockModArchResponse(MCP_SERVERS_RESPONSE), + ); + cy.intercept( + { method: 'GET', pathname: MCP_SERVERS_PATH }, + mockModArchResponse(MCP_SERVERS_RESPONSE), + ); + cy.interceptApi( + `GET /api/:apiVersion/model_catalog/sources`, + { path: { apiVersion: MODEL_CATALOG_API_VERSION } }, + mockCatalogSourceList({ items: [mockCatalogSource({})] }), + ); + cy.intercept( + { method: 'GET', pathname: MCP_FILTER_OPTIONS_PATH }, + mockModArchResponse(mockMcpCatalogFilterOptions), + ); +}; + +export const initServerDetailIntercept = (server: (typeof mockMcpServers)[number]): void => { + cy.intercept( + { + method: 'GET', + pathname: `${MCP_SERVERS_PATH}/${server.id}`, + }, + mockModArchResponse(server), + ); + cy.intercept('GET', `**/mcp_servers/${server.id}`, mockModArchResponse(server)); +}; diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpServerDetails.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpServerDetails.cy.ts new file mode 100644 index 0000000000..3796b289a5 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/mcpCatalog/mcpServerDetails.cy.ts @@ -0,0 +1,135 @@ +import { mcpCatalog, mcpServerDetails } from '~/__tests__/cypress/cypress/pages/mcpCatalog'; +import { mockMcpServers } from '~/app/pages/mcpCatalog/mocks/mockMcpServers'; +import { initMcpCatalogIntercepts, initServerDetailIntercept } from './mcpCatalogTestUtils'; + +const kubernetesServer = mockMcpServers.find((s) => s.name === 'Kubernetes')!; +const customServer = mockMcpServers.find((s) => s.name === 'Custom MCP Server')!; + +describe('MCP Server Details Page', () => { + beforeEach(() => { + initMcpCatalogIntercepts(); + }); + + describe('Navigation from catalog', () => { + it('should navigate to details page when clicking server card link', () => { + mcpCatalog.visit(); + cy.get(`[data-testid="mcp-catalog-card-detail-link-1"]`, { timeout: 15000 }).should( + 'be.visible', + ); + mcpCatalog.findCardDetailLink('1').click(); + cy.url().should('include', '/mcp-catalog/1'); + }); + }); + + describe('Breadcrumb navigation', () => { + beforeEach(() => { + initServerDetailIntercept(kubernetesServer); + }); + + it('should display breadcrumb with MCP Catalog link and server name', () => { + mcpServerDetails.visit(String(kubernetesServer.id)); + mcpServerDetails.findBreadcrumbCatalogLink().should('be.visible'); + mcpServerDetails.findBreadcrumbServerName().should('contain.text', kubernetesServer.name); + }); + + it('should navigate back to catalog when clicking breadcrumb link', () => { + mcpServerDetails.visit(String(kubernetesServer.id)); + mcpServerDetails.findBreadcrumbCatalogLink().click(); + cy.url().should('include', '/mcp-catalog'); + cy.url().should('not.include', `/${kubernetesServer.id}`); + }); + }); + + describe('Server header and description', () => { + beforeEach(() => { + initServerDetailIntercept(kubernetesServer); + }); + + it('should display server name and description', () => { + mcpServerDetails.visit(String(kubernetesServer.id)); + + cy.findByTestId('app-page-title').should('contain.text', kubernetesServer.name); + + mcpServerDetails.findDescription().should('contain.text', kubernetesServer.description); + }); + }); + + describe('README card', () => { + it('should render README with markdown elements', () => { + initServerDetailIntercept(kubernetesServer); + mcpServerDetails.visit(String(kubernetesServer.id)); + mcpServerDetails.findReadmeMarkdown().should('be.visible'); + mcpServerDetails.findReadmeMarkdown().should('contain.text', 'Kubernetes MCP Server'); + mcpServerDetails.findReadmeMarkdown().find('h3').should('exist'); + mcpServerDetails.findReadmeMarkdown().find('code').should('exist'); + }); + + it('should display empty state when no README is available', () => { + initServerDetailIntercept(customServer); + mcpServerDetails.visit(String(customServer.id)); + mcpServerDetails.findNoReadme().should('be.visible'); + mcpServerDetails.findNoReadme().should('contain.text', 'No README available'); + }); + }); + + describe('Server details sidebar', () => { + beforeEach(() => { + initServerDetailIntercept(kubernetesServer); + }); + + it('should display labels, license, version, and deployment mode', () => { + mcpServerDetails.visit(String(kubernetesServer.id)); + + mcpServerDetails.findLabels().should('have.length.at.least', 1); + + mcpServerDetails.findLicenseLink().should('be.visible'); + mcpServerDetails.findLicenseLink().should('contain.text', kubernetesServer.license); + + mcpServerDetails.findVersion().should('contain.text', kubernetesServer.version); + + mcpServerDetails.findDeploymentMode().should('contain.text', 'Local to cluster'); + }); + + it('should display artifacts, source code, provider, and transport type', () => { + mcpServerDetails.visit(String(kubernetesServer.id)); + + mcpServerDetails.findArtifactCopy().should('be.visible'); + mcpServerDetails + .findArtifactCopy() + .first() + .find('input') + .should('have.value', kubernetesServer.artifacts![0].uri); + + mcpServerDetails.findSourceCodeLink().should('be.visible'); + + mcpServerDetails.findProvider().should('contain.text', kubernetesServer.provider); + + mcpServerDetails.findTransportType().should('contain.text', 'http-streaming'); + }); + }); + + describe('Error handling', () => { + it('should show not-found state for invalid server ID', () => { + cy.visit('/mcp-catalog/999'); + cy.findByTestId('mcp-server-not-found').should('be.visible'); + cy.contains('MCP server not found').should('be.visible'); + }); + }); + + describe('Browser navigation', () => { + beforeEach(() => { + initServerDetailIntercept(kubernetesServer); + }); + + it('should support browser back navigation', () => { + mcpCatalog.visit(); + cy.get(`[data-testid="mcp-catalog-card-detail-link-1"]`, { timeout: 15000 }).should( + 'be.visible', + ); + mcpCatalog.findCardDetailLink('1').click(); + cy.url().should('include', '/mcp-catalog/1'); + cy.go('back'); + cy.url().should('eq', `${Cypress.config().baseUrl}/mcp-catalog`); + }); + }); +}); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalog.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalog.cy.ts index 799705f808..ad73605ed7 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalog.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/modelCatalog.cy.ts @@ -499,7 +499,7 @@ describe('Performance Empty State', () => { modelCatalog.findFilterCheckbox('Task', 'audio-to-text').click(); modelCatalog.findPerformanceEmptyState().should('not.exist'); - modelCatalog.findModelCatalogEmptyState().should('contain.text', 'No result found'); + modelCatalog.findModelCatalogEmptyState().should('contain.text', 'No results found'); modelCatalog.findEmptyStateResetFiltersButton().click(); modelCatalog.findPerformanceEmptyState().should('be.visible'); @@ -533,7 +533,7 @@ describe('Performance Empty State', () => { modelCatalog.findModelCatalogCards().should('not.exist'); }); - it('should show "No result found" when toggle is ON and user applies filter that returns 0 results', () => { + it('should show "No results found" when toggle is ON and user applies filter that returns 0 results', () => { initIntercepts({ sources: [mockCatalogSource({ labels: ['Provider one'] })], hasValidatedModels: true, @@ -546,10 +546,10 @@ describe('Performance Empty State', () => { modelCatalog.findFilterShowMoreButton('Task').click(); modelCatalog.findFilterCheckbox('Task', 'audio-to-text').click(); modelCatalog.findPerformanceEmptyState().should('not.exist'); - modelCatalog.findModelCatalogEmptyState().should('contain.text', 'No result found'); + modelCatalog.findModelCatalogEmptyState().should('contain.text', 'No results found'); modelCatalog.findAllModelsToggle().click(); modelCatalog.findPerformanceEmptyState().should('not.exist'); - modelCatalog.findModelCatalogEmptyState().should('contain.text', 'No result found'); + modelCatalog.findModelCatalogEmptyState().should('contain.text', 'No results found'); }); }); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalogSettings/modelCatalogPerformanceFiltersAlert.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalogSettings/modelCatalogPerformanceFiltersAlert.cy.ts index fd360073be..7668148038 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalogSettings/modelCatalogPerformanceFiltersAlert.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalogSettings/modelCatalogPerformanceFiltersAlert.cy.ts @@ -157,6 +157,48 @@ describe('Model Catalog Performance Filters Alert', () => { modelCatalog.findPerformanceFiltersUpdatedAlert().should('not.exist'); }); + + it('should hide alert when Reset all defaults is clicked on catalog page', () => { + modelCatalog.togglePerformanceView(); + modelCatalog.findLoadingState().should('not.exist'); + + modelCatalog.findModelCatalogDetailLink().first().click(); + modelCatalog.clickPerformanceInsightsTab(); + + modelCatalog.findWorkloadTypeFilter().click(); + modelCatalog.selectWorkloadType('code_fixing'); + + cy.go('back'); + cy.go('back'); + modelCatalog.findLoadingState().should('not.exist'); + + modelCatalog.findPerformanceFiltersUpdatedAlert().should('be.visible'); + + cy.findByRole('button', { name: 'Reset all defaults' }).click(); + + modelCatalog.findPerformanceFiltersUpdatedAlert().should('not.exist'); + }); + + it('should hide alert when a performance filter chip is removed on catalog page', () => { + modelCatalog.togglePerformanceView(); + modelCatalog.findLoadingState().should('not.exist'); + + modelCatalog.findModelCatalogDetailLink().first().click(); + modelCatalog.clickPerformanceInsightsTab(); + + modelCatalog.findWorkloadTypeFilter().click(); + modelCatalog.selectWorkloadType('code_fixing'); + + cy.go('back'); + cy.go('back'); + modelCatalog.findLoadingState().should('not.exist'); + + modelCatalog.findPerformanceFiltersUpdatedAlert().should('be.visible'); + + modelCatalog.closeFilterChip('artifacts.use_case.string_value', 'code_fixing'); + + modelCatalog.findPerformanceFiltersUpdatedAlert().should('not.exist'); + }); }); describe('Multiple Filter Changes', () => { diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalogSettings/modelCatalogPerformanceFiltersApi.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalogSettings/modelCatalogPerformanceFiltersApi.cy.ts index c64a782a76..6f1ea49259 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalogSettings/modelCatalogPerformanceFiltersApi.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalogSettings/modelCatalogPerformanceFiltersApi.cy.ts @@ -269,7 +269,7 @@ describe('Model Catalog Performance Filters API Behavior', () => { changeWorkloadTypeFilter(); // Click Clear all filters button in the toolbar (PatternFly's native button) - cy.findByRole('button', { name: 'Reset all filters' }).click(); + cy.findByRole('button', { name: 'Reset all defaults' }).click(); // Verify filters are reset to defaults - workload type should NOT show Code Fixing cy.findByTestId(PERFORMANCE_FILTER_TEST_IDS.workloadType) @@ -288,7 +288,7 @@ describe('Model Catalog Performance Filters API Behavior', () => { modelCatalog.clickApplyFilter(); // Click 'Reset all defaults (PatternFly's native button) - cy.findByRole('button', { name: 'Reset all filters' }).click(); + cy.findByRole('button', { name: 'Reset all defaults' }).click(); // Latency filter should be reset to default (TTFT, not E2E) cy.findByTestId(PERFORMANCE_FILTER_TEST_IDS.latency) diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelTransferJobs.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelTransferJobs.cy.ts index ab4ce1e3cc..ed1a100ad3 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelTransferJobs.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelTransferJobs.cy.ts @@ -8,8 +8,16 @@ const modelRegistryName = 'modelregistry-sample'; const jobList = mockModelTransferJobList({ items: [ - mockModelTransferJob({ id: 'job-to-delete', name: 'job-to-delete' }), - mockModelTransferJob({ id: 'job-to-keep', name: 'job-to-keep' }), + mockModelTransferJob({ + id: 'job-to-delete', + name: 'job-to-delete', + jobDisplayName: 'job-to-delete', + }), + mockModelTransferJob({ + id: 'job-to-keep', + name: 'job-to-keep', + jobDisplayName: 'job-to-keep', + }), ], size: 2, pageSize: 10, diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerAndStoreFields.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerAndStoreFields.cy.ts index d66bfd41e1..b4335989cb 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerAndStoreFields.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerAndStoreFields.cy.ts @@ -189,6 +189,79 @@ describe('Register and Store Fields - Namespace access validation', () => { }); }); +describe('Register and Store Fields - Namespace loading error', () => { + beforeEach(() => { + initIntercepts({}); + // Use a dynamic handler: the first call (from the app context/navbar) must succeed + // for the page to render; only the second call (from the form's useNamespaces) should fail. + let namespaceFetchCount = 0; + cy.intercept( + { + method: 'GET', + pathname: `/model-registry/api/${MODEL_REGISTRY_API_VERSION}/namespaces`, + }, + (req) => { + namespaceFetchCount += 1; + if (namespaceFetchCount <= 1) { + req.reply({ statusCode: 200, body: { data: [mockNamespace({})] } }); + } else { + req.reply({ statusCode: 500, body: { error: 'failed to list namespaces' } }); + } + }, + ).as('getNamespacesError'); + registerAndStoreFields.visit(); + registerAndStoreFields.selectRegisterAndStoreMode(); + }); + + it('Should show error alert when namespace loading fails', () => { + registerAndStoreFields.shouldShowNamespaceLoadError(); + }); + + it('Should disable namespace selector when loading fails', () => { + registerAndStoreFields.shouldBeNamespaceSelectorDisabled(); + }); + + it('Should hide form sections when namespace loading fails', () => { + registerAndStoreFields.shouldHideOriginLocationSection(); + registerAndStoreFields.shouldHideDestinationLocationSection(); + }); + + it('Should keep Create button disabled when namespace loading fails', () => { + registerAndStoreFields.shouldHaveCreateButtonDisabled(); + }); +}); + +describe('Register and Store Fields - Namespace access check error', () => { + beforeEach(() => { + initIntercepts({}); + cy.intercept('POST', '**/api/v1/check-namespace-registry-access', { + statusCode: 500, + body: { error: 'failed to check namespace access' }, + }).as('checkNamespaceAccessError'); + registerAndStoreFields.visit(); + registerAndStoreFields.selectRegisterAndStoreMode(); + }); + + it('Should show error alert when namespace access check fails', () => { + registerAndStoreFields.selectNamespace('namespace-1'); + cy.wait('@checkNamespaceAccessError'); + registerAndStoreFields.shouldShowNamespaceAccessCheckError(); + }); + + it('Should hide form sections when access check fails', () => { + registerAndStoreFields.selectNamespace('namespace-1'); + cy.wait('@checkNamespaceAccessError'); + registerAndStoreFields.shouldHideOriginLocationSection(); + registerAndStoreFields.shouldHideDestinationLocationSection(); + }); + + it('Should keep Create button disabled when access check fails', () => { + registerAndStoreFields.selectNamespace('namespace-1'); + cy.wait('@checkNamespaceAccessError'); + registerAndStoreFields.shouldHaveCreateButtonDisabled(); + }); +}); + describe('Register and Store Fields - Who is my admin popover (namespace wording)', () => { beforeEach(() => { initIntercepts({ namespaces: [] }); diff --git a/clients/ui/frontend/src/app/AppRoutes.tsx b/clients/ui/frontend/src/app/AppRoutes.tsx index bbd606d434..09ecf2c859 100644 --- a/clients/ui/frontend/src/app/AppRoutes.tsx +++ b/clients/ui/frontend/src/app/AppRoutes.tsx @@ -6,8 +6,10 @@ import { NavDataItem } from '~/app/standalone/types'; import ModelRegistrySettingsRoutes from './pages/settings/ModelRegistrySettingsRoutes'; import ModelRegistryRoutes from './pages/modelRegistry/ModelRegistryRoutes'; import ModelCatalogRoutes from './pages/modelCatalog/ModelCatalogRoutes'; +import McpCatalogRoutes from './pages/mcpCatalog/McpCatalogRoutes'; import ModelCatalogSettingsRoutes from './pages/modelCatalogSettings/ModelCatalogSettingsRoutes'; import { modelCatalogUrl } from './routes/modelCatalog/catalogModel'; +import { mcpCatalogUrl } from './routes/mcpCatalog/mcpCatalog'; import { catalogSettingsUrl, CATALOG_SETTINGS_PAGE_TITLE, @@ -53,12 +55,11 @@ export const useNavData = (): NavDataItem[] => { }, ]; - // Only show Model Catalog in Standalone or Federated mode if (isStandalone || isFederated) { - baseNavItems.push({ - label: 'Model Catalog', - path: modelCatalogUrl(), - }); + baseNavItems.push( + { label: 'Model Catalog', path: modelCatalogUrl() }, + { label: 'MCP Catalog', path: mcpCatalogUrl() }, + ); } return [...baseNavItems, ...useAdminSettings()]; @@ -78,6 +79,7 @@ const AppRoutes: React.FC = () => { {(isStandalone || isFederated) && ( <> } /> + } /> } /> )} diff --git a/clients/ui/frontend/src/app/api/service.ts b/clients/ui/frontend/src/app/api/service.ts index 0b18f12c98..ea69b671ed 100644 --- a/clients/ui/frontend/src/app/api/service.ts +++ b/clients/ui/frontend/src/app/api/service.ts @@ -263,14 +263,14 @@ export const updateModelTransferJob = (hostPath: string, queryParams: Record = {}) => ( opts: APIOptions, - jobId: string, + jobName: string, data: Partial, additionalQueryParams?: Record, ): Promise => handleRestFailures( restPATCH( hostPath, - `/model_transfer_jobs/${jobId}`, + `/model_transfer_jobs/${jobName}`, assembleModArchBody(data), { ...queryParams, ...additionalQueryParams }, opts, @@ -284,13 +284,13 @@ export const updateModelTransferJob = export const deleteModelTransferJob = (hostPath: string, queryParams: Record = {}) => - (opts: APIOptions, jobName: string): Promise => + (opts: APIOptions, jobName: string, jobNamespace: string): Promise => handleRestFailures( restDELETE( hostPath, `/model_transfer_jobs/${encodeURIComponent(jobName)}`, {}, - queryParams, + { ...queryParams, jobNamespace }, opts, ), ); diff --git a/clients/ui/frontend/src/app/context/mcpCatalog/McpCatalogContext.tsx b/clients/ui/frontend/src/app/context/mcpCatalog/McpCatalogContext.tsx new file mode 100644 index 0000000000..e11dc90dda --- /dev/null +++ b/clients/ui/frontend/src/app/context/mcpCatalog/McpCatalogContext.tsx @@ -0,0 +1,199 @@ +import * as React from 'react'; +import { useQueryParamNamespaces } from 'mod-arch-core'; +import { BFF_API_VERSION, URL_PREFIX } from '~/app/utilities/const'; +import useModelCatalogAPIState from '~/app/hooks/modelCatalog/useModelCatalogAPIState'; +import { useCatalogSources } from '~/app/hooks/modelCatalog/useCatalogSources'; +import { useMcpServersBySourceLabelWithAPI } from '~/app/hooks/mcpServerCatalog/useMcpServersBySourceLabel'; +import { useMcpServerFilterOptionListWithAPI } from '~/app/hooks/mcpServerCatalog/useMcpServerFilterOptionList'; +import { + filterEnabledCatalogSources, + getUniqueSourceLabels, +} from '~/app/pages/modelCatalog/utils/modelCatalogUtils'; +import type { + McpCatalogContextType, + McpCatalogPaginationState, +} from '~/app/pages/mcpCatalog/types/mcpCatalogContext'; +import type { McpCatalogFiltersState } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; +import { + filterMcpServersByFilters, + filterMcpServersBySearchQuery, +} from '~/app/pages/mcpCatalog/utils/mcpCatalogUtils'; +import { mockMcpServers } from '~/app/pages/mcpCatalog/mocks/mockMcpServers'; + +export type { + McpCatalogContextType, + McpCatalogPaginationState, +} from '~/app/pages/mcpCatalog/types/mcpCatalogContext'; +export type { McpCatalogFiltersState } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +type McpCatalogContextProviderProps = { + children: React.ReactNode; +}; + +const defaultPagination: McpCatalogPaginationState = { + page: 1, + pageSize: 10, + totalItems: 0, +}; + +export const McpCatalogContext = React.createContext({ + filters: {}, + setFilters: () => undefined, + searchQuery: '', + setSearchQuery: () => undefined, + namedQuery: null, + setNamedQuery: () => undefined, + pagination: defaultPagination, + setPage: () => undefined, + setPageSize: () => undefined, + setTotalItems: () => undefined, + selectedSourceLabel: undefined, + setSelectedSourceLabel: () => undefined, + clearAllFilters: () => undefined, + sourceLabels: [], + catalogSourcesLoaded: false, + catalogSourcesLoadError: undefined, + mcpServers: { items: [] }, + mcpServersLoaded: false, + mcpServersLoadError: undefined, + filterOptions: null, + filterOptionsLoaded: false, + filterOptionsLoadError: undefined, +}); + +export const McpCatalogContextProvider: React.FC = ({ + children, +}) => { + const hostPath = `${URL_PREFIX}/api/${BFF_API_VERSION}/model_catalog`; + const queryParams = useQueryParamNamespaces(); + const [apiState] = useModelCatalogAPIState(hostPath, queryParams); + + const mcpListParams = React.useMemo(() => ({ assetType: 'mcp_servers' as const }), []); + const [catalogSources, catalogSourcesLoaded, catalogSourcesLoadError] = useCatalogSources( + apiState, + mcpListParams, + ); + const [filterOptions, filterOptionsLoaded, filterOptionsLoadError] = + useMcpServerFilterOptionListWithAPI(apiState); + + const [filters, setFilters] = React.useState({}); + const [searchQuery, setSearchQuery] = React.useState(''); + const [namedQuery, setNamedQuery] = React.useState(null); + const [pagination, setPaginationState] = + React.useState(defaultPagination); + const [selectedSourceLabel, setSelectedSourceLabel] = React.useState( + undefined, + ); + + const sourceLabelsFromApi = React.useMemo(() => { + const enabled = filterEnabledCatalogSources(catalogSources); + return getUniqueSourceLabels(enabled); + }, [catalogSources]); + + const mockSourceLabels = React.useMemo( + () => + Array.from( + new Set(mockMcpServers.map((s) => s.source_id).filter((id): id is string => Boolean(id))), + ), + [], + ); + + const mcpServersResult = useMcpServersBySourceLabelWithAPI(apiState, { + sourceLabel: selectedSourceLabel, + pageSize: pagination.pageSize, + searchQuery, + }); + + const apiReady = + catalogSourcesLoaded && + !catalogSourcesLoadError && + mcpServersResult.mcpServersLoaded && + !mcpServersResult.mcpServersLoadError; + const useMockData = !apiReady; + const sourceLabels = useMockData ? mockSourceLabels : sourceLabelsFromApi; + + const mcpServers = React.useMemo(() => { + if (useMockData) { + let items = mockMcpServers.filter( + (s) => + selectedSourceLabel === undefined || (s.source_id && s.source_id === selectedSourceLabel), + ); + if (searchQuery.trim().length > 0) { + items = filterMcpServersBySearchQuery(items, searchQuery); + } + return { items: filterMcpServersByFilters(items, filters) }; + } + return { items: filterMcpServersByFilters(mcpServersResult.mcpServers.items, filters) }; + }, [useMockData, selectedSourceLabel, searchQuery, filters, mcpServersResult.mcpServers.items]); + + const mcpServersLoaded = useMockData ? true : mcpServersResult.mcpServersLoaded; + const mcpServersLoadError = useMockData ? undefined : mcpServersResult.mcpServersLoadError; + + const setPage = React.useCallback((page: number) => { + setPaginationState((prev) => ({ ...prev, page })); + }, []); + + const setPageSize = React.useCallback((pageSize: number) => { + setPaginationState((prev) => ({ ...prev, pageSize, page: 1 })); + }, []); + + const setTotalItems = React.useCallback((totalItems: number) => { + setPaginationState((prev) => ({ ...prev, totalItems })); + }, []); + + const clearAllFilters = React.useCallback(() => { + setSearchQuery(''); + setFilters({}); + setSelectedSourceLabel(undefined); + setNamedQuery(null); + }, []); + + const value = React.useMemo( + () => ({ + filters, + setFilters, + searchQuery, + setSearchQuery, + namedQuery, + setNamedQuery, + pagination, + setPage, + setPageSize, + setTotalItems, + selectedSourceLabel, + setSelectedSourceLabel, + clearAllFilters, + sourceLabels, + catalogSourcesLoaded, + catalogSourcesLoadError, + mcpServers, + mcpServersLoaded, + mcpServersLoadError, + filterOptions, + filterOptionsLoaded, + filterOptionsLoadError, + }), + [ + filters, + searchQuery, + namedQuery, + pagination, + selectedSourceLabel, + sourceLabels, + catalogSourcesLoaded, + catalogSourcesLoadError, + mcpServers, + mcpServersLoaded, + mcpServersLoadError, + filterOptions, + filterOptionsLoaded, + filterOptionsLoadError, + setPage, + setPageSize, + setTotalItems, + clearAllFilters, + ], + ); + + return {children}; +}; diff --git a/clients/ui/frontend/src/app/context/mcpCatalog/__tests__/McpCatalogContext.spec.tsx b/clients/ui/frontend/src/app/context/mcpCatalog/__tests__/McpCatalogContext.spec.tsx new file mode 100644 index 0000000000..10977ea7c9 --- /dev/null +++ b/clients/ui/frontend/src/app/context/mcpCatalog/__tests__/McpCatalogContext.spec.tsx @@ -0,0 +1,156 @@ +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { + McpCatalogContextProvider, + McpCatalogContext, +} from '~/app/context/mcpCatalog/McpCatalogContext'; + +jest.mock('mod-arch-core', () => ({ + useQueryParamNamespaces: jest.fn(() => ({})), + asEnumMember: jest.fn((val: unknown) => val), + DeploymentMode: {}, +})); + +jest.mock('~/app/utilities/const', () => ({ + BFF_API_VERSION: 'v1', + URL_PREFIX: '/model-registry', +})); + +jest.mock('~/app/hooks/modelCatalog/useModelCatalogAPIState', () => ({ + __esModule: true, + default: jest.fn(() => [ + { + apiAvailable: false, + api: { + getMcpServerList: jest.fn(), + getMcpServerFilterOptionList: jest.fn(), + }, + }, + ]), +})); + +jest.mock('~/app/hooks/modelCatalog/useCatalogSources', () => ({ + useCatalogSources: jest.fn(() => [ + { items: [], size: 0, pageSize: 0, nextPageToken: '' }, + true, + undefined, + ]), +})); + +jest.mock('~/app/hooks/mcpServerCatalog/useMcpServersBySourceLabel', () => ({ + useMcpServersBySourceLabelWithAPI: jest.fn(() => ({ + mcpServers: { items: [] }, + mcpServersLoaded: true, + mcpServersLoadError: undefined, + })), +})); + +jest.mock('~/app/hooks/mcpServerCatalog/useMcpServerFilterOptionList', () => ({ + useMcpServerFilterOptionListWithAPI: jest.fn(() => [null, true, undefined]), +})); + +describe('McpCatalogContext', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('provides default filter state', () => { + const { result } = renderHook(() => React.useContext(McpCatalogContext), { wrapper }); + expect(result.current.filters).toEqual({}); + expect(result.current.searchQuery).toBe(''); + expect(result.current.namedQuery).toBeNull(); + expect(result.current.selectedSourceLabel).toBeUndefined(); + expect(result.current.pagination).toEqual({ + page: 1, + pageSize: 10, + totalItems: 0, + }); + }); + + it('updates searchQuery via setSearchQuery', () => { + const { result } = renderHook(() => React.useContext(McpCatalogContext), { wrapper }); + act(() => { + result.current.setSearchQuery('test'); + }); + expect(result.current.searchQuery).toBe('test'); + }); + + it('updates namedQuery via setNamedQuery', () => { + const { result } = renderHook(() => React.useContext(McpCatalogContext), { wrapper }); + act(() => { + result.current.setNamedQuery('named'); + }); + expect(result.current.namedQuery).toBe('named'); + act(() => { + result.current.setNamedQuery(null); + }); + expect(result.current.namedQuery).toBeNull(); + }); + + it('updates filters via setFilters', () => { + const { result } = renderHook(() => React.useContext(McpCatalogContext), { wrapper }); + act(() => { + result.current.setFilters({ deploymentMode: ['Local'] }); + }); + expect(result.current.filters).toEqual({ deploymentMode: ['Local'] }); + act(() => { + result.current.setFilters((prev) => ({ ...prev, license: ['MIT'] })); + }); + expect(result.current.filters).toEqual({ deploymentMode: ['Local'], license: ['MIT'] }); + }); + + it('updates pagination via setPage, setPageSize, setTotalItems', () => { + const { result } = renderHook(() => React.useContext(McpCatalogContext), { wrapper }); + act(() => { + result.current.setPage(2); + }); + expect(result.current.pagination.page).toBe(2); + act(() => { + result.current.setPageSize(20); + }); + expect(result.current.pagination.pageSize).toBe(20); + expect(result.current.pagination.page).toBe(1); + act(() => { + result.current.setTotalItems(50); + }); + expect(result.current.pagination.totalItems).toBe(50); + }); + + it('updates selectedSourceLabel via setSelectedSourceLabel', () => { + const { result } = renderHook(() => React.useContext(McpCatalogContext), { wrapper }); + act(() => { + result.current.setSelectedSourceLabel('sample'); + }); + expect(result.current.selectedSourceLabel).toBe('sample'); + act(() => { + result.current.setSelectedSourceLabel('other'); + }); + expect(result.current.selectedSourceLabel).toBe('other'); + act(() => { + result.current.setSelectedSourceLabel(undefined); + }); + expect(result.current.selectedSourceLabel).toBeUndefined(); + }); + + it('clearAllFilters resets searchQuery, filters, selectedSourceLabel and namedQuery', () => { + const { result } = renderHook(() => React.useContext(McpCatalogContext), { wrapper }); + act(() => { + result.current.setSearchQuery('q'); + result.current.setFilters({ deploymentMode: ['Local'] }); + result.current.setSelectedSourceLabel('sample'); + result.current.setNamedQuery('named'); + }); + expect(result.current.searchQuery).toBe('q'); + expect(result.current.filters).toEqual({ deploymentMode: ['Local'] }); + expect(result.current.selectedSourceLabel).toBe('sample'); + expect(result.current.namedQuery).toBe('named'); + act(() => { + result.current.clearAllFilters(); + }); + expect(result.current.searchQuery).toBe(''); + expect(result.current.filters).toEqual({}); + expect(result.current.selectedSourceLabel).toBeUndefined(); + expect(result.current.namedQuery).toBeNull(); + }); +}); diff --git a/clients/ui/frontend/src/app/context/modelCatalog/ModelCatalogContext.tsx b/clients/ui/frontend/src/app/context/modelCatalog/ModelCatalogContext.tsx index f902b0a397..e728f54e83 100644 --- a/clients/ui/frontend/src/app/context/modelCatalog/ModelCatalogContext.tsx +++ b/clients/ui/frontend/src/app/context/modelCatalog/ModelCatalogContext.tsx @@ -186,7 +186,11 @@ export const ModelCatalogContextProvider: React.FC => { - const { api, apiAvailable } = useModelCatalogAPI(); +export const useMcpServerFilterOptionListWithAPI = ( + apiState: ModelCatalogAPIState, +): FetchState => { + const { api, apiAvailable } = apiState; const call = React.useCallback>( (opts) => { if (!apiAvailable) { @@ -19,3 +22,8 @@ export const useMcpServerFilterOptionList = (): FetchState => { ); return useFetchState(call, null, { initialPromisePurity: true }); }; + +export const useMcpServerFilterOptionList = (): FetchState => { + const { api, apiAvailable } = useModelCatalogAPI(); + return useMcpServerFilterOptionListWithAPI({ api, apiAvailable }); +}; diff --git a/clients/ui/frontend/src/app/hooks/mcpServerCatalog/useMcpServersBySourceLabel.ts b/clients/ui/frontend/src/app/hooks/mcpServerCatalog/useMcpServersBySourceLabel.ts index 924843c9df..47f0f4130e 100644 --- a/clients/ui/frontend/src/app/hooks/mcpServerCatalog/useMcpServersBySourceLabel.ts +++ b/clients/ui/frontend/src/app/hooks/mcpServerCatalog/useMcpServersBySourceLabel.ts @@ -2,6 +2,7 @@ import React from 'react'; import { FetchStateCallbackPromise, useFetchState } from 'mod-arch-core'; import { McpServer, McpServerList, McpServerListParams } from '~/app/mcpServerCatalogTypes'; import { useModelCatalogAPI } from '~/app/hooks/modelCatalog/useModelCatalogAPI'; +import type { ModelCatalogAPIState } from '~/app/hooks/modelCatalog/useModelCatalogAPIState'; type PaginatedMcpServerList = { items: McpServer[]; @@ -15,25 +16,41 @@ type PaginatedMcpServerList = { loadMoreError?: Error; }; -type McpServers = { +export type McpServersResult = { mcpServers: PaginatedMcpServerList; mcpServersLoaded: boolean; mcpServersLoadError: Error | undefined; refresh: () => void; }; -export const useMcpServersBySourceLabel = ( - sourceLabel?: string, - pageSize = 10, - searchQuery = '', - filterQuery?: string, - namedQuery?: string, - includeTools?: boolean, - toolLimit?: number, - sortBy?: string | null, - sortOrder?: string, -): McpServers => { - const { api, apiAvailable } = useModelCatalogAPI(); +type UseMcpServersBySourceLabelParams = { + sourceLabel?: string; + pageSize?: number; + searchQuery?: string; + filterQuery?: string; + namedQuery?: string; + includeTools?: boolean; + toolLimit?: number; + sortBy?: string | null; + sortOrder?: string; +}; + +export function useMcpServersBySourceLabelWithAPI( + apiState: ModelCatalogAPIState, + params: UseMcpServersBySourceLabelParams, +): McpServersResult { + const { + sourceLabel, + pageSize = 10, + searchQuery = '', + filterQuery, + namedQuery, + includeTools, + toolLimit, + sortBy, + sortOrder, + } = params; + const { api, apiAvailable } = apiState; const [allItems, setAllItems] = React.useState([]); const [totalSize, setTotalSize] = React.useState(0); @@ -41,8 +58,6 @@ export const useMcpServersBySourceLabel = ( const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [loadMoreError, setLoadMoreError] = React.useState(); - const resetJustRanRef = React.useRef(false); - const buildMcpServerListParams = React.useCallback( (nextPageToken?: string): McpServerListParams => ({ sourceLabel, @@ -87,11 +102,7 @@ export const useMcpServersBySourceLabel = ( ); React.useEffect(() => { - if (loaded && !error) { - if (resetJustRanRef.current) { - resetJustRanRef.current = false; - return; - } + if (loaded && !error && (firstPageData.items?.length ?? 0) > 0) { setAllItems(firstPageData.items ?? []); setTotalSize(firstPageData.size); setNextPageTokenValue(firstPageData.nextPageToken); @@ -130,7 +141,6 @@ export const useMcpServersBySourceLabel = ( setNextPageTokenValue(''); setIsLoadingMore(false); setLoadMoreError(undefined); - resetJustRanRef.current = true; }, [ sourceLabel, pageSize, @@ -170,4 +180,32 @@ export const useMcpServersBySourceLabel = ( mcpServersLoadError: error, refresh, }; +} + +export const useMcpServersBySourceLabel = ( + sourceLabel?: string, + pageSize = 10, + searchQuery = '', + filterQuery?: string, + namedQuery?: string, + includeTools?: boolean, + toolLimit?: number, + sortBy?: string | null, + sortOrder?: string, +): McpServersResult => { + const { api, apiAvailable } = useModelCatalogAPI(); + return useMcpServersBySourceLabelWithAPI( + { api, apiAvailable }, + { + sourceLabel, + pageSize, + searchQuery, + filterQuery, + namedQuery, + includeTools, + toolLimit, + sortBy, + sortOrder, + }, + ); }; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/McpCatalogCoreLoader.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/McpCatalogCoreLoader.tsx new file mode 100644 index 0000000000..89054ddcfc --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/McpCatalogCoreLoader.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Alert, Bullseye } from '@patternfly/react-core'; +import { ApplicationsPage } from 'mod-arch-shared'; +import { Outlet } from 'react-router-dom'; +import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext'; + +const MCP_CATALOG_TITLE = 'MCP Catalog'; +const MCP_CATALOG_DESCRIPTION = + 'Discover and manage MCP servers and tools available for your organization.'; + +const McpCatalogCoreLoader: React.FC = () => { + const { catalogSourcesLoaded, catalogSourcesLoadError } = React.useContext(McpCatalogContext); + + if (catalogSourcesLoadError) { + return ( + + + {catalogSourcesLoadError.message} + + + } + loaded + /> + ); + } + + if (!catalogSourcesLoaded) { + return ( + Loading catalog sources...} + loaded={false} + /> + ); + } + + return ; +}; + +export default McpCatalogCoreLoader; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/McpCatalogRoutes.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/McpCatalogRoutes.tsx new file mode 100644 index 0000000000..fc07fc9f9d --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/McpCatalogRoutes.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { McpCatalogContextProvider } from '~/app/context/mcpCatalog/McpCatalogContext'; +import McpCatalogCoreLoader from './McpCatalogCoreLoader'; +import McpCatalog from './screens/McpCatalog'; +import McpServerDetailsPage from './screens/McpServerDetailsPage'; + +const McpCatalogRoutes: React.FC = () => ( + + + }> + } /> + } /> + } /> + + + +); + +export default McpCatalogRoutes; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogActiveFilters.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogActiveFilters.tsx new file mode 100644 index 0000000000..c525b9bd49 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogActiveFilters.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { ToolbarFilter, ToolbarLabel, ToolbarLabelGroup } from '@patternfly/react-core'; +import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext'; +import type { McpFilterCategoryKey } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; +import { + MCP_FILTER_KEYS, + MCP_FILTER_CATEGORY_NAMES, +} from '~/app/pages/mcpCatalog/constants/mcpCatalogFilterChipNames'; + +const SEARCH_CHIP_CATEGORY = 'Search'; + +const McpCatalogActiveFilters: React.FC = () => { + const { filters, setFilters, searchQuery, setSearchQuery } = React.useContext(McpCatalogContext); + + const hasSearchChip = searchQuery.trim().length > 0; + + const handleClearSearch = React.useCallback(() => { + setSearchQuery(''); + }, [setSearchQuery]); + + const handleRemoveFilter = React.useCallback( + (categoryKey: McpFilterCategoryKey, valueKey: string) => { + setFilters((prev) => { + const current = prev[categoryKey]; + const arr = Array.isArray(current) ? current : []; + const newValues = arr.filter((v) => v !== valueKey); + return { ...prev, [categoryKey]: newValues }; + }); + }, + [setFilters], + ); + + const handleClearCategory = React.useCallback( + (categoryKey: McpFilterCategoryKey) => { + setFilters((prev) => ({ ...prev, [categoryKey]: [] })); + }, + [setFilters], + ); + + return ( + <> + {hasSearchChip && ( + {searchQuery.trim()}, + }, + ]} + deleteLabel={handleClearSearch} + deleteLabelGroup={handleClearSearch} + data-testid="mcp-filter-container-search" + > + {null} + + )} + {MCP_FILTER_KEYS.map((filterKey) => { + const filterValue = filters[filterKey]; + const values = Array.isArray(filterValue) ? filterValue : []; + const hasValue = values.length > 0; + + const labels: ToolbarLabel[] = hasValue + ? values.map((value) => ({ + key: value, + node: {value}, + })) + : []; + + const categoryLabelGroup: ToolbarLabelGroup = { + key: filterKey, + name: MCP_FILTER_CATEGORY_NAMES[filterKey], + }; + + return ( + { + const labelKey = typeof label === 'string' ? label : label.key; + handleRemoveFilter(filterKey, labelKey); + }} + deleteLabelGroup={() => handleClearCategory(filterKey)} + data-testid={`mcp-filter-container-${filterKey}`} + > + {null} + + ); + })} + + ); +}; + +export default McpCatalogActiveFilters; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogCard.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogCard.tsx new file mode 100644 index 0000000000..ef89461502 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogCard.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import { + Button, + Card, + CardBody, + CardHeader, + CardTitle, + Content, + Flex, + FlexItem, + Label, + Truncate, +} from '@patternfly/react-core'; +import { ApplicationsIcon } from '@patternfly/react-icons'; +import { Link, type LinkProps } from 'react-router-dom'; +import type { McpServer } from '~/app/mcpServerCatalogTypes'; +import { getSecurityIndicatorLabels } from '~/app/pages/mcpCatalog/utils/mcpCatalogUtils'; +import { + McpCardIconType, + McpCardIconByLabel, + getMcpCardIconConfig, +} from '~/app/pages/mcpCatalog/constants/mcpCatalogCardIcons'; +import { mcpServerDetailsUrl } from '~/app/routes/mcpCatalog/mcpCatalog'; + +type McpCatalogCardProps = { + server: McpServer; +}; + +const SecurityTag: React.FC<{ label: string }> = ({ label }) => ( + + + + {label} + + +); + +const McpCatalogCard: React.FC = ({ server }) => { + const deploymentType = + server.deploymentMode === 'local' ? McpCardIconType.LOCAL_TO_CLUSTER : McpCardIconType.REMOTE; + const deploymentConfig = getMcpCardIconConfig(deploymentType); + const securityLabels = getSecurityIndicatorLabels(server.securityIndicators); + const serverId = String(server.id); + + return ( + + + + + + + + + + + + + + + + + + + {server.description ?? ''} + + {securityLabels.length > 0 && ( + + {securityLabels.map((tag) => ( + + ))} + + )} + + + ); +}; + +export default McpCatalogCard; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogFilters.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogFilters.tsx new file mode 100644 index 0000000000..ed858e0b2f --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogFilters.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { Alert, Spinner, Stack } from '@patternfly/react-core'; +import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext'; +import DeploymentModeFilter from '~/app/pages/mcpCatalog/components/globalFilters/DeploymentModeFilter'; +import SupportedTransportsFilter from '~/app/pages/mcpCatalog/components/globalFilters/SupportedTransportsFilter'; +import McpLicenseFilter from '~/app/pages/mcpCatalog/components/globalFilters/McpLicenseFilter'; +import LabelsFilter from '~/app/pages/mcpCatalog/components/globalFilters/LabelsFilter'; +import SecurityVerificationFilter from '~/app/pages/mcpCatalog/components/globalFilters/SecurityVerificationFilter'; + +const McpCatalogFilters: React.FC = () => { + const { filterOptions, filterOptionsLoaded, filterOptionsLoadError } = + React.useContext(McpCatalogContext); + + if (!filterOptionsLoaded) { + return ; + } + + if (filterOptionsLoadError) { + return ( + + {filterOptionsLoadError.message} + + ); + } + + const filters = filterOptions?.filters; + + return ( + + + + + + + + ); +}; + +export default McpCatalogFilters; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogStringFilter.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogStringFilter.tsx new file mode 100644 index 0000000000..9f3a34c4ec --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/McpCatalogStringFilter.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import CatalogStringFilter from '~/app/shared/components/catalog/CatalogStringFilter'; +import { useMcpFilterState } from '~/app/pages/mcpCatalog/hooks/useMcpFilterState'; +import type { + McpFilterCategoryKey, + McpCatalogFilterStringOption, +} from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +type McpCatalogStringFilterProps = { + title: string; + filterKey: McpFilterCategoryKey; + filters: McpCatalogFilterStringOption | undefined; +}; + +const McpCatalogStringFilter: React.FC = ({ + title, + filterKey, + filters, +}) => { + const { selectedValues, setSelected } = useMcpFilterState(filterKey); + const filterValues = React.useMemo(() => filters?.values ?? [], [filters?.values]); + + return ( + + ); +}; + +export default McpCatalogStringFilter; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/__tests__/McpCatalogCard.spec.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/__tests__/McpCatalogCard.spec.tsx new file mode 100644 index 0000000000..f562d514a3 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/__tests__/McpCatalogCard.spec.tsx @@ -0,0 +1,61 @@ +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import McpCatalogCard from '~/app/pages/mcpCatalog/components/McpCatalogCard'; +import type { McpServer } from '~/app/mcpServerCatalogTypes'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const mockServer: McpServer = { + id: 1, + name: 'Test MCP Server', + description: 'Test description for the server.', + deploymentMode: 'local', + securityIndicators: { verifiedSource: true, sast: true }, + toolCount: 0, +}; + +describe('McpCatalogCard', () => { + it('renders server name and description', () => { + render(, { wrapper }); + expect(screen.getByTestId('mcp-catalog-card-name-1')).toHaveTextContent('Test MCP Server'); + expect(screen.getByTestId('mcp-catalog-card-description-1')).toHaveTextContent( + 'Test description for the server.', + ); + }); + + it('renders deployment mode label for local', () => { + render(, { wrapper }); + expect(screen.getByTestId('mcp-catalog-card-deployment-1')).toHaveTextContent( + 'Local to cluster', + ); + }); + + it('renders deployment mode label for remote', () => { + render(, { + wrapper, + }); + expect(screen.getByTestId('mcp-catalog-card-deployment-2')).toHaveTextContent('Remote'); + }); + + it('renders security verification tags', () => { + render(, { wrapper }); + expect(screen.getByText('Verified source')).toBeInTheDocument(); + expect(screen.getByText('SAST')).toBeInTheDocument(); + }); + + it('does not render security section when securityIndicators is empty', () => { + render(, { + wrapper, + }); + expect(screen.queryByText('Verified source')).not.toBeInTheDocument(); + }); + + it('renders card with data-testid for the server id', () => { + render(, { wrapper }); + expect(screen.getByTestId('mcp-catalog-card-1')).toBeInTheDocument(); + }); +}); diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/__tests__/McpCatalogFilters.spec.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/__tests__/McpCatalogFilters.spec.tsx new file mode 100644 index 0000000000..af547cb1b1 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/__tests__/McpCatalogFilters.spec.tsx @@ -0,0 +1,50 @@ +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import McpCatalogFilters from '~/app/pages/mcpCatalog/components/McpCatalogFilters'; +import { McpCatalogContextProvider } from '~/app/context/mcpCatalog/McpCatalogContext'; +import { mockMcpCatalogFilterOptions } from '~/app/pages/mcpCatalog/mocks/mockMcpCatalogFilterOptions'; + +jest.mock('mod-arch-core', () => ({ useQueryParamNamespaces: () => ({}) })); +jest.mock('~/app/utilities/const', () => ({ + BFF_API_VERSION: 'v1', + URL_PREFIX: '/model-registry', +})); +jest.mock('~/app/hooks/modelCatalog/useModelCatalogAPIState', () => ({ + __esModule: true, + default: () => [{ apiAvailable: false, api: {} }, jest.fn()], +})); +jest.mock('~/app/hooks/modelCatalog/useCatalogSources', () => ({ + useCatalogSources: () => [{ items: [] }, true, undefined], +})); +jest.mock('~/app/hooks/mcpServerCatalog/useMcpServersBySourceLabel', () => ({ + useMcpServersBySourceLabelWithAPI: () => ({ + mcpServers: { items: [] }, + mcpServersLoaded: true, + mcpServersLoadError: undefined, + }), +})); +jest.mock('~/app/hooks/mcpServerCatalog/useMcpServerFilterOptionList', () => ({ + useMcpServerFilterOptionListWithAPI: () => [mockMcpCatalogFilterOptions, true, undefined], +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('McpCatalogFilters', () => { + it('renders all filter sections from mock options', () => { + render(, { wrapper }); + expect(screen.getByTestId('mcp-filter-deploymentMode')).toBeInTheDocument(); + expect(screen.getByTestId('mcp-filter-supportedTransports')).toBeInTheDocument(); + expect(screen.getByTestId('mcp-filter-license')).toBeInTheDocument(); + expect(screen.getByTestId('mcp-filter-labels')).toBeInTheDocument(); + expect(screen.getByTestId('mcp-filter-securityVerification')).toBeInTheDocument(); + }); + + it('renders Deployment mode filter with Local and Remote options', () => { + render(, { wrapper }); + expect(screen.getByLabelText('Local')).toBeInTheDocument(); + expect(screen.getByLabelText('Remote')).toBeInTheDocument(); + }); +}); diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/__tests__/McpCatalogStringFilter.spec.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/__tests__/McpCatalogStringFilter.spec.tsx new file mode 100644 index 0000000000..e967435be9 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/__tests__/McpCatalogStringFilter.spec.tsx @@ -0,0 +1,111 @@ +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import McpCatalogStringFilter from '~/app/pages/mcpCatalog/components/McpCatalogStringFilter'; +import { McpCatalogContextProvider } from '~/app/context/mcpCatalog/McpCatalogContext'; + +jest.mock('mod-arch-core', () => ({ useQueryParamNamespaces: () => ({}) })); +jest.mock('~/app/utilities/const', () => ({ + BFF_API_VERSION: 'v1', + URL_PREFIX: '/model-registry', +})); +jest.mock('~/app/hooks/modelCatalog/useModelCatalogAPIState', () => ({ + __esModule: true, + default: () => [{ apiAvailable: false, api: {} }, jest.fn()], +})); +jest.mock('~/app/hooks/modelCatalog/useCatalogSources', () => ({ + useCatalogSources: () => [{ items: [] }, true, undefined], +})); +jest.mock('~/app/hooks/mcpServerCatalog/useMcpServersBySourceLabel', () => ({ + useMcpServersBySourceLabelWithAPI: () => ({ + mcpServers: { items: [] }, + mcpServersLoaded: true, + mcpServersLoadError: undefined, + }), +})); +jest.mock('~/app/hooks/mcpServerCatalog/useMcpServerFilterOptionList', () => ({ + useMcpServerFilterOptionListWithAPI: () => [null, true, undefined], +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('McpCatalogStringFilter', () => { + it('renders title and filter options', () => { + render( + , + { wrapper }, + ); + expect(screen.getByText('Deployment mode')).toBeInTheDocument(); + expect(screen.getByLabelText('Local')).toBeInTheDocument(); + expect(screen.getByLabelText('Remote')).toBeInTheDocument(); + }); + + it('shows empty state when no values match search', () => { + render( + , + { wrapper }, + ); + const searchWrapper = screen.getByTestId('mcp-filter-license-search'); + const input = searchWrapper.querySelector('input'); + expect(input).toBeTruthy(); + if (input) { + fireEvent.change(input, { target: { value: 'nonexistent' } }); + } + expect(screen.getByTestId('mcp-filter-license-empty')).toHaveTextContent('No results found'); + }); + + it('toggles checkbox and updates context', () => { + render( + , + { wrapper }, + ); + const localCheckbox = screen.getByTestId('mcp-filter-deploymentMode-Local'); + expect(localCheckbox).not.toBeChecked(); + fireEvent.click(localCheckbox); + expect(localCheckbox).toBeChecked(); + }); + + it('shows Show more when values exceed max visible', () => { + const values = ['a', 'b', 'c', 'd', 'e', 'f']; + render( + , + { wrapper }, + ); + expect(screen.getByTestId('mcp-filter-labels-show-more')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('mcp-filter-labels-show-more')); + expect(screen.getByTestId('mcp-filter-labels-show-less')).toBeInTheDocument(); + }); + + it('renders with data-testid for filter key', () => { + render( + , + { wrapper }, + ); + expect(screen.getByTestId('mcp-filter-deploymentMode')).toBeInTheDocument(); + }); +}); diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/DeploymentModeFilter.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/DeploymentModeFilter.tsx new file mode 100644 index 0000000000..5dea77a014 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/DeploymentModeFilter.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Divider, StackItem } from '@patternfly/react-core'; +import McpCatalogStringFilter from '~/app/pages/mcpCatalog/components/McpCatalogStringFilter'; +import type { McpCatalogFilterOptions } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +const filterKey = 'deploymentMode'; + +type DeploymentModeFilterProps = { + filters?: McpCatalogFilterOptions; +}; + +const DeploymentModeFilter: React.FC = ({ filters }) => { + const value = filters?.[filterKey]; + if (!value) { + return null; + } + return ( + <> + + + + + + ); +}; + +export default DeploymentModeFilter; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/LabelsFilter.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/LabelsFilter.tsx new file mode 100644 index 0000000000..78906f419e --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/LabelsFilter.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Divider, StackItem } from '@patternfly/react-core'; +import McpCatalogStringFilter from '~/app/pages/mcpCatalog/components/McpCatalogStringFilter'; +import type { McpCatalogFilterOptions } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +const filterKey = 'labels'; + +type LabelsFilterProps = { + filters?: McpCatalogFilterOptions; +}; + +const LabelsFilter: React.FC = ({ filters }) => { + const value = filters?.[filterKey]; + if (!value) { + return null; + } + return ( + <> + + + + + + ); +}; + +export default LabelsFilter; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/McpLicenseFilter.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/McpLicenseFilter.tsx new file mode 100644 index 0000000000..b0cbdb08ca --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/McpLicenseFilter.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Divider, StackItem } from '@patternfly/react-core'; +import McpCatalogStringFilter from '~/app/pages/mcpCatalog/components/McpCatalogStringFilter'; +import type { McpCatalogFilterOptions } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +const filterKey = 'license'; + +type McpLicenseFilterProps = { + filters?: McpCatalogFilterOptions; +}; + +const McpLicenseFilter: React.FC = ({ filters }) => { + const value = filters?.[filterKey]; + if (!value) { + return null; + } + return ( + <> + + + + + + ); +}; + +export default McpLicenseFilter; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/SecurityVerificationFilter.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/SecurityVerificationFilter.tsx new file mode 100644 index 0000000000..b810118618 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/SecurityVerificationFilter.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { StackItem } from '@patternfly/react-core'; +import McpCatalogStringFilter from '~/app/pages/mcpCatalog/components/McpCatalogStringFilter'; +import type { McpCatalogFilterOptions } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +const filterKey = 'securityVerification'; + +type SecurityVerificationFilterProps = { + filters?: McpCatalogFilterOptions; +}; + +const SecurityVerificationFilter: React.FC = ({ filters }) => { + const value = filters?.[filterKey]; + if (!value) { + return null; + } + return ( + + + + ); +}; + +export default SecurityVerificationFilter; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/SupportedTransportsFilter.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/SupportedTransportsFilter.tsx new file mode 100644 index 0000000000..9a2daffc53 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/components/globalFilters/SupportedTransportsFilter.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { Divider, StackItem } from '@patternfly/react-core'; +import McpCatalogStringFilter from '~/app/pages/mcpCatalog/components/McpCatalogStringFilter'; +import type { McpCatalogFilterOptions } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +const filterKey = 'supportedTransports'; + +type SupportedTransportsFilterProps = { + filters?: McpCatalogFilterOptions; +}; + +const SupportedTransportsFilter: React.FC = ({ filters }) => { + const value = filters?.[filterKey]; + if (!value) { + return null; + } + return ( + <> + + + + + + ); +}; + +export default SupportedTransportsFilter; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/constants/mcpCatalogCardIcons.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/constants/mcpCatalogCardIcons.tsx new file mode 100644 index 0000000000..33ebdb74f0 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/constants/mcpCatalogCardIcons.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { CheckCircleIcon, ClusterIcon, SecurityIcon, WrenchIcon } from '@patternfly/react-icons'; + +export enum McpCardIconType { + VERIFIED_SOURCE = 'Verified source', + SECURE_ENDPOINT = 'Secure endpoint', + SAST = 'SAST', + LOCAL_TO_CLUSTER = 'Local to cluster', + READ_ONLY_TOOLS = 'Read only tools', + REMOTE = 'Remote', +} + +const GREEN_ICON_STYLE = { color: 'rgb(62, 134, 53)' }; + +const iconMap: Record< + McpCardIconType, + { + Icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + label: string; + green?: boolean; + } +> = { + [McpCardIconType.VERIFIED_SOURCE]: { + Icon: SecurityIcon, + label: 'Verified source', + green: true, + }, + [McpCardIconType.SECURE_ENDPOINT]: { + Icon: SecurityIcon, + label: 'Secure endpoint', + green: true, + }, + [McpCardIconType.SAST]: { + Icon: CheckCircleIcon, + label: 'SAST', + green: true, + }, + [McpCardIconType.LOCAL_TO_CLUSTER]: { + Icon: ClusterIcon, + label: 'Local to cluster', + green: false, + }, + [McpCardIconType.READ_ONLY_TOOLS]: { + Icon: WrenchIcon, + label: 'Read only tools', + green: true, + }, + [McpCardIconType.REMOTE]: { + Icon: ClusterIcon, + label: 'Remote', + green: false, + }, +}; + +export const getMcpCardIconConfig = (type: McpCardIconType): (typeof iconMap)[McpCardIconType] => + iconMap[type]; + +export const getMcpCardIconConfigByLabel = ( + label: string, +): { + Icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + label: string; + green?: boolean; +} | null => { + const entry = Object.values(iconMap).find((c) => c.label === label); + return entry ?? null; +}; + +export const McpCardIcon: React.FC<{ + type: McpCardIconType; + className?: string; +}> = ({ type, className }) => { + const config = getMcpCardIconConfig(type); + const { Icon, green } = config; + return ; +}; + +export const McpCardIconByLabel: React.FC<{ + label: string; + className?: string; +}> = ({ label, className }) => { + const config = getMcpCardIconConfigByLabel(label); + if (!config) { + return null; + } + const { Icon, green } = config; + return ; +}; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/constants/mcpCatalogFilterChipNames.ts b/clients/ui/frontend/src/app/pages/mcpCatalog/constants/mcpCatalogFilterChipNames.ts new file mode 100644 index 0000000000..e817c60750 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/constants/mcpCatalogFilterChipNames.ts @@ -0,0 +1,17 @@ +import type { McpFilterCategoryKey } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +export const MCP_FILTER_CATEGORY_NAMES: Record = { + deploymentMode: 'Deployment mode', + supportedTransports: 'Supported transports', + license: 'License', + labels: 'Labels', + securityVerification: 'Security & Verification', +}; + +export const MCP_FILTER_KEYS: McpFilterCategoryKey[] = [ + 'deploymentMode', + 'supportedTransports', + 'license', + 'labels', + 'securityVerification', +]; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/hooks/useMcpFilterState.ts b/clients/ui/frontend/src/app/pages/mcpCatalog/hooks/useMcpFilterState.ts new file mode 100644 index 0000000000..a787ee0818 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/hooks/useMcpFilterState.ts @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext'; +import type { McpFilterCategoryKey } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +export function useMcpFilterState(filterKey: McpFilterCategoryKey): { + selectedValues: string[]; + setSelected: (value: string, checked: boolean) => void; + isSelected: (value: string) => boolean; +} { + const { filters, setFilters } = React.useContext(McpCatalogContext); + const selected = React.useMemo(() => { + const v = filters[filterKey]; + return Array.isArray(v) ? v : []; + }, [filters, filterKey]); + const setSelected = React.useCallback( + (value: string, checked: boolean) => { + setFilters((prev) => { + const current = prev[filterKey]; + const arr = Array.isArray(current) ? current : []; + if (checked) { + return { ...prev, [filterKey]: [...arr, value] }; + } + return { ...prev, [filterKey]: arr.filter((x) => x !== value) }; + }); + }, + [filterKey, setFilters], + ); + const isSelected = React.useCallback((value: string) => selected.includes(value), [selected]); + return { selectedValues: selected, setSelected, isSelected }; +} diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/mocks/mockMcpCatalogFilterOptions.ts b/clients/ui/frontend/src/app/pages/mcpCatalog/mocks/mockMcpCatalogFilterOptions.ts new file mode 100644 index 0000000000..13e4a339f5 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/mocks/mockMcpCatalogFilterOptions.ts @@ -0,0 +1,63 @@ +import type { McpCatalogFilterOptionsList } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +export const mockMcpCatalogFilterOptions: McpCatalogFilterOptionsList = { + filters: { + deploymentMode: { + type: 'string', + values: ['Remote', 'Local'], + }, + supportedTransports: { + type: 'string', + values: ['SSE', 'http-streaming'], + }, + license: { + type: 'string', + values: ['MIT', 'Apache-2.0'], + }, + labels: { + type: 'string', + values: [ + 'apm', + 'automation', + 'chatops', + 'cluster-management', + 'collaboration', + 'crm', + 'customer-support', + 'database', + 'development', + 'dynatrace', + 'git', + 'github', + 'healthcare', + 'incident-management', + 'infrastructure', + 'integration', + 'itsm', + 'kubectl', + 'kubernetes', + 'logs', + 'monitoring', + 'observability', + 'postgresql', + 'repositories', + 'saas', + 'salesforce', + 'security', + 'servicenow', + 'slack', + 'soql', + 'splunk', + 'sql', + 'tickets', + 'vulnerability', + 'workflows', + 'zapier', + ], + }, + securityVerification: { + type: 'string', + values: ['Verified source', 'Secure endpoint', 'SAST', 'Read only tools'], + }, + }, +}; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/mocks/mockMcpServers.ts b/clients/ui/frontend/src/app/pages/mcpCatalog/mocks/mockMcpServers.ts new file mode 100644 index 0000000000..5bbdf92b93 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/mocks/mockMcpServers.ts @@ -0,0 +1,79 @@ +/* eslint-disable camelcase */ +import type { McpServer } from '~/app/mcpServerCatalogTypes'; + +export const mockMcpServers: McpServer[] = [ + { + id: 1, + name: 'Kubernetes', + description: + 'Control and inspect Kubernetes clusters using natural language queries for health, resources, and deployments.', + deploymentMode: 'local', + securityIndicators: { verifiedSource: true, sast: true }, + source_id: 'sample', + toolCount: 5, + license: 'Apache-2.0', + licenseLink: 'https://opensource.org/licenses/Apache-2.0', + version: '1.2.0', + provider: 'kubernetes-sigs', + tags: ['kubernetes', 'infrastructure', 'containers', 'orchestration'], + transports: ['http'], + artifacts: [{ uri: 'quay.io/kubernetes-sigs/mcp-kubernetes:1.2.0' }], + sourceCode: 'kubernetes-sigs/mcp-kubernetes', + repositoryUrl: 'https://github.com/kubernetes-sigs/mcp-kubernetes', + readme: + '# Kubernetes MCP Server\n\nThe Kubernetes MCP Server allows AI Assistants to interact with Kubernetes clusters.\n\n## Quickstart\n\nInstall via `npx`:\n\n```bash\nnpx @kubernetes-sigs/mcp-kubernetes\n```\n\n## Use Cases\n\n- **Cluster inspection** - Query pod, deployment, and service status\n- **Resource management** - Create, update, and delete resources\n- **Health monitoring** - Check cluster health and resource utilization\n', + lastUpdated: '1709913600000', + }, + { + id: 2, + name: 'GitHub', + description: + 'Integrate with GitHub repositories, issues, and pull requests using natural language.', + deploymentMode: 'remote', + securityIndicators: { verifiedSource: true, secureEndpoint: true }, + source_id: 'sample', + toolCount: 8, + license: 'MIT', + licenseLink: 'https://opensource.org/licenses/MIT', + version: '3.0.1', + provider: 'github', + tags: ['github', 'vcs', 'devops'], + transports: ['http', 'sse'], + artifacts: [{ uri: 'quay.io/github/mcp-github:3.0.1' }], + sourceCode: 'github/mcp-server', + repositoryUrl: 'https://github.com/github/mcp-server', + }, + { + id: 3, + name: 'Slack', + description: 'Search and interact with Slack workspaces, channels, and messages.', + deploymentMode: 'remote', + securityIndicators: { verifiedSource: true }, + source_id: 'sample', + toolCount: 4, + version: '2.1.0', + provider: 'slack', + tags: ['slack', 'messaging'], + transports: ['sse'], + }, + { + id: 4, + name: 'PostgreSQL', + description: 'Query and manage PostgreSQL databases using natural language.', + deploymentMode: 'local', + securityIndicators: { readOnlyTools: true }, + source_id: 'other', + toolCount: 3, + version: '1.0.0', + provider: 'postgres-community', + transports: ['stdio'], + }, + { + id: 5, + name: 'Custom MCP Server', + description: 'A custom MCP server for extended integrations and workflows.', + deploymentMode: 'remote', + source_id: 'other', + toolCount: 0, + }, +]; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalog.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalog.tsx new file mode 100644 index 0000000000..7e1d5b6bc5 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalog.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { PageSection, Sidebar, SidebarContent, SidebarPanel, Stack } from '@patternfly/react-core'; +import { ApplicationsPage } from 'mod-arch-shared'; +import ScrollViewOnMount from '~/app/shared/components/ScrollViewOnMount'; +import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext'; +import McpCatalogFilters from '~/app/pages/mcpCatalog/components/McpCatalogFilters'; +import McpCatalogSourceLabelSelector from './McpCatalogSourceLabelSelector'; +import McpCatalogGalleryView from './McpCatalogGalleryView'; + +const MCP_CATALOG_TITLE = 'MCP Catalog'; +const MCP_CATALOG_SUBTITLE = + 'Discover and manage MCP servers and tools available for your organization.'; + +const McpCatalog: React.FC = () => { + const { searchQuery, setSearchQuery, clearAllFilters } = React.useContext(McpCatalogContext); + + const handleSearch = React.useCallback( + (term: string) => { + setSearchQuery(term); + }, + [setSearchQuery], + ); + + const handleClearSearch = React.useCallback(() => { + setSearchQuery(''); + }, [setSearchQuery]); + + const handleResetAllFilters = React.useCallback(() => { + clearAllFilters(); + }, [clearAllFilters]); + + return ( + <> + + + + + + + + + + + + + + + + + + ); +}; + +export default McpCatalog; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogCategorySection.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogCategorySection.tsx new file mode 100644 index 0000000000..f41f316953 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogCategorySection.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Content, Flex, FlexItem, Grid, GridItem, StackItem, Title } from '@patternfly/react-core'; +import type { McpServer } from '~/app/mcpServerCatalogTypes'; +import McpCatalogCard from '~/app/pages/mcpCatalog/components/McpCatalogCard'; + +type McpCatalogCategorySectionProps = { + title: string; + description?: string; + servers: McpServer[]; +}; + +const McpCatalogCategorySection: React.FC = ({ + title, + description, + servers, +}) => { + if (servers.length === 0) { + return null; + } + + return ( + + + + + {title} + + {description && ( + + {description} + + )} + + + + {servers.map((server) => ( + + + + ))} + + + ); +}; + +export default McpCatalogCategorySection; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogGalleryView.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogGalleryView.tsx new file mode 100644 index 0000000000..74e73242b7 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogGalleryView.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { EmptyState, EmptyStateBody, Stack } from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; +import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext'; +import McpCatalogCategorySection from '~/app/pages/mcpCatalog/screens/McpCatalogCategorySection'; + +function getCategoryDisplayName(sourceLabel: string): string { + if (!sourceLabel) { + return 'Other'; + } + return sourceLabel.charAt(0).toUpperCase() + sourceLabel.slice(1).toLowerCase(); +} + +type McpCatalogGalleryViewProps = { + searchTerm: string; +}; + +const McpCatalogGalleryView: React.FC = () => { + const { mcpServers, mcpServersLoaded, mcpServersLoadError, selectedSourceLabel, sourceLabels } = + React.useContext(McpCatalogContext); + const { items } = mcpServers; + + const { itemsByLabel, uncategorizedItems } = React.useMemo(() => { + const knownLabels = new Set(sourceLabels); + const byLabel = new Map(); + const uncategorized: typeof items = []; + + for (const item of items) { + if (!item.source_id || !knownLabels.has(item.source_id)) { + uncategorized.push(item); + } else { + const group = byLabel.get(item.source_id); + if (group) { + group.push(item); + } else { + byLabel.set(item.source_id, [item]); + } + } + } + + return { itemsByLabel: byLabel, uncategorizedItems: uncategorized }; + }, [items, sourceLabels]); + + if (mcpServersLoadError) { + return ( + + {mcpServersLoadError.message} + + ); + } + + if (mcpServersLoaded && items.length === 0) { + return ( + + Adjust your filters and try again. + + ); + } + + if (!mcpServersLoaded && items.length === 0) { + return null; + } + + if (selectedSourceLabel !== undefined) { + return ( + + + + ); + } + + if (sourceLabels.length === 0) { + return ( + + + + ); + } + + return ( + + {sourceLabels.map((label) => ( + + ))} + {uncategorizedItems.length > 0 && ( + + )} + + ); +}; + +export default McpCatalogGalleryView; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogSourceLabelBlocks.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogSourceLabelBlocks.tsx new file mode 100644 index 0000000000..02b2d9cb9b --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogSourceLabelBlocks.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core'; +import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext'; + +const ALL_SERVERS_LABEL = 'All Servers'; + +type SourceLabelBlock = { id: string; label?: string; displayName: string }; + +const McpCatalogSourceLabelBlocks: React.FC = () => { + const { sourceLabels, selectedSourceLabel, setSelectedSourceLabel } = + React.useContext(McpCatalogContext); + + const blocks = React.useMemo((): SourceLabelBlock[] => { + const allBlock: SourceLabelBlock = { id: 'all', displayName: ALL_SERVERS_LABEL }; + const labelBlocks: SourceLabelBlock[] = sourceLabels.map((label) => ({ + id: `label-${label}`, + label, + displayName: label, + })); + return [allBlock, ...labelBlocks]; + }, [sourceLabels]); + + const isSelected = (block: SourceLabelBlock) => + block.label === undefined + ? selectedSourceLabel === undefined + : selectedSourceLabel === block.label; + + return ( + + {blocks.map((block) => ( + setSelectedSourceLabel(block.label)} + /> + ))} + + ); +}; + +export default McpCatalogSourceLabelBlocks; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogSourceLabelSelector.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogSourceLabelSelector.tsx new file mode 100644 index 0000000000..aa98454367 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpCatalogSourceLabelSelector.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { + Button, + Flex, + Stack, + StackItem, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { ArrowRightIcon, FilterIcon } from '@patternfly/react-icons'; +import { useThemeContext } from 'mod-arch-kubeflow'; +import ThemeAwareSearchInput from '~/app/pages/modelRegistry/screens/components/ThemeAwareSearchInput'; +import { McpCatalogContext } from '~/app/context/mcpCatalog/McpCatalogContext'; +import { hasMcpFiltersApplied } from '~/app/pages/mcpCatalog/utils/mcpCatalogUtils'; +import McpCatalogActiveFilters from '~/app/pages/mcpCatalog/components/McpCatalogActiveFilters'; +import McpCatalogSourceLabelBlocks from './McpCatalogSourceLabelBlocks'; + +type McpCatalogSourceLabelSelectorProps = { + searchTerm: string; + onSearch: (term: string) => void; + onClearSearch: () => void; + onResetAllFilters: () => void; +}; + +const McpCatalogSourceLabelSelector: React.FC = ({ + searchTerm, + onSearch, + onClearSearch, + onResetAllFilters, +}) => { + const [inputValue, setInputValue] = React.useState(searchTerm || ''); + const { isMUITheme } = useThemeContext(); + const { filters } = React.useContext(McpCatalogContext); + + const hasFiltersAppliedValue = hasMcpFiltersApplied(filters, searchTerm); + + React.useEffect(() => { + setInputValue(searchTerm || ''); + }, [searchTerm]); + + const handleClearAllFilters = React.useCallback(() => { + if (hasFiltersAppliedValue) { + onResetAllFilters(); + } + }, [hasFiltersAppliedValue, onResetAllFilters]); + + const handleSearch = React.useCallback(() => { + if (inputValue.trim() !== searchTerm) { + onSearch(inputValue.trim()); + } + }, [inputValue, searchTerm, onSearch]); + + const handleClear = React.useCallback(() => { + onClearSearch(); + }, [onClearSearch]); + + const handleSearchInputChange = React.useCallback((value: string) => { + setInputValue(value); + }, []); + + const handleSearchInputSearch = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + onSearch(value.trim()); + }, + [onSearch], + ); + + const toolbarClearAllProps = hasFiltersAppliedValue + ? { + clearAllFilters: handleClearAllFilters, + clearFiltersButtonText: 'Reset all filters' as const, + } + : undefined; + + return ( + + + + + + }> + + + + + + {isMUITheme && ( + + + + ) : undefined + } + loadError={serverLoadError} + loaded={serverLoaded} + errorMessage="Unable to load MCP server details" + provideChildrenPadding + > + {server && } + + + ); +}; + +export default McpServerDetailsPage; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpServerDetailsView.tsx b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpServerDetailsView.tsx new file mode 100644 index 0000000000..332bc125b2 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/screens/McpServerDetailsView.tsx @@ -0,0 +1,235 @@ +import * as React from 'react'; +import { + Card, + CardBody, + CardHeader, + ClipboardCopy, + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Icon, + Label, + LabelGroup, + PageSection, + Sidebar, + SidebarContent, + SidebarPanel, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { GithubIcon, OutlinedClockIcon } from '@patternfly/react-icons'; +import type { McpServer } from '~/app/mcpServerCatalogTypes'; +import ExternalLink from '~/app/shared/components/ExternalLink'; +import MarkdownComponent from '~/app/shared/markdown/MarkdownComponent'; +import ModelTimestamp from '~/app/pages/modelRegistry/screens/components/ModelTimestamp'; + +type McpServerDetailsViewProps = { + server: McpServer; +}; + +const VISIBLE_LABELS = 3; + +const getDeploymentModeLabel = (mode?: string): string => { + if (!mode) { + return 'N/A'; + } + return mode === 'local' ? 'Local to cluster' : 'Remote'; +}; + +const getTransportTypeLabel = (transports?: string[]): string => { + if (!transports || transports.length === 0) { + return 'N/A'; + } + return transports + .map((t) => { + switch (t) { + case 'http': + return 'http-streaming'; + case 'sse': + return 'SSE'; + case 'stdio': + return 'stdio'; + default: + return t; + } + }) + .join(', '); +}; + +const McpServerDetailsView: React.FC = ({ server }) => { + const deploymentModeLabel = getDeploymentModeLabel(server.deploymentMode); + const transportTypeLabel = getTransportTypeLabel(server.transports); + + return ( + + + + + + + + + Description + + + + +

+ {server.description || 'No description'} +

+
+
+
+
+ + + + + <Icon isInline style={{ marginRight: '4px' }}> + <GithubIcon /> + </Icon> + README + + + + {!server.readme && ( + + No README available + + )} + {server.readme && ( + + )} + + + +
+
+ + + + + Server details + + + + + {server.tags && server.tags.length > 0 && ( + + Labels + + + {server.tags.map((tag) => ( + + ))} + + + + )} + + License + + {server.licenseLink ? ( + + ) : ( + {server.license || 'N/A'} + )} + + + + Version + + {server.version || 'N/A'} + + + + Deployment mode + + {deploymentModeLabel} + + + {server.artifacts && server.artifacts.length > 0 && ( + + Artifacts + + + {server.artifacts.map((artifact) => ( + + + {artifact.uri} + + + ))} + + + + )} + {(server.sourceCode || server.repositoryUrl) && ( + + Source Code + + {server.repositoryUrl ? ( + + ) : ( + {server.sourceCode} + )} + + + )} + {server.provider && ( + + Provider + + {server.provider} + + + )} + + Transport type + + {transportTypeLabel} + + + {server.lastUpdated && ( + + Last modified + + + + + + + + )} + + + + +
+
+ ); +}; + +export default McpServerDetailsView; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/types/mcpCatalogContext.ts b/clients/ui/frontend/src/app/pages/mcpCatalog/types/mcpCatalogContext.ts new file mode 100644 index 0000000000..d0facf7595 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/types/mcpCatalogContext.ts @@ -0,0 +1,36 @@ +import type { CatalogFilterOptionsList } from '~/app/modelCatalogTypes'; +import type { McpServer } from '~/app/mcpServerCatalogTypes'; +import type { McpCatalogFiltersState } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; + +export type McpCatalogPaginationState = { + page: number; + pageSize: number; + totalItems: number; +}; + +export type McpCatalogContextType = { + filters: McpCatalogFiltersState; + setFilters: ( + filters: McpCatalogFiltersState | ((prev: McpCatalogFiltersState) => McpCatalogFiltersState), + ) => void; + searchQuery: string; + setSearchQuery: (query: string) => void; + namedQuery: string | null; + setNamedQuery: (query: string | null) => void; + pagination: McpCatalogPaginationState; + setPage: (page: number) => void; + setPageSize: (pageSize: number) => void; + setTotalItems: (totalItems: number) => void; + selectedSourceLabel: string | undefined; + setSelectedSourceLabel: (label: string | undefined) => void; + clearAllFilters: () => void; + sourceLabels: string[]; + catalogSourcesLoaded: boolean; + catalogSourcesLoadError: Error | undefined; + mcpServers: { items: McpServer[] }; + mcpServersLoaded: boolean; + mcpServersLoadError: Error | undefined; + filterOptions: CatalogFilterOptionsList | null; + filterOptionsLoaded: boolean; + filterOptionsLoadError: Error | undefined; +}; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/types/mcpCatalogFilterOptions.ts b/clients/ui/frontend/src/app/pages/mcpCatalog/types/mcpCatalogFilterOptions.ts new file mode 100644 index 0000000000..22710ff230 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/types/mcpCatalogFilterOptions.ts @@ -0,0 +1,23 @@ +export type McpFilterCategoryKey = + | 'deploymentMode' + | 'supportedTransports' + | 'license' + | 'labels' + | 'securityVerification'; + +export type McpCatalogFiltersState = { + [K in McpFilterCategoryKey]?: string[]; +}; + +export type McpCatalogFilterStringOption = { + type: 'string'; + values?: string[]; +}; + +export type McpCatalogFilterOptions = { + [key in McpFilterCategoryKey]?: McpCatalogFilterStringOption; +}; + +export type McpCatalogFilterOptionsList = { + filters?: McpCatalogFilterOptions; +}; diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/utils/__tests__/mcpCatalogUtils.spec.ts b/clients/ui/frontend/src/app/pages/mcpCatalog/utils/__tests__/mcpCatalogUtils.spec.ts new file mode 100644 index 0000000000..d04f900912 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/utils/__tests__/mcpCatalogUtils.spec.ts @@ -0,0 +1,162 @@ +import { + filterMcpServersByFilters, + filterMcpServersBySearchQuery, + getSecurityIndicatorLabels, + hasMcpFiltersApplied, +} from '~/app/pages/mcpCatalog/utils/mcpCatalogUtils'; +import type { McpCatalogFiltersState } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; +import type { McpServer } from '~/app/mcpServerCatalogTypes'; + +describe('getSecurityIndicatorLabels', () => { + it('returns empty array when securityIndicators is undefined or null', () => { + expect(getSecurityIndicatorLabels(undefined)).toEqual([]); + expect(getSecurityIndicatorLabels(null)).toEqual([]); + }); + + it('returns labels for true boolean flags', () => { + expect(getSecurityIndicatorLabels({ verifiedSource: true, sast: true })).toEqual([ + 'Verified source', + 'SAST', + ]); + expect(getSecurityIndicatorLabels({ secureEndpoint: true })).toEqual(['Secure endpoint']); + expect(getSecurityIndicatorLabels({ readOnlyTools: true })).toEqual(['Read only tools']); + }); + + it('ignores false or undefined flags', () => { + expect( + getSecurityIndicatorLabels({ + verifiedSource: false, + secureEndpoint: true, + sast: undefined, + }), + ).toEqual(['Secure endpoint']); + }); +}); + +describe('hasMcpFiltersApplied', () => { + it('returns false when filters are empty and searchQuery is empty', () => { + expect(hasMcpFiltersApplied({}, '')).toBe(false); + expect(hasMcpFiltersApplied({}, ' ')).toBe(false); + }); + + it('returns true when searchQuery has non-empty trimmed content', () => { + expect(hasMcpFiltersApplied({}, 'q')).toBe(true); + expect(hasMcpFiltersApplied({}, ' query ')).toBe(true); + }); + + it('returns false when all filter keys have empty arrays or are missing', () => { + const filters: McpCatalogFiltersState = { + deploymentMode: [], + supportedTransports: [], + license: [], + labels: [], + securityVerification: [], + }; + expect(hasMcpFiltersApplied(filters, '')).toBe(false); + }); + + it('returns true when deploymentMode has values', () => { + expect(hasMcpFiltersApplied({ deploymentMode: ['Local'] }, '')).toBe(true); + }); + + it('returns true when supportedTransports has values', () => { + expect(hasMcpFiltersApplied({ supportedTransports: ['stdio'] }, '')).toBe(true); + }); + + it('returns true when license has values', () => { + expect(hasMcpFiltersApplied({ license: ['MIT'] }, '')).toBe(true); + }); + + it('returns true when labels has values', () => { + expect(hasMcpFiltersApplied({ labels: ['Red Hat'] }, '')).toBe(true); + }); + + it('returns true when securityVerification has values', () => { + expect(hasMcpFiltersApplied({ securityVerification: ['Verified'] }, '')).toBe(true); + }); + + it('returns true when multiple filter keys have values', () => { + const filters: McpCatalogFiltersState = { + deploymentMode: ['Local'], + license: ['Apache-2.0'], + }; + expect(hasMcpFiltersApplied(filters, '')).toBe(true); + }); + + it('ignores non-array filter values', () => { + expect(hasMcpFiltersApplied({ deploymentMode: 'Local' as unknown as string[] }, '')).toBe( + false, + ); + }); +}); + +describe('filterMcpServersByFilters', () => { + const servers: McpServer[] = [ + { id: 1, name: 'A', deploymentMode: 'local', toolCount: 0 }, + { id: 2, name: 'B', deploymentMode: 'remote', toolCount: 0 }, + { id: 3, name: 'C', deploymentMode: 'remote', toolCount: 0 }, + ]; + + it('returns all items when filters are empty', () => { + expect(filterMcpServersByFilters(servers, {})).toEqual(servers); + expect(filterMcpServersByFilters(servers, { deploymentMode: [] })).toEqual(servers); + }); + + it('filters by deploymentMode (Remote)', () => { + const result = filterMcpServersByFilters(servers, { deploymentMode: ['Remote'] }); + expect(result).toHaveLength(2); + expect(result.map((s) => s.name)).toEqual(['B', 'C']); + }); + + it('filters by deploymentMode (Local) case-insensitive', () => { + const result = filterMcpServersByFilters(servers, { deploymentMode: ['local'] }); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('A'); + }); + + it('filters by license when server has license', () => { + const withLicense: McpServer[] = [ + { id: 1, name: 'X', license: 'MIT', toolCount: 0 }, + { id: 2, name: 'Y', license: 'Apache-2.0', toolCount: 0 }, + ]; + expect(filterMcpServersByFilters(withLicense, { license: ['MIT'] })).toHaveLength(1); + expect(filterMcpServersByFilters(withLicense, { license: ['MIT'] })[0].name).toBe('X'); + }); + + it('filters by securityVerification label', () => { + const withSecurity: McpServer[] = [ + { id: 1, name: 'S1', securityIndicators: { verifiedSource: true }, toolCount: 0 }, + { id: 2, name: 'S2', securityIndicators: { sast: true }, toolCount: 0 }, + ]; + const result = filterMcpServersByFilters(withSecurity, { + securityVerification: ['Verified source'], + }); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('S1'); + }); +}); + +describe('filterMcpServersBySearchQuery', () => { + const servers: McpServer[] = [ + { id: 1, name: 'GitHub', description: 'Integrate with GitHub', toolCount: 0 }, + { id: 2, name: 'Slack', description: 'Search and interact with Slack', toolCount: 0 }, + { id: 3, name: 'PostgreSQL', description: 'Query databases', toolCount: 0 }, + ]; + + it('returns all items when search is empty or whitespace', () => { + expect(filterMcpServersBySearchQuery(servers, '')).toEqual(servers); + expect(filterMcpServersBySearchQuery(servers, ' ')).toEqual(servers); + }); + + it('filters by name (case-insensitive)', () => { + const result = filterMcpServersBySearchQuery(servers, 'git'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('GitHub'); + }); + + it('filters by description', () => { + const result = filterMcpServersBySearchQuery(servers, 'databases'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('PostgreSQL'); + }); +}); diff --git a/clients/ui/frontend/src/app/pages/mcpCatalog/utils/mcpCatalogUtils.ts b/clients/ui/frontend/src/app/pages/mcpCatalog/utils/mcpCatalogUtils.ts new file mode 100644 index 0000000000..943e97d1d0 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/mcpCatalog/utils/mcpCatalogUtils.ts @@ -0,0 +1,160 @@ +import type { McpCatalogFiltersState } from '~/app/pages/mcpCatalog/types/mcpCatalogFilterOptions'; +import { MCP_FILTER_KEYS } from '~/app/pages/mcpCatalog/constants/mcpCatalogFilterChipNames'; +import type { + McpSecurityIndicator, + McpServer, + McpTransportType, +} from '~/app/mcpServerCatalogTypes'; + +const SECURITY_INDICATOR_LABELS: Record = { + verifiedSource: 'Verified source', + secureEndpoint: 'Secure endpoint', + sast: 'SAST', + readOnlyTools: 'Read only tools', +}; + +const SECURITY_LABEL_TO_KEY: Record = { + [SECURITY_INDICATOR_LABELS.verifiedSource]: 'verifiedSource', + [SECURITY_INDICATOR_LABELS.secureEndpoint]: 'secureEndpoint', + [SECURITY_INDICATOR_LABELS.sast]: 'sast', + [SECURITY_INDICATOR_LABELS.readOnlyTools]: 'readOnlyTools', +}; + +const SECURITY_INDICATOR_KEYS: (keyof McpSecurityIndicator)[] = [ + 'verifiedSource', + 'secureEndpoint', + 'sast', + 'readOnlyTools', +]; + +export const getSecurityIndicatorLabels = ( + securityIndicators?: McpSecurityIndicator | null, +): string[] => { + if (!securityIndicators) { + return []; + } + return SECURITY_INDICATOR_KEYS.filter((key) => Boolean(securityIndicators[key])).map( + (key) => SECURITY_INDICATOR_LABELS[key], + ); +}; + +export const hasMcpFiltersApplied = ( + filters: McpCatalogFiltersState, + searchQuery: string, +): boolean => { + if (searchQuery && searchQuery.trim().length > 0) { + return true; + } + for (const key of MCP_FILTER_KEYS) { + const value = filters[key]; + if (Array.isArray(value) && value.length > 0) { + return true; + } + } + return false; +}; + +export function filterMcpServersBySearchQuery( + items: McpServer[], + searchQuery: string, +): McpServer[] { + const q = searchQuery.trim().toLowerCase(); + if (!q) { + return items; + } + return items.filter((server) => { + const name = server.name.toLowerCase(); + const description = server.description?.toLowerCase() ?? ''; + return name.includes(q) || description.includes(q); + }); +} + +function isMcpTransport(s: string): s is McpTransportType { + return s === 'stdio' || s === 'sse' || s === 'http'; +} + +function matchesDeploymentMode(server: McpServer, selected: string[]): boolean { + if (selected.length === 0) { + return true; + } + const mode = server.deploymentMode?.toLowerCase(); + if (!mode) { + return false; + } + return selected.some((v) => v.toLowerCase() === mode); +} + +function matchesLicense(server: McpServer, selected: string[]): boolean { + if (selected.length === 0) { + return true; + } + const license = server.license?.trim(); + if (!license) { + return false; + } + return selected.some((v) => v.trim().toLowerCase() === license.toLowerCase()); +} + +function matchesLabels(server: McpServer, selected: string[]): boolean { + if (selected.length === 0) { + return true; + } + const tags = server.tags ?? []; + return selected.some((s) => tags.includes(s)); +} + +function matchesTransports(server: McpServer, selected: string[]): boolean { + if (selected.length === 0) { + return true; + } + const transports: McpTransportType[] = server.transports ?? []; + return selected.some((s) => isMcpTransport(s) && transports.includes(s)); +} + +function matchesSecurityVerification(server: McpServer, selected: string[]): boolean { + if (selected.length === 0) { + return true; + } + const ind = server.securityIndicators; + if (!ind) { + return false; + } + const selectedKeys = selected + .map((label) => SECURITY_LABEL_TO_KEY[label]) + .filter((k): k is keyof McpSecurityIndicator => k !== undefined); + if (selectedKeys.length === 0) { + return false; + } + return selectedKeys.some((key) => Boolean(ind[key])); +} + +export function filterMcpServersByFilters( + items: McpServer[], + filters: McpCatalogFiltersState, +): McpServer[] { + const { + deploymentMode: deploymentModeFilter, + license: licenseFilter, + labels: labelsFilter, + supportedTransports: transportsFilter, + securityVerification: securityFilter, + } = filters; + return items.filter((server) => { + if (deploymentModeFilter?.length && !matchesDeploymentMode(server, deploymentModeFilter)) { + return false; + } + if (licenseFilter?.length && !matchesLicense(server, licenseFilter)) { + return false; + } + if (labelsFilter?.length && !matchesLabels(server, labelsFilter)) { + return false; + } + if (transportsFilter?.length && !matchesTransports(server, transportsFilter)) { + return false; + } + if (securityFilter?.length && !matchesSecurityVerification(server, securityFilter)) { + return false; + } + return true; + }); +} diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationFilterToolbar.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationFilterToolbar.tsx index 8320d3baf7..56e21af0b4 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationFilterToolbar.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationFilterToolbar.tsx @@ -99,7 +99,7 @@ const HardwareConfigurationFilterToolbar: React.FC diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableColumns.ts b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableColumns.ts index ed0609e2b5..31a2d57756 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableColumns.ts +++ b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableColumns.ts @@ -24,6 +24,14 @@ This prevents word wrapping into 3 lines (e.g., keeps "TTFT Latency" together in */ const NBSP = '\u00A0'; +const TPS_INFO = { + popover: + 'Throughput measured in tokens per second (tok/s). Higher values indicate faster token generation.', + popoverProps: { + position: 'left' as const, + }, +}; + export const hardwareConfigColumns: HardwareConfigColumn[] = [ { field: PerformancePropertyKey.HARDWARE_CONFIGURATION, @@ -46,7 +54,7 @@ export const hardwareConfigColumns: HardwareConfigColumn[] = [ getStringValue(b.customProperties, PerformancePropertyKey.HARDWARE_CONFIGURATION), ), isStickyColumn: true, - stickyMinWidth: '162px', + stickyMinWidth: '200px', stickyLeftOffset: '0', modifier: 'wrap', }, @@ -59,7 +67,7 @@ export const hardwareConfigColumns: HardwareConfigColumn[] = [ ): number => getWorkloadType(a).localeCompare(getWorkloadType(b)), isStickyColumn: true, stickyMinWidth: '132px', - stickyLeftOffset: '162px', + stickyLeftOffset: '200px', modifier: 'wrap', hasRightBorder: true, }, @@ -255,6 +263,7 @@ export const hardwareConfigColumns: HardwareConfigColumn[] = [ { field: 'tps_mean', label: `TPS${NBSP}Mean`, + info: TPS_INFO, sortable: ( a: CatalogPerformanceMetricsArtifact, b: CatalogPerformanceMetricsArtifact, @@ -267,6 +276,7 @@ export const hardwareConfigColumns: HardwareConfigColumn[] = [ { field: 'tps_p90', label: `TPS${NBSP}P90`, + info: TPS_INFO, sortable: ( a: CatalogPerformanceMetricsArtifact, b: CatalogPerformanceMetricsArtifact, @@ -278,6 +288,7 @@ export const hardwareConfigColumns: HardwareConfigColumn[] = [ { field: 'tps_p95', label: `TPS${NBSP}P95`, + info: TPS_INFO, sortable: ( a: CatalogPerformanceMetricsArtifact, b: CatalogPerformanceMetricsArtifact, @@ -289,6 +300,7 @@ export const hardwareConfigColumns: HardwareConfigColumn[] = [ { field: 'tps_p99', label: `TPS${NBSP}P99`, + info: TPS_INFO, sortable: ( a: CatalogPerformanceMetricsArtifact, b: CatalogPerformanceMetricsArtifact, diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableRow.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableRow.tsx index 5592fdda23..4fc5a8acad 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableRow.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableRow.tsx @@ -3,6 +3,7 @@ import { Td, Tr } from '@patternfly/react-table'; import { CatalogPerformanceMetricsArtifact } from '~/app/modelCatalogTypes'; import { formatLatency, + formatTps, formatTokenValue, getWorkloadType, } from '~/app/pages/modelCatalog/utils/performanceMetricsUtils'; @@ -52,15 +53,16 @@ const HardwareConfigurationTableRow: React.FC(obj: Partial>, s: string): s is K { + return Object.hasOwn(obj, s); +} type ModelCatalogStringFilterProps = { title: string; @@ -22,82 +25,30 @@ const ModelCatalogStringFilter = ({ filterToNameMapping, filters, }: ModelCatalogStringFilterProps): JSX.Element => { - const [showMore, setShowMore] = React.useState(false); - const [searchValue, setSearchValue] = React.useState(''); - const { isSelected, setSelected } = useCatalogStringFilterState(filterKey); + const { filterData } = React.useContext(ModelCatalogContext); + const { setSelected } = useCatalogStringFilterState(filterKey); + const selectedValues = filterData[filterKey]; const getLabel = React.useCallback( - (value: ModelCatalogStringFilterValueType[K]) => filterToNameMapping[value] ?? value, + (value: string): string => + isFilterMappingKey(filterToNameMapping, value) + ? (filterToNameMapping[value] ?? value) + : value, [filterToNameMapping], ); const filterValues = React.useMemo(() => filters?.values ?? [], [filters?.values]); - const valuesMatchingSearch = React.useMemo( - () => - filterValues.filter((value) => { - const label = getLabel(value).toLowerCase(); - return ( - value.toLowerCase().includes(searchValue.trim().toLowerCase()) || - label.includes(searchValue.trim().toLowerCase()) || - isSelected(value) - ); - }), - [filterValues, getLabel, isSelected, searchValue], - ); - - const onSearchChange = (newValue: string) => { - setSearchValue(newValue); - }; - - const visibleValues = showMore - ? valuesMatchingSearch - : valuesMatchingSearch.slice(0, MAX_VISIBLE_FILTERS); - return ( - - {title} - {filterValues.length > MAX_VISIBLE_FILTERS && ( - onSearchChange(newValue)} - /> - )} - {visibleValues.length === 0 && ( -
No results found
- )} - {visibleValues.map((checkbox) => ( - setSelected(checkbox, checked)} - /> - ))} - {!showMore && valuesMatchingSearch.length > MAX_VISIBLE_FILTERS && ( - - )} - {showMore && valuesMatchingSearch.length > MAX_VISIBLE_FILTERS && ( - - )} -
+ `${title}-${value}-checkbox`} + /> ); }; diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/components/globalFilters/MaxRpsFilter.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/components/globalFilters/MaxRpsFilter.tsx index f67e551550..cb8e3f0724 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/components/globalFilters/MaxRpsFilter.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/components/globalFilters/MaxRpsFilter.tsx @@ -74,7 +74,7 @@ const MaxRpsFilter: React.FC = () => { direction={{ default: 'column' }} spaceItems={{ default: 'spaceItemsSm' }} flexWrap={{ default: 'wrap' }} - style={{ width: '500px', padding: '16px' }} + style={{ minWidth: '400px', padding: '16px' }} > Max requests per second (RPS) diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogGalleryView.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogGalleryView.tsx index 0e199fff63..4463288246 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogGalleryView.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/screens/ModelCatalogGalleryView.tsx @@ -256,10 +256,16 @@ const ModelCatalogGalleryView: React.FC = ({ return ( Reset filters} + primaryAction={ + + } /> ); } diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts b/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts index 8c46f17d55..dfec4676b7 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts +++ b/clients/ui/frontend/src/app/pages/modelCatalog/utils/modelCatalogUtils.ts @@ -188,7 +188,7 @@ export const useCatalogStringFilterState = boolean; - setSelected: (value: ModelCatalogStringFilterValueType[K], selected: boolean) => void; + setSelected: (value: string, selected: boolean) => void; } => { type Value = ModelCatalogStringFilterValueType[K]; const { filterData, setFilterData } = React.useContext(ModelCatalogContext); @@ -196,7 +196,7 @@ export const useCatalogStringFilterState = Object.values(ModelCatalogStringFilterKey).includes(filterKey); const isSelected = React.useCallback((value: Value) => selections.includes(value), [selections]); - const setSelected = (value: Value, selected: boolean) => { + const setSelected = (value: string, selected: boolean) => { const nextState = selected ? [...selections, value] : selections.filter((item) => item !== value); diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/utils/performanceMetricsUtils.ts b/clients/ui/frontend/src/app/pages/modelCatalog/utils/performanceMetricsUtils.ts index f57f696cb5..13b66f2124 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/utils/performanceMetricsUtils.ts +++ b/clients/ui/frontend/src/app/pages/modelCatalog/utils/performanceMetricsUtils.ts @@ -43,6 +43,8 @@ type CalculateSliderRangeOptions = { export const formatLatency = (value: number): string => `${value.toFixed(2)} ms`; +export const formatTps = (value: number): string => `${value.toFixed(2)} tok/s`; + export const formatTokenValue = (value: number): string => value.toFixed(0); export const getWorkloadType = (artifact: CatalogPerformanceMetricsArtifact): string => { diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobStatusModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobStatusModal.tsx index 22e6b264d4..7febf965b3 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobStatusModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobStatusModal.tsx @@ -59,7 +59,7 @@ const ModelTransferJobStatusModal: React.FC = if (!isOpen || !apiAvailable || !job.name || !job.namespace) { return Promise.reject(new NotReadyError('Modal is closed or API not available')); } - return api.getModelTransferJobEvents(opts, job.name, job.namespace ?? ''); + return api.getModelTransferJobEvents(opts, job.name, job.namespace); }, [isOpen, apiAvailable, api, job.name, job.namespace], ); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobTableRow.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobTableRow.tsx index 32ae48a367..6a797bb79e 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobTableRow.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobTableRow.tsx @@ -1,12 +1,4 @@ -import { - Button, - Content, - ContentVariants, - Flex, - FlexItem, - Label, - Truncate, -} from '@patternfly/react-core'; +import { Button, Content, Flex, FlexItem, Label, Truncate } from '@patternfly/react-core'; import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; import { CheckCircleIcon, @@ -20,11 +12,7 @@ import { useNavigate } from 'react-router-dom'; import ModelTimestamp from '~/app/pages/modelRegistry/screens/components/ModelTimestamp'; import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; import { registeredModelUrl, modelVersionUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; -import { - ModelTransferJob, - ModelTransferJobStatus, - ModelTransferJobUploadIntent, -} from '~/app/types'; +import { ModelTransferJob, ModelTransferJobStatus } from '~/app/types'; import { EMPTY_CUSTOM_PROPERTY_VALUE } from '~/concepts/modelCatalog/const'; import ModelTransferJobStatusModal from './ModelTransferJobStatusModal'; @@ -99,18 +87,12 @@ const ModelTransferJobTableRow: React.FC = ({
- +
- {job.description && ( - - - - )} {job.registeredModelName ? ( - job.uploadIntent === ModelTransferJobUploadIntent.CREATE_MODEL && - job.status === ModelTransferJobStatus.COMPLETED ? ( + job.registeredModelId ? ( @@ -123,9 +105,7 @@ const ModelTransferJobTableRow: React.FC = ({ {job.modelVersionName ? ( - (job.uploadIntent === ModelTransferJobUploadIntent.CREATE_MODEL || - job.uploadIntent === ModelTransferJobUploadIntent.CREATE_VERSION) && - job.status === ModelTransferJobStatus.COMPLETED ? ( + job.registeredModelId && job.modelVersionId ? ( diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobs.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobs.tsx index 4c4662d7f0..a224835d23 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobs.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobs.tsx @@ -29,14 +29,18 @@ const ModelTransferJobs: React.FC = ({ ...pageProps }) = const [jobToRetry, setJobToRetry] = React.useState(null); const onRetryTransferJob = React.useCallback( - async (newJobName: string, deleteOldJob: boolean) => { + async (newJobName: string, newJobDisplayName: string, deleteOldJob: boolean) => { if (!jobToRetry?.name || !apiAvailable) { return; } await api.updateModelTransferJob( {}, jobToRetry.name, - { name: newJobName }, + { + name: newJobName, + namespace: jobToRetry.namespace, + jobDisplayName: newJobDisplayName, + }, { deleteOldJob: deleteOldJob.toString() }, ); await refetchJobs(); @@ -59,7 +63,7 @@ const ModelTransferJobs: React.FC = ({ ...pageProps }) = setIsDeleting(true); setDeleteError(undefined); try { - await api.deleteModelTransferJob({}, jobToDelete.name); + await api.deleteModelTransferJob({}, jobToDelete.name, jobToDelete.namespace); setJobToDelete(null); await refetchJobs(); } catch (e) { diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobsListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobsListView.tsx index 48ad76a27b..137f911dcd 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobsListView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/ModelTransferJobsListView.tsx @@ -67,7 +67,8 @@ const ModelTransferJobsListView: React.FC = ({ const statusFilter = filterData[ModelTransferJobsFilterOptions.status]?.toLowerCase(); return jobs.filter((job) => { - if (jobNameFilter && !job.name.toLowerCase().includes(jobNameFilter)) { + const jobNameForDisplay = (job.jobDisplayName || job.name || '').toLowerCase(); + if (jobNameFilter && !jobNameForDisplay.includes(jobNameFilter)) { return false; } if (modelNameFilter && !job.registeredModelName?.toLowerCase().includes(modelNameFilter)) { @@ -76,7 +77,7 @@ const ModelTransferJobsListView: React.FC = ({ if (versionNameFilter && !job.modelVersionName?.toLowerCase().includes(versionNameFilter)) { return false; } - if (namespaceFilter && !job.namespace?.toLowerCase().includes(namespaceFilter)) { + if (namespaceFilter && !job.namespace.toLowerCase().includes(namespaceFilter)) { return false; } if (authorFilter && !job.author?.toLowerCase().includes(authorFilter)) { diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/RetryJobModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/RetryJobModal.tsx index e0f993d46d..d7a48e0fec 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/RetryJobModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/RetryJobModal.tsx @@ -46,7 +46,7 @@ const generateRetryJobName = (originalName: string, maxLength = 63): string => { type RetryJobModalProps = { job: ModelTransferJob; onClose: () => void; - onRetry: (newJobName: string, deleteOldJob: boolean) => Promise; + onRetry: (newJobName: string, newJobDisplayName: string, deleteOldJob: boolean) => Promise; }; const RetryJobModal: React.FC = ({ job, onClose, onRetry }) => { @@ -74,7 +74,7 @@ const RetryJobModal: React.FC = ({ job, onClose, onRetry }) setIsRetrying(true); setError(undefined); try { - await onRetry(k8sName.value, deleteOldJob); + await onRetry(fieldData.k8sName.value, fieldData.name, deleteOldJob); onClose(); } catch (e) { setError(e instanceof Error ? e : new Error(String(e))); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/__tests__/RetryJobModal.spec.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/__tests__/RetryJobModal.spec.tsx index 0e59367ad6..c4bf84d18f 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/__tests__/RetryJobModal.spec.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelTransferJobs/__tests__/RetryJobModal.spec.tsx @@ -17,6 +17,7 @@ describe('RetryJobModal', () => { const mockJob: ModelTransferJob = { id: 'test-job-id', name: 'test-job-name', + jobDisplayName: 'test-job-name', status: ModelTransferJobStatus.FAILED, uploadIntent: ModelTransferJobUploadIntent.CREATE_MODEL, namespace: 'test-namespace', @@ -156,7 +157,7 @@ describe('RetryJobModal', () => { fireEvent.click(screen.getByTestId('retry-job-submit-button')); await waitFor(() => { - expect(mockOnRetry).toHaveBeenCalledWith('test-job-name-2', true); + expect(mockOnRetry).toHaveBeenCalledWith('test-job-name-2', 'test-job-name-2', true); }); }); @@ -169,7 +170,7 @@ describe('RetryJobModal', () => { fireEvent.click(screen.getByTestId('retry-job-submit-button')); await waitFor(() => { - expect(mockOnRetry).toHaveBeenCalledWith('test-job-name-2', false); + expect(mockOnRetry).toHaveBeenCalledWith('test-job-name-2', 'test-job-name-2', false); }); }); @@ -186,7 +187,7 @@ describe('RetryJobModal', () => { fireEvent.click(screen.getByTestId('retry-job-submit-button')); await waitFor(() => { - expect(mockOnRetry).toHaveBeenCalledWith('custom-retry-name', true); + expect(mockOnRetry).toHaveBeenCalledWith('custom-retry-name', 'test-job-name-2', true); }); }); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterAndStoreFields.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterAndStoreFields.tsx index 7b108b143b..d042b22e87 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterAndStoreFields.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegisterAndStoreFields.tsx @@ -81,7 +81,7 @@ const RegisterAndStoreFields = ({ hideDescription /> { hasAccess: namespaceHasAccess, isLoading: isNamespaceAccessLoading, error: namespaceAccessError, - } = useCheckNamespaceRegistryAccess(mrName, registryNamespace, formData.namespace ?? ''); + } = useCheckNamespaceRegistryAccess(mrName, registryNamespace, formData.namespace); const isModelNameValid = isNameValid(formData.modelName); const isModelNameDuplicate = isModelNameExisting(formData.modelName, registeredModels); 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 459dea98ea..416e467b5b 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 @@ -53,7 +53,7 @@ const RegisterVersion: React.FC = () => { hasAccess: namespaceHasAccess, isLoading: isNamespaceAccessLoading, error: namespaceAccessError, - } = useCheckNamespaceRegistryAccess(mrName, registryNamespace, formData.namespace ?? ''); + } = useCheckNamespaceRegistryAccess(mrName, registryNamespace, formData.namespace); const isSubmitDisabled = isSubmitting || isRegisterVersionSubmitDisabled(formData, namespaceHasAccess, isNamespaceAccessLoading); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationDestinationLocationFields.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationDestinationLocationFields.tsx index d04aaf231d..588a101778 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationDestinationLocationFields.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/RegistrationDestinationLocationFields.tsx @@ -19,7 +19,6 @@ const RegistrationDestinationLocationFields = ); - const ociEmailInput = ( - setData('destinationOciEmail', value)} - /> - ); - return ( <> @@ -87,9 +76,6 @@ const RegistrationDestinationLocationFields = - - - diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/__tests__/utils.spec.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/__tests__/utils.spec.ts index 7586ce6bcd..2d9cfa3a13 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/__tests__/utils.spec.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/__tests__/utils.spec.ts @@ -60,7 +60,7 @@ describe('RegisterModel utils', () => { destinationOciUsername: '', destinationOciPassword: '', destinationOciUri: 'quay.io/org/model:v1', - destinationOciEmail: '', + jobName: 'test-job', jobResourceName: 'test-job-resource', versionCustomProperties: {}, diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegisterModelData.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegisterModelData.ts index b16df7d7fd..8d86e06297 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegisterModelData.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegisterModelData.ts @@ -25,8 +25,8 @@ export type RegistrationCommonFormData = { destinationOciUsername: string; destinationOciPassword: string; destinationOciUri: string; - destinationOciEmail: string; - namespace?: string; + + namespace: string; registrationMode?: RegistrationMode.Register | RegistrationMode.RegisterAndStore; jobName: string; jobResourceName: string; @@ -65,7 +65,7 @@ const registrationCommonFormDataDefaults: RegistrationCommonFormData = { destinationOciUsername: '', destinationOciPassword: '', destinationOciUri: '', - destinationOciEmail: '', + namespace: '', registrationMode: RegistrationMode.Register, jobName: '', 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 5bcb46ab71..ff207d97c4 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 @@ -263,7 +263,6 @@ export const buildModelTransferJobPayload = ( registry: formData.destinationOciRegistry || undefined, username: formData.destinationOciUsername, password: formData.destinationOciPassword, - email: formData.destinationOciEmail || undefined, }; // RegisterModelFormData has modelName (user-provided for new model). @@ -277,6 +276,7 @@ export const buildModelTransferJobPayload = ( return { name: formData.jobResourceName, + jobDisplayName: formData.jobName, source, destination, uploadIntent, diff --git a/clients/ui/frontend/src/app/routes/mcpCatalog/mcpCatalog.ts b/clients/ui/frontend/src/app/routes/mcpCatalog/mcpCatalog.ts new file mode 100644 index 0000000000..2f8ed8020d --- /dev/null +++ b/clients/ui/frontend/src/app/routes/mcpCatalog/mcpCatalog.ts @@ -0,0 +1,4 @@ +export const mcpCatalogUrl = (): string => '/mcp-catalog'; + +export const mcpServerDetailsUrl = (serverId: string | number): string => + `${mcpCatalogUrl()}/${encodeURIComponent(String(serverId))}`; diff --git a/clients/ui/frontend/src/app/shared/components/catalog/CatalogStringFilter.tsx b/clients/ui/frontend/src/app/shared/components/catalog/CatalogStringFilter.tsx new file mode 100644 index 0000000000..8888a983b1 --- /dev/null +++ b/clients/ui/frontend/src/app/shared/components/catalog/CatalogStringFilter.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import { Button, Checkbox, Content, ContentVariants, SearchInput } from '@patternfly/react-core'; +import { CATALOG_STRING_FILTER_MAX_VISIBLE } from './constants'; + +export type CatalogStringFilterProps = { + title: string; + filterValues: string[]; + selectedValues: string[]; + onToggle: (value: string, checked: boolean) => void; + getLabel?: (value: string) => string; + testIdBase: string; + getCheckboxTestId?: (value: string) => string; +}; + +const CatalogStringFilter: React.FC = ({ + title, + filterValues, + selectedValues, + onToggle, + getLabel = (v) => v, + testIdBase, + getCheckboxTestId = (v) => `${testIdBase}-${v}`, +}) => { + const [showMore, setShowMore] = React.useState(false); + const [searchValue, setSearchValue] = React.useState(''); + + const valuesMatchingSearch = React.useMemo( + () => + filterValues.filter((value) => { + const label = getLabel(value).toLowerCase(); + const q = searchValue.trim().toLowerCase(); + return ( + !q || + value.toLowerCase().includes(q) || + label.includes(q) || + selectedValues.includes(value) + ); + }), + [filterValues, getLabel, searchValue, selectedValues], + ); + + const visibleValues = showMore + ? valuesMatchingSearch + : valuesMatchingSearch.slice(0, CATALOG_STRING_FILTER_MAX_VISIBLE); + const hasMoreThanMax = valuesMatchingSearch.length > CATALOG_STRING_FILTER_MAX_VISIBLE; + const showSearch = filterValues.length > CATALOG_STRING_FILTER_MAX_VISIBLE; + + const isSelected = React.useCallback( + (value: string) => selectedValues.includes(value), + [selectedValues], + ); + + return ( + + {title} + {showSearch && ( + setSearchValue(value)} + data-testid={`${testIdBase}-search`} + className="pf-v6-u-mb-sm" + /> + )} + {visibleValues.length === 0 && ( +
No results found
+ )} + {visibleValues.map((value) => ( + onToggle(value, checked)} + data-testid={getCheckboxTestId(value)} + /> + ))} + {!showMore && hasMoreThanMax && ( + + )} + {showMore && hasMoreThanMax && ( + + )} +
+ ); +}; + +export default CatalogStringFilter; diff --git a/clients/ui/frontend/src/app/shared/components/catalog/constants.ts b/clients/ui/frontend/src/app/shared/components/catalog/constants.ts new file mode 100644 index 0000000000..c791b681af --- /dev/null +++ b/clients/ui/frontend/src/app/shared/components/catalog/constants.ts @@ -0,0 +1 @@ +export const CATALOG_STRING_FILTER_MAX_VISIBLE = 5; diff --git a/clients/ui/frontend/src/app/shared/components/catalog/index.ts b/clients/ui/frontend/src/app/shared/components/catalog/index.ts new file mode 100644 index 0000000000..0dc5f9d9f6 --- /dev/null +++ b/clients/ui/frontend/src/app/shared/components/catalog/index.ts @@ -0,0 +1,3 @@ +export { default as CatalogStringFilter } from './CatalogStringFilter'; +export type { CatalogStringFilterProps } from './CatalogStringFilter'; +export { CATALOG_STRING_FILTER_MAX_VISIBLE } from './constants'; diff --git a/clients/ui/frontend/src/app/standalone/NamespaceSelector.tsx b/clients/ui/frontend/src/app/standalone/GlobalNamespaceSelector.tsx similarity index 55% rename from clients/ui/frontend/src/app/standalone/NamespaceSelector.tsx rename to clients/ui/frontend/src/app/standalone/GlobalNamespaceSelector.tsx index 7db5b6ba2d..cbba80bbbd 100644 --- a/clients/ui/frontend/src/app/standalone/NamespaceSelector.tsx +++ b/clients/ui/frontend/src/app/standalone/GlobalNamespaceSelector.tsx @@ -3,50 +3,36 @@ import { useNamespaceSelector, useModularArchContext } from 'mod-arch-core'; import { SimpleSelect } from 'mod-arch-shared'; import { SimpleSelectOption } from 'mod-arch-shared/dist/components/SimpleSelect'; -interface NamespaceSelectorProps { +interface GlobalNamespaceSelectorProps { onSelect?: (namespace: string) => void; className?: string; isDisabled?: boolean; - selectedNamespace?: string; - placeholderText?: string; - isFullWidth?: boolean; - isGlobalSelector?: boolean; - ignoreMandatoryNamespace?: boolean; } -const NamespaceSelector: React.FC = ({ - placeholderText, +const GlobalNamespaceSelector: React.FC = ({ onSelect, className, isDisabled: externalDisabled, - selectedNamespace, - isFullWidth, - isGlobalSelector, - ignoreMandatoryNamespace, }) => { const { namespaces = [], preferredNamespace, updatePreferredNamespace } = useNamespaceSelector(); const { config } = useModularArchContext(); - // Check if mandatory namespace is configured const isMandatoryNamespace = Boolean(config.mandatoryNamespace); + const isDisabled = externalDisabled || isMandatoryNamespace || namespaces.length === 0; - const baseDisabled = externalDisabled || namespaces.length === 0; - const isDisabled = ignoreMandatoryNamespace ? baseDisabled : baseDisabled || isMandatoryNamespace; const options: SimpleSelectOption[] = namespaces.map((namespace) => ({ key: namespace.name, label: namespace.name, })); - const selectedValue = !isGlobalSelector - ? selectedNamespace || '' - : preferredNamespace?.name || namespaces[0]?.name || ''; + const selectedValue = preferredNamespace?.name || namespaces[0]?.name || ''; const handleChange = (key: string, isPlaceholder: boolean) => { if (isPlaceholder || !key) { return; } - if (!isMandatoryNamespace && isGlobalSelector) { + if (!isMandatoryNamespace) { updatePreferredNamespace({ name: key }); } @@ -61,13 +47,13 @@ const NamespaceSelector: React.FC = ({ value={selectedValue} className={className} onChange={handleChange} - placeholder={placeholderText} isDisabled={isDisabled} - isFullWidth={isFullWidth} + isScrollable + maxMenuHeight="300px" popperProps={{ maxWidth: '400px' }} - dataTestId={isGlobalSelector ? 'navbar-namespace-selector' : 'form-namespace-selector'} + dataTestId="navbar-namespace-selector" /> ); }; -export default NamespaceSelector; +export default GlobalNamespaceSelector; diff --git a/clients/ui/frontend/src/app/standalone/NavBar.tsx b/clients/ui/frontend/src/app/standalone/NavBar.tsx index d0c9b46c35..52c84261a1 100644 --- a/clients/ui/frontend/src/app/standalone/NavBar.tsx +++ b/clients/ui/frontend/src/app/standalone/NavBar.tsx @@ -21,7 +21,7 @@ import { import { BarsIcon } from '@patternfly/react-icons'; import { useThemeContext } from 'mod-arch-kubeflow'; import { images as sharedImages } from 'mod-arch-shared'; -import NamespaceSelector from './NamespaceSelector'; +import GlobalNamespaceSelector from './GlobalNamespaceSelector'; interface NavBarProps { username?: string; @@ -69,7 +69,7 @@ const NavBar: React.FC = ({ username, onLogout }) => { - + {username && ( diff --git a/clients/ui/frontend/src/app/types.ts b/clients/ui/frontend/src/app/types.ts index af0330b302..076c2e418f 100644 --- a/clients/ui/frontend/src/app/types.ts +++ b/clients/ui/frontend/src/app/types.ts @@ -213,6 +213,8 @@ export type PatchModelArtifact = ( modelartifactId: string, ) => Promise; +export type GetListModelTransferJobs = (opts: APIOptions) => Promise; + export type CreateModelTransferJob = ( opts: APIOptions, data: CreateModelTransferJobData, @@ -220,12 +222,16 @@ export type CreateModelTransferJob = ( export type UpdateModelTransferJob = ( opts: APIOptions, - jobId: string, + jobName: string, data: Partial, additionalQueryParams?: Record, ) => Promise; -export type DeleteModelTransferJob = (opts: APIOptions, jobId: string) => Promise; +export type DeleteModelTransferJob = ( + opts: APIOptions, + jobName: string, + jobNamespace: string, +) => Promise; export type ModelRegistryAPIs = { createRegisteredModel: CreateRegisteredModel; @@ -248,7 +254,6 @@ export type ModelRegistryAPIs = { }; // Model Transfer Job Types - export enum ModelTransferJobSourceType { S3 = 's3', OCI = 'oci', @@ -289,7 +294,6 @@ export type ModelTransferJobOCISource = { uri: string; username: string; password: string; - email?: string; registry?: string; }; @@ -316,7 +320,6 @@ export type ModelTransferJobOCIDestination = { uri: string; username: string; password: string; - email?: string; registry?: string; }; @@ -334,6 +337,7 @@ export type ModelTransferJobEvent = { export type ModelTransferJob = { id: string; name: string; + jobDisplayName: string; description?: string; source: ModelTransferJobSource; destination: ModelTransferJobDestination; @@ -348,7 +352,7 @@ export type ModelTransferJob = { modelArtifactName?: string; sourceModelFormat?: string; sourceModelFormatVersion?: string; - namespace?: string; + namespace: string; author?: string; status: ModelTransferJobStatus; createTimeSinceEpoch: string; @@ -368,8 +372,6 @@ export type CreateModelTransferJobData = Omit< export type ModelTransferJobList = ModelRegistryListParams & { items: ModelTransferJob[] }; -export type GetListModelTransferJobs = (opts: APIOptions) => Promise; - export type GetModelTransferJobEvents = ( opts: APIOptions, jobName: string, diff --git a/clients/ui/frontend/src/concepts/k8s/NamespaceSelectorField/NamespaceSelectorField.tsx b/clients/ui/frontend/src/concepts/k8s/NamespaceSelectorField/NamespaceSelectorField.tsx index a8da6cff71..3ca6491b06 100644 --- a/clients/ui/frontend/src/concepts/k8s/NamespaceSelectorField/NamespaceSelectorField.tsx +++ b/clients/ui/frontend/src/concepts/k8s/NamespaceSelectorField/NamespaceSelectorField.tsx @@ -9,9 +9,10 @@ import { Stack, StackItem, } from '@patternfly/react-core'; -import { useNamespaceSelector } from 'mod-arch-core'; +import { SimpleSelect } from 'mod-arch-shared'; +import { SimpleSelectOption } from 'mod-arch-shared/dist/components/SimpleSelect'; +import { useNamespaces } from '~/app/hooks/useNamespaces'; import ThemeAwareFormGroupWrapper from '~/app/pages/settings/components/ThemeAwareFormGroupWrapper'; -import NamespaceSelector from '~/app/standalone/NamespaceSelector'; import { NamespaceSelectorMessages } from '~/app/utilities/const'; const WHO_IS_MY_ADMIN_POPOVER_CONTENT = ( @@ -52,23 +53,38 @@ const NamespaceSelectorField: React.FC = ({ error, }) => { const labelHelpRef = useRef(null); - const { namespaces = [] } = useNamespaceSelector(); + const [namespaces, namespacesLoaded, namespacesLoadError] = useNamespaces(); const isDisabled = namespaces.length === 0; + const options: SimpleSelectOption[] = namespaces.map((ns) => ({ + key: ns.name, + label: ns.name, + })); + + const handleChange = (key: string, isPlaceholder: boolean) => { + if (isPlaceholder || !key) { + return; + } + onSelect(key); + }; + const namespaceSelectorElement = (
-
); - const showNoAccessMessage = namespaces.length === 0; + const showNoAccessMessage = namespacesLoaded && !namespacesLoadError && namespaces.length === 0; const showNoAccessAlert = namespaces.length > 0 && selectedNamespace && !isLoading && hasAccess === false; @@ -84,6 +100,22 @@ const NamespaceSelectorField: React.FC = ({ const helperTextNode = ( <> + {!namespacesLoaded && !namespacesLoadError && ( + + Loading namespaces... + + )} + {namespacesLoadError && ( + + {namespacesLoadError.message} + + )} {selectedNamespace && isLoading && ( Checking access... diff --git a/go.mod b/go.mod index e5278a9968..02584e645a 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 k8s.io/api v0.34.4 - k8s.io/apimachinery v0.34.4 + k8s.io/apimachinery v0.35.2 k8s.io/client-go v0.34.4 knative.dev/pkg v0.0.0-20250326102644-9f3e60a9244c sigs.k8s.io/controller-runtime v0.22.4 diff --git a/go.sum b/go.sum index d85ef3ba88..4e2adfa6aa 100644 --- a/go.sum +++ b/go.sum @@ -690,8 +690,8 @@ k8s.io/api v0.34.4 h1:Z5hsoQcZ2yBjelb9j5JKzCVo9qv9XLkVm5llnqS4h+0= k8s.io/api v0.34.4/go.mod h1:6SaGYuGPkMqqCgg8rPG/OQoCrhgSEV+wWn9v21fDP3o= k8s.io/apiextensions-apiserver v0.34.3 h1:p10fGlkDY09eWKOTeUSioxwLukJnm+KuDZdrW71y40g= k8s.io/apiextensions-apiserver v0.34.3/go.mod h1:aujxvqGFRdb/cmXYfcRTeppN7S2XV/t7WMEc64zB5A0= -k8s.io/apimachinery v0.34.4 h1:C5SiSzLEMyWIk53sSbnk0WlOOyqv/MFnWvuc/d6M+xc= -k8s.io/apimachinery v0.34.4/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= +k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.34.3 h1:uGH1qpDvSiYG4HVFqc6A3L4CKiX+aBWDrrsxHYK0Bdo= k8s.io/apiserver v0.34.3/go.mod h1:QPnnahMO5C2m3lm6fPW3+JmyQbvHZQ8uudAu/493P2w= k8s.io/client-go v0.34.4 h1:IXhvzFdm0e897kXtLbeyMpAGzontcShJ/gi/XCCsOLc= diff --git a/internal/db/scopes/paginate.go b/internal/db/scopes/paginate.go index 75fdc529c4..63fa25c7db 100644 --- a/internal/db/scopes/paginate.go +++ b/internal/db/scopes/paginate.go @@ -164,7 +164,8 @@ func DecodeCursor(token string) (*Cursor, error) { return nil, err } - parts := strings.Split(string(decoded), ":") + // Split only on the first ":" so the value can contain colons (e.g. stored names "sourceId:modelName"). + parts := strings.SplitN(string(decoded), ":", 2) if len(parts) != 2 { return nil, fmt.Errorf("invalid cursor format") } diff --git a/internal/db/scopes/paginate_test.go b/internal/db/scopes/paginate_test.go index 7864ba30a3..d524f72fe1 100644 --- a/internal/db/scopes/paginate_test.go +++ b/internal/db/scopes/paginate_test.go @@ -225,9 +225,15 @@ func TestCursorDecoding(t *testing.T) { }, { name: "Cursor with invalid format", - token: base64.StdEncoding.EncodeToString([]byte("invalid:format:extra")), + token: base64.StdEncoding.EncodeToString([]byte("no-colon")), expectError: true, - description: "Cursor with wrong format should return error", + description: "Cursor with no colon should return error", + }, + { + name: "Cursor with value containing colon", + token: base64.StdEncoding.EncodeToString([]byte("123:source_id:model_name")), + expectError: false, + description: "Cursor value may contain colons (e.g. namespaced names)", }, { name: "Cursor with non-numeric ID", @@ -246,6 +252,10 @@ func TestCursorDecoding(t *testing.T) { } else { assert.NoError(t, err, tt.description) assert.NotNil(t, cursor, "Cursor should not be nil on success") + if tt.name == "Cursor with value containing colon" { + assert.Equal(t, int32(123), cursor.ID) + assert.Equal(t, "source_id:model_name", cursor.Value) + } } }) }