From 0e87677b1e02574389031617600d12d343cebced Mon Sep 17 00:00:00 2001 From: SasmithaDilshan Date: Mon, 17 Aug 2026 15:45:18 +0530 Subject: [PATCH] fix(entity-service,customer-portal-backend-v2): decode deployedProductCount and url on deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checked the Go entity-service's ServiceNow deployment struct against the Ballerina entity-service (digiops-cs, the implementation currently serving production). Two fields ServiceNow already sends were not declared in the Go struct, so encoding/json silently discarded them: Ballerina Deployment record Go snDeployment --------------------------- --------------- deployedProductCount (absent) url (absent) Consequence: the customer portal's Usage & Metrics view filters deployments on `(dep.productCount ?? 0) > 0`. With the count missing, every deployment was filtered out, no deployment tab was selected, and every downstream metrics query was disabled by its own `enabled` guard — so the page rendered empty with no console error and no network request. Against the Ballerina backend the same page works, which is what isolated it. - entity-service: declare url and deployedProductCount on snDeployment, map both onto domain.DeploymentView, and document them in openapi.yaml - backend-v2: decode both on entity.DeploymentView and expose them as url and productCount, matching the frontend's ProjectDeploymentItem and the rename the Ballerina backend already performs This also removes the per-request product tally added in #1471. That worked around the missing field by issuing an extra deployed-products search and counting client-side; with the upstream field decoded it is redundant, so the extra upstream call, its paging loop and its best-effort failure path are all deleted. productCount is now a plain int rather than a pointer — the upstream always supplies a value, so there is no "not counted" state to represent. Tests assert the emitted JSON keys (productCount, not deployedProductCount) and that a product-less deployment reports 0 rather than omitting the field. Co-Authored-By: Claude Opus 5 --- .../backend-v2/internal/dto/deployment.go | 28 +-- .../internal/dto/deployment_counts_test.go | 100 ----------- .../dto/deployment_product_count_test.go | 83 +++++++++ .../backend-v2/internal/entity/types.go | 5 + .../handler/deployment_product_counts_test.go | 161 ------------------ .../internal/handler/deployments.go | 64 +------ apps/customer-portal/backend-v2/openapi.yaml | 15 +- entity-service/internal/domain/entity.go | 7 + .../internal/service/sn_deployment_service.go | 8 + entity-service/openapi.yaml | 6 + 10 files changed, 122 insertions(+), 355 deletions(-) delete mode 100644 apps/customer-portal/backend-v2/internal/dto/deployment_counts_test.go create mode 100644 apps/customer-portal/backend-v2/internal/dto/deployment_product_count_test.go delete mode 100644 apps/customer-portal/backend-v2/internal/handler/deployment_product_counts_test.go diff --git a/apps/customer-portal/backend-v2/internal/dto/deployment.go b/apps/customer-portal/backend-v2/internal/dto/deployment.go index fafa0a6f9f..c6c4cb346d 100644 --- a/apps/customer-portal/backend-v2/internal/dto/deployment.go +++ b/apps/customer-portal/backend-v2/internal/dto/deployment.go @@ -37,14 +37,14 @@ type DeploymentSummary struct { Project *IDLabelRef `json:"project,omitempty"` CreatedOn time.Time `json:"createdOn"` UpdatedOn time.Time `json:"updatedOn"` + URL *string `json:"url,omitempty"` // ProductCount is named productCount, NOT deployedProductCount: the // frontend's ProjectDeploymentItem reads productCount, and the Usage // Metrics page filters deployments on `(dep.productCount ?? 0) > 0`. An // absent value silently filters every deployment out, leaving that page // blank with no error and no network calls. The Ballerina backend does the // same rename (utils.bal: `productCount: deployment.deployedProductCount`). - ProductCount *int `json:"productCount,omitempty"` - InstanceCount *int `json:"instanceCount,omitempty"` + ProductCount int `json:"productCount"` } // SearchDeploymentsResponse is the portal's response for @@ -72,6 +72,9 @@ func MapSearchDeployments(r entity.SearchDeploymentsResponse) SearchDeploymentsR Project: entityRefToIDLabel(&d.Project), CreatedOn: d.CreatedOn, UpdatedOn: d.UpdatedOn, + + URL: d.URL, + ProductCount: d.DeployedProductCount, }) } return SearchDeploymentsResponse{ @@ -83,27 +86,6 @@ func MapSearchDeployments(r entity.SearchDeploymentsResponse) SearchDeploymentsR } } -// WithDeploymentCounts fills ProductCount/InstanceCount on each deployment from -// per-deployment tallies keyed by deployment ID. -// -// entity-service's DeploymentView carries no counts (neither does the -// ServiceNow payload behind it), so the handler derives them and passes them -// here — see DeploymentHandler.SearchDeployments. A deployment missing from a -// map is left nil rather than set to 0, so "not counted" stays distinguishable -// from "counted zero"; the frontend treats both as 0 via `?? 0`. -func WithDeploymentCounts(resp SearchDeploymentsResponse, productCounts, instanceCounts map[string]int) SearchDeploymentsResponse { - for i := range resp.Deployments { - id := resp.Deployments[i].ID - if n, ok := productCounts[id]; ok { - resp.Deployments[i].ProductCount = &n - } - if n, ok := instanceCounts[id]; ok { - resp.Deployments[i].InstanceCount = &n - } - } - return resp -} - // DeploymentCreateRequest is the portal's request body for // POST /projects/{id}/deployments — DeploymentTypeKey is the ServiceNow // numeric choice-list key the frontend sends (CreateDeploymentRequest. diff --git a/apps/customer-portal/backend-v2/internal/dto/deployment_counts_test.go b/apps/customer-portal/backend-v2/internal/dto/deployment_counts_test.go deleted file mode 100644 index 44feac6e77..0000000000 --- a/apps/customer-portal/backend-v2/internal/dto/deployment_counts_test.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -package dto - -import ( - "encoding/json" - "testing" -) - -// TestWithDeploymentCounts_UsesFrontendFieldNames is the regression guard for -// the Usage Metrics page rendering blank. -// -// That page filters deployments on `(dep.productCount ?? 0) > 0`. The field is -// productCount — NOT deployedProductCount, which is what entity-service calls -// the equivalent aggregate elsewhere. When the key is absent or misnamed every -// deployment is filtered out, no deployment tab is selected, and every -// downstream metrics query is disabled by its `enabled` guard — so the page -// renders empty with no console error and no network request at all. Renaming -// this key would silently reintroduce that. -func TestWithDeploymentCounts_UsesFrontendFieldNames(t *testing.T) { - resp := WithDeploymentCounts( - SearchDeploymentsResponse{Deployments: []DeploymentSummary{{ID: "dep-1"}}}, - map[string]int{"dep-1": 3}, - map[string]int{"dep-1": 5}, - ) - - raw, err := json.Marshal(resp.Deployments[0]) - if err != nil { - t.Fatalf("marshal returned error: %v", err) - } - var got map[string]any - if err := json.Unmarshal(raw, &got); err != nil { - t.Fatalf("result is not valid JSON: %v", err) - } - - if got["productCount"] != float64(3) { - t.Errorf(`productCount = %v, want 3 — the Usage Metrics page filters on this exact key`, got["productCount"]) - } - if got["instanceCount"] != float64(5) { - t.Errorf("instanceCount = %v, want 5", got["instanceCount"]) - } - if _, wrong := got["deployedProductCount"]; wrong { - t.Error(`emitted "deployedProductCount"; the frontend reads "productCount"`) - } -} - -// TestWithDeploymentCounts_AbsentWhenNotCounted checks that a deployment with -// no tally is left without the key rather than reported as zero, so "we did not -// count" stays distinguishable from "counted zero" for any future consumer. -// The current frontend collapses both to 0 via `?? 0`. -func TestWithDeploymentCounts_AbsentWhenNotCounted(t *testing.T) { - resp := WithDeploymentCounts( - SearchDeploymentsResponse{Deployments: []DeploymentSummary{{ID: "dep-1"}, {ID: "dep-2"}}}, - map[string]int{"dep-1": 2}, - nil, - ) - - if resp.Deployments[0].ProductCount == nil || *resp.Deployments[0].ProductCount != 2 { - t.Errorf("dep-1 ProductCount = %v, want 2", resp.Deployments[0].ProductCount) - } - if resp.Deployments[1].ProductCount != nil { - t.Errorf("dep-2 ProductCount = %v, want nil (never counted)", *resp.Deployments[1].ProductCount) - } - - raw, _ := json.Marshal(resp.Deployments[1]) - var got map[string]any - if err := json.Unmarshal(raw, &got); err != nil { - t.Fatalf("result is not valid JSON: %v", err) - } - if _, present := got["productCount"]; present { - t.Error("productCount should be omitted entirely when the deployment was never counted") - } -} - -// TestWithDeploymentCounts_NilMapsAreSafe covers the best-effort path: when the -// upstream tally fails the handler passes nil, and the response must still be -// well-formed rather than panicking or emitting zeros. -func TestWithDeploymentCounts_NilMapsAreSafe(t *testing.T) { - resp := WithDeploymentCounts( - SearchDeploymentsResponse{Deployments: []DeploymentSummary{{ID: "dep-1"}}}, - nil, nil, - ) - if resp.Deployments[0].ProductCount != nil || resp.Deployments[0].InstanceCount != nil { - t.Error("nil count maps must leave both counts unset") - } -} diff --git a/apps/customer-portal/backend-v2/internal/dto/deployment_product_count_test.go b/apps/customer-portal/backend-v2/internal/dto/deployment_product_count_test.go new file mode 100644 index 0000000000..561bdbdb0b --- /dev/null +++ b/apps/customer-portal/backend-v2/internal/dto/deployment_product_count_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package dto + +import ( + "encoding/json" + "testing" + + "github.com/wso2-open-operations/cs-tools/apps/customer-portal/backend-v2/internal/entity" +) + +// TestMapSearchDeployments_EmitsProductCount is the regression guard for the +// Usage & Metrics page rendering blank. +// +// That page filters deployments on `(dep.productCount ?? 0) > 0`. The key is +// productCount — NOT deployedProductCount, which is what entity-service calls +// it upstream. When the key is missing every deployment is filtered out, no +// deployment tab is selected, and every downstream metrics query is disabled by +// its own `enabled` guard — so the page renders empty with no console error and +// no network request at all. The Ballerina backend performs the same rename. +func TestMapSearchDeployments_EmitsProductCount(t *testing.T) { + got := MapSearchDeployments(entity.SearchDeploymentsResponse{ + Deployments: []entity.DeploymentView{ + {ID: "dep-1", Name: "Primary Production", DeployedProductCount: 3}, + {ID: "dep-2", Name: "Empty", DeployedProductCount: 0}, + }, + Total: 2, + }) + + raw, err := json.Marshal(got.Deployments) + if err != nil { + t.Fatalf("marshal returned error: %v", err) + } + var items []map[string]any + if err := json.Unmarshal(raw, &items); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + + if items[0]["productCount"] != float64(3) { + t.Errorf(`dep-1 productCount = %v, want 3 — the page filters on this exact key`, items[0]["productCount"]) + } + // Present-and-zero, not omitted: a deployment with no products is a real + // answer from the upstream count, not missing data. + if items[1]["productCount"] != float64(0) { + t.Errorf("dep-2 productCount = %v, want 0", items[1]["productCount"]) + } + for i := range items { + if _, wrong := items[i]["deployedProductCount"]; wrong { + t.Errorf(`item %d emitted "deployedProductCount"; the frontend reads "productCount"`, i) + } + } +} + +// TestMapSearchDeployments_EmitsURL covers the other field entity-service was +// dropping: the frontend's ProjectDeploymentItem declares url, and the +// Ballerina entity-service's Deployment record carries it. +func TestMapSearchDeployments_EmitsURL(t *testing.T) { + url := "https://deployment.example.com" + got := MapSearchDeployments(entity.SearchDeploymentsResponse{ + Deployments: []entity.DeploymentView{{ID: "dep-1", URL: &url}, {ID: "dep-2"}}, + }) + + if got.Deployments[0].URL == nil || *got.Deployments[0].URL != url { + t.Errorf("dep-1 URL = %v, want %q", got.Deployments[0].URL, url) + } + if got.Deployments[1].URL != nil { + t.Errorf("dep-2 URL = %v, want nil when absent upstream", *got.Deployments[1].URL) + } +} diff --git a/apps/customer-portal/backend-v2/internal/entity/types.go b/apps/customer-portal/backend-v2/internal/entity/types.go index 12a7c99bb7..26b2eba7b9 100644 --- a/apps/customer-portal/backend-v2/internal/entity/types.go +++ b/apps/customer-portal/backend-v2/internal/entity/types.go @@ -758,10 +758,15 @@ type DeploymentView struct { Name string `json:"name"` Type string `json:"type"` Description *string `json:"description"` + URL *string `json:"url"` CreatedBy *EntityRef `json:"createdBy"` Project EntityRef `json:"project"` CreatedOn time.Time `json:"createdOn"` UpdatedOn time.Time `json:"updatedOn"` + // DeployedProductCount comes straight from entity-service, which decodes it + // from the upstream deployment payload. Exposed to the frontend as + // productCount — see dto.DeploymentSummary. + DeployedProductCount int `json:"deployedProductCount"` } // SearchDeploymentsResponse is entity-service's response for POST /deployments/search. diff --git a/apps/customer-portal/backend-v2/internal/handler/deployment_product_counts_test.go b/apps/customer-portal/backend-v2/internal/handler/deployment_product_counts_test.go deleted file mode 100644 index 201d098add..0000000000 --- a/apps/customer-portal/backend-v2/internal/handler/deployment_product_counts_test.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -package handler - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/wso2-open-operations/cs-tools/apps/customer-portal/backend-v2/internal/entity" -) - -// countsFakeEntityClient serves a fixed deployment list and a fixed deployed -// product list. entityDeploymentClient is embedded (nil) so only the two -// methods under test need implementing. -type countsFakeEntityClient struct { - entityDeploymentClient - deployments []entity.DeploymentView - deployedProduct []entity.DeployedProductView - productsErr error - productsCalls int -} - -func (f *countsFakeEntityClient) SearchDeployments(_ context.Context, _ entity.SearchDeploymentsRequest) (entity.SearchDeploymentsResponse, error) { - return entity.SearchDeploymentsResponse{ - Deployments: f.deployments, - Total: len(f.deployments), - }, nil -} - -func (f *countsFakeEntityClient) SearchDeployedProducts(_ context.Context, _ entity.SearchDeployedProductsRequest) (entity.SearchDeployedProductsResponse, error) { - f.productsCalls++ - if f.productsErr != nil { - return entity.SearchDeployedProductsResponse{}, f.productsErr - } - return entity.SearchDeployedProductsResponse{ - DeployedProducts: f.deployedProduct, - Total: len(f.deployedProduct), - }, nil -} - -// searchDeploymentsBody runs SearchDeployments through a real ServeMux using -// main.go's exact pattern, and returns the decoded deployments array. -func searchDeploymentsBody(t *testing.T, fake *countsFakeEntityClient) []map[string]any { - t.Helper() - - mux := http.NewServeMux() - mux.HandleFunc("POST /projects/{id}/deployments/search", NewDeploymentHandler(fake).SearchDeployments) - - w := httptest.NewRecorder() - mux.ServeHTTP(w, authedRequest(http.MethodPost, "/projects/"+testProjectID+"/deployments/search", "{}")) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200 (body: %s)", w.Code, w.Body.String()) - } - var got struct { - Deployments []map[string]any `json:"deployments"` - } - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("response is not valid JSON: %v", err) - } - return got.Deployments -} - -// TestSearchDeployments_EmptyProductSearchEmitsZeroCounts covers the case that -// motivated seeding the tally map: the deployed-product search succeeds but -// returns nothing. Every deployment has genuinely been counted, so each must -// report productCount 0 rather than omitting the field — omitting it would be -// indistinguishable from the tally having failed. -func TestSearchDeployments_EmptyProductSearchEmitsZeroCounts(t *testing.T) { - fake := &countsFakeEntityClient{ - deployments: []entity.DeploymentView{ - {ID: "dep-1", Name: "one"}, - {ID: "dep-2", Name: "two"}, - }, - deployedProduct: nil, // successful search, no products - } - - deployments := searchDeploymentsBody(t, fake) - - if len(deployments) != 2 { - t.Fatalf("got %d deployments, want 2", len(deployments)) - } - for i, d := range deployments { - got, present := d["productCount"] - if !present { - t.Errorf("deployment %d: productCount omitted; a successful tally must report 0", i) - continue - } - if got != float64(0) { - t.Errorf("deployment %d: productCount = %v, want 0", i, got) - } - } -} - -// TestSearchDeployments_CountsPerDeployment checks products are attributed to -// the deployment that owns them, and that a deployment with none still reports -// zero in the same response. -func TestSearchDeployments_CountsPerDeployment(t *testing.T) { - fake := &countsFakeEntityClient{ - deployments: []entity.DeploymentView{ - {ID: "dep-1"}, {ID: "dep-2"}, {ID: "dep-3"}, - }, - deployedProduct: []entity.DeployedProductView{ - {ID: "p1", Deployment: entity.EntityRef{ID: "dep-1"}}, - {ID: "p2", Deployment: entity.EntityRef{ID: "dep-1"}}, - {ID: "p3", Deployment: entity.EntityRef{ID: "dep-3"}}, - }, - } - - deployments := searchDeploymentsBody(t, fake) - - want := map[string]float64{"dep-1": 2, "dep-2": 0, "dep-3": 1} - for _, d := range deployments { - id, _ := d["id"].(string) - if got := d["productCount"]; got != want[id] { - t.Errorf("%s: productCount = %v, want %v", id, got, want[id]) - } - } - - // One upstream call for all three deployments — not an N+1. - if fake.productsCalls != 1 { - t.Errorf("SearchDeployedProducts called %d times, want 1", fake.productsCalls) - } -} - -// TestSearchDeployments_ProductTallyFailureOmitsCounts pins the best-effort -// contract: when the tally errors the deployment search still succeeds, and the -// counts are omitted entirely rather than reported as a misleading 0. -func TestSearchDeployments_ProductTallyFailureOmitsCounts(t *testing.T) { - fake := &countsFakeEntityClient{ - deployments: []entity.DeploymentView{{ID: "dep-1"}}, - productsErr: errors.New("upstream unavailable"), - } - - deployments := searchDeploymentsBody(t, fake) - - if len(deployments) != 1 { - t.Fatalf("got %d deployments, want 1", len(deployments)) - } - if _, present := deployments[0]["productCount"]; present { - t.Errorf("productCount = %v, want it omitted when the tally failed", deployments[0]["productCount"]) - } -} diff --git a/apps/customer-portal/backend-v2/internal/handler/deployments.go b/apps/customer-portal/backend-v2/internal/handler/deployments.go index 6927f3eb43..eb0e96848d 100644 --- a/apps/customer-portal/backend-v2/internal/handler/deployments.go +++ b/apps/customer-portal/backend-v2/internal/handler/deployments.go @@ -31,7 +31,6 @@ import ( // used by DeploymentHandler. type entityDeploymentClient interface { SearchDeployments(ctx context.Context, req entity.SearchDeploymentsRequest) (entity.SearchDeploymentsResponse, error) - SearchDeployedProducts(ctx context.Context, req entity.SearchDeployedProductsRequest) (entity.SearchDeployedProductsResponse, error) CreateDeployment(ctx context.Context, req entity.CreateDeploymentRequest) (entity.CreateDeploymentResponse, error) UpdateDeployment(ctx context.Context, id string, req entity.UpdateDeploymentRequest) (entity.UpdateDeploymentResponse, error) UpdateAttachment(ctx context.Context, id string, req entity.UpdateAttachmentRequest) (entity.UpdateAttachmentResponse, error) @@ -84,68 +83,7 @@ func (h *DeploymentHandler) SearchDeployments(w http.ResponseWriter, r *http.Req return } - resp := dto.MapSearchDeployments(result) - productCounts := h.deployedProductCounts(r.Context(), user.UserID, result.Deployments) - writeJSONValue(w, http.StatusOK, dto.WithDeploymentCounts(resp, productCounts, nil)) -} - -// deployedProductCountsPageLimit is the page size used when tallying deployed -// products. entity-service caps limit at 100, so this is the effective maximum. -const deployedProductCountsPageLimit = 100 - -// deployedProductCounts tallies deployed products per deployment for the -// deployments just returned by a search. -// -// One upstream call (paged) covers every deployment, because -// SearchDeployedProductsRequest accepts DeploymentIDs as a list and each -// DeployedProductView carries its own Deployment ref — so this is not an N+1 -// over deployments. -// -// Every requested deployment is present in the returned map, at zero if it has -// no products — a successful tally covers all of them, so "no products" is a -// real answer rather than a gap. -// -// Best-effort by design: a failure here logs and returns nil, leaving the -// counts absent rather than failing the whole deployment search. nil is -// therefore the only "not counted" signal. The frontend treats an absent count -// as 0, so the worst case is the pre-existing behaviour, not a broken page. -// Same graceful-degradation stance as dto.BuildProjectDashboardStats. -func (h *DeploymentHandler) deployedProductCounts(ctx context.Context, userID string, deployments []entity.DeploymentView) map[string]int { - if len(deployments) == 0 { - return nil - } - ids := make([]string, 0, len(deployments)) - for _, d := range deployments { - ids = append(ids, d.ID) - } - - // Seed every deployment at zero. The tally either succeeds for all ids or - // fails for all of them (nil, below), so once we get here a deployment with - // no products has genuinely been counted and its answer is 0 — reporting it - // as absent would be indistinguishable from "never counted". - counts := make(map[string]int, len(ids)) - for _, id := range ids { - counts[id] = 0 - } - for offset := 0; ; { - page, err := h.entity.SearchDeployedProducts(ctx, entity.SearchDeployedProductsRequest{ - DeploymentIDs: ids, - Pagination: entity.Pagination{Limit: deployedProductCountsPageLimit, Offset: offset}, - }) - if err != nil { - slog.WarnContext(ctx, "entity SearchDeployedProducts failed while tallying deployment product counts", - "userID", userID, "err", summarizeErr(err)) - return nil - } - for _, p := range page.DeployedProducts { - counts[p.Deployment.ID]++ - } - offset += len(page.DeployedProducts) - if len(page.DeployedProducts) == 0 || offset >= page.Total { - break - } - } - return counts + writeJSONValue(w, http.StatusOK, dto.MapSearchDeployments(result)) } // CreateDeployment handles POST /projects/{id}/deployments. diff --git a/apps/customer-portal/backend-v2/openapi.yaml b/apps/customer-portal/backend-v2/openapi.yaml index a8dbded8f5..37c4ceae8d 100644 --- a/apps/customer-portal/backend-v2/openapi.yaml +++ b/apps/customer-portal/backend-v2/openapi.yaml @@ -6753,14 +6753,13 @@ components: productCount: type: integer description: >- - Number of deployed products in this deployment. Named productCount - (not deployedProductCount) to match the frontend contract — the - Usage Metrics page filters deployments on this field. Omitted when - the tally could not be retrieved. - instanceCount: - type: integer - description: >- - Number of instances in this deployment. Omitted when not counted. + Number of deployed products in this deployment, from + entity-service's deployedProductCount. Named productCount to match + the frontend contract — the Usage & Metrics page filters + deployments on this field. + url: + type: string + nullable: true SearchDeploymentsResponse: description: > diff --git a/entity-service/internal/domain/entity.go b/entity-service/internal/domain/entity.go index 9e114d180c..544fd39798 100644 --- a/entity-service/internal/domain/entity.go +++ b/entity-service/internal/domain/entity.go @@ -949,10 +949,17 @@ type DeploymentView struct { Name string `json:"name"` Type DeploymentType `json:"type"` Description *string `json:"description"` + URL *string `json:"url"` CreatedBy *EntityRef `json:"createdBy"` Project EntityRef `json:"project"` CreatedOn time.Time `json:"createdOn"` UpdatedOn time.Time `json:"updatedOn"` + // DeployedProductCount is the number of deployed products in this + // deployment, as reported by the upstream data source. Consumers rely on it + // to tell whether a deployment has any products at all without a second + // query — the customer portal's Usage & Metrics view filters on exactly + // this. Matches the Ballerina entity-service's Deployment.deployedProductCount. + DeployedProductCount int `json:"deployedProductCount"` } // SearchDeploymentsRequest is the input for a deployment search operation. diff --git a/entity-service/internal/service/sn_deployment_service.go b/entity-service/internal/service/sn_deployment_service.go index 7820c3b6ab..0f30fa85c6 100644 --- a/entity-service/internal/service/sn_deployment_service.go +++ b/entity-service/internal/service/sn_deployment_service.go @@ -42,10 +42,15 @@ type snDeployment struct { Number string `json:"number"` Name string `json:"name"` Description *string `json:"description"` + URL *string `json:"url"` CreatedOn string `json:"createdOn"` UpdatedOn string `json:"updatedOn"` Project snDeployProject `json:"project"` Type snDeployType `json:"type"` + // DeployedProductCount is sent by ServiceNow on every deployment in the + // search response. It was previously not declared here and so silently + // discarded — see domain.DeploymentView.DeployedProductCount. + DeployedProductCount int `json:"deployedProductCount"` } type snDeployProject struct { @@ -129,6 +134,9 @@ func (s *snDeploymentService) SearchDeployments(ctx context.Context, req domain. Project: domain.EntityRef{ID: sysidToUUID(d.Project.ID), Name: d.Project.Name}, CreatedOn: createdOn, UpdatedOn: updatedOn, + + URL: d.URL, + DeployedProductCount: d.DeployedProductCount, }) } diff --git a/entity-service/openapi.yaml b/entity-service/openapi.yaml index 1fb07b6430..206b6ce494 100644 --- a/entity-service/openapi.yaml +++ b/entity-service/openapi.yaml @@ -5533,6 +5533,12 @@ components: updatedOn: type: string format: date-time + url: + type: string + nullable: true + deployedProductCount: + type: integer + description: Number of deployed products in this deployment. CreateDeploymentRequest: type: object