diff --git a/.dockerignore b/.dockerignore index 221809a3..c08ba8d6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,8 @@ components/** packages/** !packages/gateway-management-ui/ !packages/gateway-management-ui/** +!packages/operational-dashboard-ui/ +!packages/operational-dashboard-ui/** !components/web-console/ !components/web-console/** !scripts/bootstrap_pnpm.sh diff --git a/.forbidden-terms-whitelist.json b/.forbidden-terms-whitelist.json index 2fb2bb8f..6a541b69 100644 --- a/.forbidden-terms-whitelist.json +++ b/.forbidden-terms-whitelist.json @@ -36,7 +36,7 @@ }, { "filename": "CLAUDE.md", - "line": 125, + "line": 127, "rationale": "The em dash appears inside a convention rule that documents the character itself as a forbidden term; it must be shown literally to be unambiguous." } ] diff --git a/.github/component-paths.json b/.github/component-paths.json index 8107c875..b5c6487b 100644 --- a/.github/component-paths.json +++ b/.github/component-paths.json @@ -57,6 +57,20 @@ ".github/workflows/lint.yml" ] }, + "operational_dashboard_ui": { + "directory": "packages/operational-dashboard-ui", + "lint_job": "lint-operational-dashboard-ui", + "paths": [ + "packages/operational-dashboard-ui/**", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "Makefile", + ".github/component-paths.json", + ".github/scripts/detect-components.sh", + ".github/workflows/lint.yml" + ] + }, "e2e": { "directory": "tests/e2e", "paths": [ @@ -112,6 +126,7 @@ "paths": [ "components/web-console/**", "packages/gateway-management-ui/**", + "packages/operational-dashboard-ui/**", "components/sdk-typescript/**", "package.json", "pnpm-lock.yaml", diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6f8b14ea..a39bf6be 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,6 +26,7 @@ jobs: cli: ${{ steps.detect.outputs.cli }} control_plane: ${{ steps.detect.outputs.control_plane }} gateway_management_ui: ${{ steps.detect.outputs.gateway_management_ui }} + operational_dashboard_ui: ${{ steps.detect.outputs.operational_dashboard_ui }} pr_test: ${{ steps.detect.outputs.pr_test }} sdk_typescript: ${{ steps.detect.outputs.sdk_typescript }} web_console: ${{ steps.detect.outputs.web_console }} @@ -189,6 +190,25 @@ jobs: - name: Run gateway management UI package quality gates run: pnpm --filter @openshift-online/hypershell-gateway-management-ui check + lint-operational-dashboard-ui: + name: Operational dashboard UI package quality gates + needs: detect-changes + if: needs.detect-changes.outputs.operational_dashboard_ui == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: .node-version + - name: Install pinned pnpm + run: bash scripts/bootstrap_pnpm.sh + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Run operational dashboard UI package quality gates + run: pnpm --filter @openshift-online/hypershell-operational-dashboard-ui check + lint-pr-test: name: PR test script validation needs: detect-changes @@ -254,6 +274,7 @@ jobs: - lint-cli - lint-control-plane - lint-gateway-management-ui + - lint-operational-dashboard-ui - lint-pr-test - lint-sdk-typescript - lint-web-console @@ -266,11 +287,12 @@ jobs: CLI_RESULT: ${{ needs.lint-cli.result }} CONTROL_PLANE_RESULT: ${{ needs.lint-control-plane.result }} GATEWAY_MANAGEMENT_UI_RESULT: ${{ needs.lint-gateway-management-ui.result }} + OPERATIONAL_DASHBOARD_UI_RESULT: ${{ needs.lint-operational-dashboard-ui.result }} PR_TEST_RESULT: ${{ needs.lint-pr-test.result }} SDK_TYPESCRIPT_RESULT: ${{ needs.lint-sdk-typescript.result }} WEB_CONSOLE_RESULT: ${{ needs.lint-web-console.result }} run: | - for result in "${DETECTION_RESULT}" "${API_SERVER_RESULT}" "${CLI_RESULT}" "${CONTROL_PLANE_RESULT}" "${GATEWAY_MANAGEMENT_UI_RESULT}" "${PR_TEST_RESULT}" "${SDK_TYPESCRIPT_RESULT}" "${WEB_CONSOLE_RESULT}"; do + for result in "${DETECTION_RESULT}" "${API_SERVER_RESULT}" "${CLI_RESULT}" "${CONTROL_PLANE_RESULT}" "${GATEWAY_MANAGEMENT_UI_RESULT}" "${OPERATIONAL_DASHBOARD_UI_RESULT}" "${PR_TEST_RESULT}" "${SDK_TYPESCRIPT_RESULT}" "${WEB_CONSOLE_RESULT}"; do if [[ "${result}" == failure || "${result}" == cancelled ]]; then echo "One or more lint jobs failed or were cancelled." exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index dacfd740..1bdf218e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ checks manually with `make check`. - `components/api-server/` - Go REST + gRPC API microservice (rh-trex-ai framework), PostgreSQL-backed - `components/control-plane/` - Go service, watches API server via gRPC and reconciles gateway resources into K8s - `packages/gateway-management-ui/` - Private reusable React package containing canonical gateway management workflows +- `packages/operational-dashboard-ui/` - Private reusable React package containing the operational metrics dashboard - `specs/` - Desired state of the system ([platform](specs/platform/), [standards](specs/standards/)) - `skills/` - Agent skills: [reconcile](skills/build/reconcile), [spec](skills/plan/spec), [full-stack-pipeline](skills/build/full-stack-pipeline), [dev-cluster](skills/build/dev-cluster), [ibm-cluster](skills/deploy/ibm-cluster), [deploy-cluster](skills/deploy/deploy-cluster), [cloud-hub-ingress-bootstrap](skills/deploy/cloud-hub-ingress-bootstrap), [review](skills/review/review-guidance), [amber-review](skills/review/amber-review), [ui-standards](skills/review/ui-standards), [tooling](skills/tooling/) - `apm.yml` - APM manifest declaring upstream skill dependencies @@ -99,6 +100,7 @@ cd components/control-plane && go vet ./... # Vet # All Components make build-all # Build all container images pnpm --filter @openshift-online/hypershell-gateway-management-ui check # Verify reusable gateway UI +pnpm --filter @openshift-online/hypershell-operational-dashboard-ui check # Verify operational dashboard UI make kind-up # Start local Kind cluster make kind-down # Destroy Kind cluster make kind-status # Show cluster status diff --git a/README.md b/README.md index 2599e541..37dc3271 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ The API server exposes Prometheus metrics on its metrics port (default `:8080/me | Metric | Type | Description | |---|---|---| -| `hypershell_gateways_total{phase="Running"|"Provisioning"|"Degraded"|"Failed"}` | Gauge | Number of gateways by phase. Queried live from the database on each scrape. | +| `hypershell_gateways_total{phase="Pending"|"Provisioning"|"Running"|"Degraded"|"Failed"}` | Gauge | Number of gateways by phase. Queried live from the database on each scrape. | The control plane also exports these metrics through OTLP: diff --git a/components/api-server/openapi/openapi.users.yaml b/components/api-server/openapi/openapi.users.yaml new file mode 100644 index 00000000..5473a4b3 --- /dev/null +++ b/components/api-server/openapi/openapi.users.yaml @@ -0,0 +1,124 @@ +# NEW ENDPOINT START +paths: + /api/hypershell/v1/users: + get: + summary: List registered users + operationId: listUsers + parameters: + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/size' + - $ref: '#/components/parameters/search' + - $ref: '#/components/parameters/orderBy' + - $ref: '#/components/parameters/fields' + responses: + '200': + description: A list of registered users + content: + application/json: + schema: + $ref: '#/components/schemas/UserList' + '401': + $ref: 'openapi.yaml#/components/responses/UnauthorizedError' + '403': + $ref: 'openapi.yaml#/components/responses/ForbiddenError' + '500': + $ref: 'openapi.yaml#/components/responses/InternalServerError' + /api/hypershell/v1/users/{id}: + get: + summary: Get a registered user by ID + operationId: getUser + responses: + '200': + description: User found + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '401': + $ref: 'openapi.yaml#/components/responses/UnauthorizedError' + '404': + description: User not found + content: + application/json: + schema: + $ref: 'openapi.yaml#/components/schemas/Error' + '500': + $ref: 'openapi.yaml#/components/responses/InternalServerError' + parameters: + - $ref: '#/components/parameters/id' +# NEW ENDPOINT END +components: + schemas: + # NEW SCHEMA START + User: + allOf: + - $ref: 'openapi.yaml#/components/schemas/ObjectReference' + - type: object + required: + - username + properties: + username: + type: string + email: + type: string + name: + type: string + # NEW SCHEMA END + # NEW SCHEMA START + UserList: + allOf: + - $ref: 'openapi.yaml#/components/schemas/List' + - type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/User' + # NEW SCHEMA END + parameters: + id: + name: id + in: path + description: The id of record + required: true + schema: + type: string + page: + name: page + in: query + description: Page number of record list when record list exceeds specified page size + schema: + type: integer + default: 1 + minimum: 1 + required: false + size: + name: size + in: query + description: Maximum number of records to return + schema: + type: integer + default: 100 + minimum: 0 + required: false + search: + name: search + in: query + required: false + description: Specifies the search criteria + schema: + type: string + orderBy: + name: orderBy + in: query + required: false + description: Specifies the order by criteria + schema: + type: string + fields: + name: fields + in: query + required: false + description: Supplies a comma-separated list of fields to be returned + schema: + type: string diff --git a/components/api-server/openapi/openapi.yaml b/components/api-server/openapi/openapi.yaml index 28f7c4e1..a2feff37 100644 --- a/components/api-server/openapi/openapi.yaml +++ b/components/api-server/openapi/openapi.yaml @@ -60,6 +60,10 @@ paths: $ref: 'openapi.roleBindings.yaml#/paths/~1api~1hypershell~1v1~1role_bindings' /api/hypershell/v1/role_bindings/{id}: $ref: 'openapi.roleBindings.yaml#/paths/~1api~1hypershell~1v1~1role_bindings~1{id}' + /api/hypershell/v1/users: + $ref: 'openapi.users.yaml#/paths/~1api~1hypershell~1v1~1users' + /api/hypershell/v1/users/{id}: + $ref: 'openapi.users.yaml#/paths/~1api~1hypershell~1v1~1users~1{id}' # AUTO-ADD NEW PATHS components: schemas: @@ -162,6 +166,10 @@ components: $ref: 'openapi.roleBindings.yaml#/components/schemas/RoleBinding' RoleBindingList: $ref: 'openapi.roleBindings.yaml#/components/schemas/RoleBindingList' + User: + $ref: 'openapi.users.yaml#/components/schemas/User' + UserList: + $ref: 'openapi.users.yaml#/components/schemas/UserList' # AUTO-ADD NEW SCHEMAS parameters: id: diff --git a/components/api-server/pkg/api/openapi/.openapi-generator/FILES b/components/api-server/pkg/api/openapi/.openapi-generator/FILES index 706c9c43..d6473e0a 100644 --- a/components/api-server/pkg/api/openapi/.openapi-generator/FILES +++ b/components/api-server/pkg/api/openapi/.openapi-generator/FILES @@ -41,6 +41,8 @@ docs/Role.md docs/RoleBinding.md docs/RoleBindingList.md docs/RoleList.md +docs/User.md +docs/UserList.md git_push.sh go.mod go.sum @@ -78,6 +80,8 @@ model_role.go model_role_binding.go model_role_binding_list.go model_role_list.go +model_user.go +model_user_list.go response.go test/api_default_test.go utils.go diff --git a/components/api-server/pkg/api/openapi/README.md b/components/api-server/pkg/api/openapi/README.md index 7a8f5d1a..68aab1b1 100644 --- a/components/api-server/pkg/api/openapi/README.md +++ b/components/api-server/pkg/api/openapi/README.md @@ -101,6 +101,7 @@ Class | Method | HTTP request | Description *DefaultAPI* | [**GetMetadata**](docs/DefaultAPI.md#getmetadata) | **Get** /api/hypershell/v1/metadata | Service metadata *DefaultAPI* | [**GetRole**](docs/DefaultAPI.md#getrole) | **Get** /api/hypershell/v1/roles/{id} | Get a role by ID *DefaultAPI* | [**GetRoleBinding**](docs/DefaultAPI.md#getrolebinding) | **Get** /api/hypershell/v1/role_bindings/{id} | Get a role binding by ID +*DefaultAPI* | [**GetUser**](docs/DefaultAPI.md#getuser) | **Get** /api/hypershell/v1/users/{id} | Get a registered user by ID *DefaultAPI* | [**ListGatewayNetworks**](docs/DefaultAPI.md#listgatewaynetworks) | **Get** /api/hypershell/v1/gateway_networks | Returns a list of gatewayNetworks *DefaultAPI* | [**ListGatewayReleases**](docs/DefaultAPI.md#listgatewayreleases) | **Get** /api/hypershell/v1/gateway_releases | Returns a list of gatewayReleases *DefaultAPI* | [**ListGatewayServiceAccounts**](docs/DefaultAPI.md#listgatewayserviceaccounts) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | List OpenShell gateway service accounts @@ -109,6 +110,7 @@ Class | Method | HTTP request | Description *DefaultAPI* | [**ListManagedDatabases**](docs/DefaultAPI.md#listmanageddatabases) | **Get** /api/hypershell/v1/managed_databases | Returns a list of managedDatabases *DefaultAPI* | [**ListRoleBindings**](docs/DefaultAPI.md#listrolebindings) | **Get** /api/hypershell/v1/role_bindings | List role bindings *DefaultAPI* | [**ListRoles**](docs/DefaultAPI.md#listroles) | **Get** /api/hypershell/v1/roles | List all roles +*DefaultAPI* | [**ListUsers**](docs/DefaultAPI.md#listusers) | **Get** /api/hypershell/v1/users | List registered users *DefaultAPI* | [**RevokeGatewayServiceAccount**](docs/DefaultAPI.md#revokegatewayserviceaccount) | **Post** /api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id}/revoke | Permanently revoke an OpenShell gateway service account *DefaultAPI* | [**UpdateGateway**](docs/DefaultAPI.md#updategateway) | **Patch** /api/hypershell/v1/gateways/{id} | Update an gateway *DefaultAPI* | [**UpdateGatewayNetwork**](docs/DefaultAPI.md#updategatewaynetwork) | **Patch** /api/hypershell/v1/gateway_networks/{id} | Update an gatewayNetwork @@ -153,6 +155,8 @@ Class | Method | HTTP request | Description - [RoleBinding](docs/RoleBinding.md) - [RoleBindingList](docs/RoleBindingList.md) - [RoleList](docs/RoleList.md) + - [User](docs/User.md) + - [UserList](docs/UserList.md) ## Documentation For Authorization diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index 65e687da..2bcef4cd 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -1973,6 +1973,107 @@ paths: $ref: "#/components/schemas/Error" description: Unexpected error occurred summary: Get a role binding by ID + /api/hypershell/v1/users: + get: + operationId: listUsers + parameters: + - description: Page number of record list when record list exceeds specified + page size + explode: true + in: query + name: page + required: false + schema: + default: 1 + minimum: 1 + type: integer + style: form + - description: Maximum number of records to return + explode: true + in: query + name: size + required: false + schema: + default: 100 + minimum: 0 + type: integer + style: form + - description: Specifies the search criteria + explode: true + in: query + name: search + required: false + schema: + type: string + style: form + - description: Specifies the order by criteria + explode: true + in: query + name: orderBy + required: false + schema: + type: string + style: form + - description: Supplies a comma-separated list of fields to be returned + explode: true + in: query + name: fields + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UserList" + description: A list of registered users + "401": + description: Access token is missing or invalid + "403": + description: Access token does not have sufficient privileges + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unexpected error occurred + summary: List registered users + /api/hypershell/v1/users/{id}: + get: + operationId: getUser + parameters: + - description: The id of record + explode: false + in: path + name: id + required: true + schema: + type: string + style: simple + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/User" + description: User found + "401": + description: Access token is missing or invalid + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: User not found + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unexpected error occurred + summary: Get a registered user by ID components: parameters: id: @@ -3332,6 +3433,63 @@ components: id: id href: href gateway_id: gateway_id + User: + allOf: + - $ref: "#/components/schemas/ObjectReference" + - properties: + username: + type: string + email: + type: string + name: + type: string + required: + - username + type: object + example: + updated_at: 2000-01-23T04:56:07.000+00:00 + kind: kind + name: name + created_at: 2000-01-23T04:56:07.000+00:00 + id: id + href: href + email: email + username: username + UserList: + allOf: + - $ref: "#/components/schemas/List" + - properties: + items: + items: + $ref: "#/components/schemas/User" + type: array + type: object + example: + total: 1 + size: 6 + updated_at: 2000-01-23T04:56:07.000+00:00 + kind: kind + created_at: 2000-01-23T04:56:07.000+00:00 + page: 0 + id: id + href: href + items: + - updated_at: 2000-01-23T04:56:07.000+00:00 + kind: kind + name: name + created_at: 2000-01-23T04:56:07.000+00:00 + id: id + href: href + email: email + username: username + - updated_at: 2000-01-23T04:56:07.000+00:00 + kind: kind + name: name + created_at: 2000-01-23T04:56:07.000+00:00 + id: id + href: href + email: email + username: username GatewayCreateRequest: example: phase: phase diff --git a/components/api-server/pkg/api/openapi/api_default.go b/components/api-server/pkg/api/openapi/api_default.go index 7621d56d..9bdc5215 100644 --- a/components/api-server/pkg/api/openapi/api_default.go +++ b/components/api-server/pkg/api/openapi/api_default.go @@ -3261,6 +3261,129 @@ func (a *DefaultAPIService) GetRoleBindingExecute(r ApiGetRoleBindingRequest) (* return localVarReturnValue, localVarHTTPResponse, nil } +type ApiGetUserRequest struct { + ctx context.Context + ApiService *DefaultAPIService + id string +} + +func (r ApiGetUserRequest) Execute() (*User, *http.Response, error) { + return r.ApiService.GetUserExecute(r) +} + +/* +GetUser Get a registered user by ID + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param id The id of record + @return ApiGetUserRequest +*/ +func (a *DefaultAPIService) GetUser(ctx context.Context, id string) ApiGetUserRequest { + return ApiGetUserRequest{ + ApiService: a, + ctx: ctx, + id: id, + } +} + +// Execute executes the request +// +// @return User +func (a *DefaultAPIService) GetUserExecute(r ApiGetUserRequest) (*User, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *User + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.GetUser") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/v1/users/{id}" + localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", url.PathEscape(parameterValueToString(r.id, "id")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 404 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiListGatewayNetworksRequest struct { ctx context.Context ApiService *DefaultAPIService @@ -4722,6 +4845,170 @@ func (a *DefaultAPIService) ListRolesExecute(r ApiListRolesRequest) (*RoleList, return localVarReturnValue, localVarHTTPResponse, nil } +type ApiListUsersRequest struct { + ctx context.Context + ApiService *DefaultAPIService + page *int32 + size *int32 + search *string + orderBy *string + fields *string +} + +// Page number of record list when record list exceeds specified page size +func (r ApiListUsersRequest) Page(page int32) ApiListUsersRequest { + r.page = &page + return r +} + +// Maximum number of records to return +func (r ApiListUsersRequest) Size(size int32) ApiListUsersRequest { + r.size = &size + return r +} + +// Specifies the search criteria +func (r ApiListUsersRequest) Search(search string) ApiListUsersRequest { + r.search = &search + return r +} + +// Specifies the order by criteria +func (r ApiListUsersRequest) OrderBy(orderBy string) ApiListUsersRequest { + r.orderBy = &orderBy + return r +} + +// Supplies a comma-separated list of fields to be returned +func (r ApiListUsersRequest) Fields(fields string) ApiListUsersRequest { + r.fields = &fields + return r +} + +func (r ApiListUsersRequest) Execute() (*UserList, *http.Response, error) { + return r.ApiService.ListUsersExecute(r) +} + +/* +ListUsers List registered users + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiListUsersRequest +*/ +func (a *DefaultAPIService) ListUsers(ctx context.Context) ApiListUsersRequest { + return ApiListUsersRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return UserList +func (a *DefaultAPIService) ListUsersExecute(r ApiListUsersRequest) (*UserList, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *UserList + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.ListUsers") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/v1/users" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.page != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "page", r.page, "form", "") + } else { + var defaultValue int32 = 1 + r.page = &defaultValue + } + if r.size != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "size", r.size, "form", "") + } else { + var defaultValue int32 = 100 + r.size = &defaultValue + } + if r.search != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "search", r.search, "form", "") + } + if r.orderBy != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderBy", r.orderBy, "form", "") + } + if r.fields != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "fields", r.fields, "form", "") + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 500 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiRevokeGatewayServiceAccountRequest struct { ctx context.Context ApiService *DefaultAPIService diff --git a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md index 2a96f7a7..31fac40b 100644 --- a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md +++ b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md @@ -27,6 +27,7 @@ Method | HTTP request | Description [**GetMetadata**](DefaultAPI.md#GetMetadata) | **Get** /api/hypershell/v1/metadata | Service metadata [**GetRole**](DefaultAPI.md#GetRole) | **Get** /api/hypershell/v1/roles/{id} | Get a role by ID [**GetRoleBinding**](DefaultAPI.md#GetRoleBinding) | **Get** /api/hypershell/v1/role_bindings/{id} | Get a role binding by ID +[**GetUser**](DefaultAPI.md#GetUser) | **Get** /api/hypershell/v1/users/{id} | Get a registered user by ID [**ListGatewayNetworks**](DefaultAPI.md#ListGatewayNetworks) | **Get** /api/hypershell/v1/gateway_networks | Returns a list of gatewayNetworks [**ListGatewayReleases**](DefaultAPI.md#ListGatewayReleases) | **Get** /api/hypershell/v1/gateway_releases | Returns a list of gatewayReleases [**ListGatewayServiceAccounts**](DefaultAPI.md#ListGatewayServiceAccounts) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | List OpenShell gateway service accounts @@ -35,6 +36,7 @@ Method | HTTP request | Description [**ListManagedDatabases**](DefaultAPI.md#ListManagedDatabases) | **Get** /api/hypershell/v1/managed_databases | Returns a list of managedDatabases [**ListRoleBindings**](DefaultAPI.md#ListRoleBindings) | **Get** /api/hypershell/v1/role_bindings | List role bindings [**ListRoles**](DefaultAPI.md#ListRoles) | **Get** /api/hypershell/v1/roles | List all roles +[**ListUsers**](DefaultAPI.md#ListUsers) | **Get** /api/hypershell/v1/users | List registered users [**RevokeGatewayServiceAccount**](DefaultAPI.md#RevokeGatewayServiceAccount) | **Post** /api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id}/revoke | Permanently revoke an OpenShell gateway service account [**UpdateGateway**](DefaultAPI.md#UpdateGateway) | **Patch** /api/hypershell/v1/gateways/{id} | Update an gateway [**UpdateGatewayNetwork**](DefaultAPI.md#UpdateGatewayNetwork) | **Patch** /api/hypershell/v1/gateway_networks/{id} | Update an gatewayNetwork @@ -1571,6 +1573,74 @@ Name | Type | Description | Notes [[Back to README]](../README.md) +## GetUser + +> User GetUser(ctx, id).Execute() + +Get a registered user by ID + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "github.com/GIT_USER_ID/GIT_REPO_ID" +) + +func main() { + id := "id_example" // string | The id of record + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.DefaultAPI.GetUser(context.Background(), id).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.GetUser``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `GetUser`: User + fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.GetUser`: %v\n", resp) +} +``` + +### Path Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc. +**id** | **string** | The id of record | + +### Other Parameters + +Other parameters are passed through a pointer to a apiGetUserRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + + +### Return type + +[**User**](User.md) + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## ListGatewayNetworks > GatewayNetworkList ListGatewayNetworks(ctx).Page(page).Size(size).Search(search).OrderBy(orderBy).Fields(fields).Execute() @@ -2155,6 +2225,78 @@ Name | Type | Description | Notes [[Back to README]](../README.md) +## ListUsers + +> UserList ListUsers(ctx).Page(page).Size(size).Search(search).OrderBy(orderBy).Fields(fields).Execute() + +List registered users + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "github.com/GIT_USER_ID/GIT_REPO_ID" +) + +func main() { + page := int32(56) // int32 | Page number of record list when record list exceeds specified page size (optional) (default to 1) + size := int32(56) // int32 | Maximum number of records to return (optional) (default to 100) + search := "search_example" // string | Specifies the search criteria (optional) + orderBy := "orderBy_example" // string | Specifies the order by criteria (optional) + fields := "fields_example" // string | Supplies a comma-separated list of fields to be returned (optional) + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.DefaultAPI.ListUsers(context.Background()).Page(page).Size(size).Search(search).OrderBy(orderBy).Fields(fields).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.ListUsers``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `ListUsers`: UserList + fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.ListUsers`: %v\n", resp) +} +``` + +### Path Parameters + + + +### Other Parameters + +Other parameters are passed through a pointer to a apiListUsersRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **page** | **int32** | Page number of record list when record list exceeds specified page size | [default to 1] + **size** | **int32** | Maximum number of records to return | [default to 100] + **search** | **string** | Specifies the search criteria | + **orderBy** | **string** | Specifies the order by criteria | + **fields** | **string** | Supplies a comma-separated list of fields to be returned | + +### Return type + +[**UserList**](UserList.md) + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## RevokeGatewayServiceAccount > OpenShellGatewayServiceAccountListItem RevokeGatewayServiceAccount(ctx, gatewayId, serviceAccountId).Execute() diff --git a/components/api-server/pkg/api/openapi/docs/User.md b/components/api-server/pkg/api/openapi/docs/User.md new file mode 100644 index 00000000..b2e226d1 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/User.md @@ -0,0 +1,233 @@ +# User + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Id** | Pointer to **string** | | [optional] +**Kind** | Pointer to **string** | | [optional] +**Href** | Pointer to **string** | | [optional] +**CreatedAt** | Pointer to **time.Time** | | [optional] +**UpdatedAt** | Pointer to **time.Time** | | [optional] +**Username** | **string** | | +**Email** | Pointer to **string** | | [optional] +**Name** | Pointer to **string** | | [optional] + +## Methods + +### NewUser + +`func NewUser(username string, ) *User` + +NewUser instantiates a new User object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewUserWithDefaults + +`func NewUserWithDefaults() *User` + +NewUserWithDefaults instantiates a new User object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetId + +`func (o *User) GetId() string` + +GetId returns the Id field if non-nil, zero value otherwise. + +### GetIdOk + +`func (o *User) GetIdOk() (*string, bool)` + +GetIdOk returns a tuple with the Id field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetId + +`func (o *User) SetId(v string)` + +SetId sets Id field to given value. + +### HasId + +`func (o *User) HasId() bool` + +HasId returns a boolean if a field has been set. + +### GetKind + +`func (o *User) GetKind() string` + +GetKind returns the Kind field if non-nil, zero value otherwise. + +### GetKindOk + +`func (o *User) GetKindOk() (*string, bool)` + +GetKindOk returns a tuple with the Kind field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetKind + +`func (o *User) SetKind(v string)` + +SetKind sets Kind field to given value. + +### HasKind + +`func (o *User) HasKind() bool` + +HasKind returns a boolean if a field has been set. + +### GetHref + +`func (o *User) GetHref() string` + +GetHref returns the Href field if non-nil, zero value otherwise. + +### GetHrefOk + +`func (o *User) GetHrefOk() (*string, bool)` + +GetHrefOk returns a tuple with the Href field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetHref + +`func (o *User) SetHref(v string)` + +SetHref sets Href field to given value. + +### HasHref + +`func (o *User) HasHref() bool` + +HasHref returns a boolean if a field has been set. + +### GetCreatedAt + +`func (o *User) GetCreatedAt() time.Time` + +GetCreatedAt returns the CreatedAt field if non-nil, zero value otherwise. + +### GetCreatedAtOk + +`func (o *User) GetCreatedAtOk() (*time.Time, bool)` + +GetCreatedAtOk returns a tuple with the CreatedAt field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetCreatedAt + +`func (o *User) SetCreatedAt(v time.Time)` + +SetCreatedAt sets CreatedAt field to given value. + +### HasCreatedAt + +`func (o *User) HasCreatedAt() bool` + +HasCreatedAt returns a boolean if a field has been set. + +### GetUpdatedAt + +`func (o *User) GetUpdatedAt() time.Time` + +GetUpdatedAt returns the UpdatedAt field if non-nil, zero value otherwise. + +### GetUpdatedAtOk + +`func (o *User) GetUpdatedAtOk() (*time.Time, bool)` + +GetUpdatedAtOk returns a tuple with the UpdatedAt field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUpdatedAt + +`func (o *User) SetUpdatedAt(v time.Time)` + +SetUpdatedAt sets UpdatedAt field to given value. + +### HasUpdatedAt + +`func (o *User) HasUpdatedAt() bool` + +HasUpdatedAt returns a boolean if a field has been set. + +### GetUsername + +`func (o *User) GetUsername() string` + +GetUsername returns the Username field if non-nil, zero value otherwise. + +### GetUsernameOk + +`func (o *User) GetUsernameOk() (*string, bool)` + +GetUsernameOk returns a tuple with the Username field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUsername + +`func (o *User) SetUsername(v string)` + +SetUsername sets Username field to given value. + + +### GetEmail + +`func (o *User) GetEmail() string` + +GetEmail returns the Email field if non-nil, zero value otherwise. + +### GetEmailOk + +`func (o *User) GetEmailOk() (*string, bool)` + +GetEmailOk returns a tuple with the Email field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetEmail + +`func (o *User) SetEmail(v string)` + +SetEmail sets Email field to given value. + +### HasEmail + +`func (o *User) HasEmail() bool` + +HasEmail returns a boolean if a field has been set. + +### GetName + +`func (o *User) GetName() string` + +GetName returns the Name field if non-nil, zero value otherwise. + +### GetNameOk + +`func (o *User) GetNameOk() (*string, bool)` + +GetNameOk returns a tuple with the Name field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetName + +`func (o *User) SetName(v string)` + +SetName sets Name field to given value. + +### HasName + +`func (o *User) HasName() bool` + +HasName returns a boolean if a field has been set. + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/UserList.md b/components/api-server/pkg/api/openapi/docs/UserList.md new file mode 100644 index 00000000..0bc5a1e8 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/UserList.md @@ -0,0 +1,264 @@ +# UserList + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Kind** | Pointer to **string** | | [optional] +**Page** | Pointer to **int32** | | [optional] +**Size** | Pointer to **int32** | | [optional] +**Total** | Pointer to **int32** | | [optional] +**Id** | Pointer to **string** | | [optional] +**Href** | Pointer to **string** | | [optional] +**CreatedAt** | Pointer to **time.Time** | | [optional] +**UpdatedAt** | Pointer to **time.Time** | | [optional] +**Items** | Pointer to [**[]User**](User.md) | | [optional] + +## Methods + +### NewUserList + +`func NewUserList() *UserList` + +NewUserList instantiates a new UserList object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewUserListWithDefaults + +`func NewUserListWithDefaults() *UserList` + +NewUserListWithDefaults instantiates a new UserList object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetKind + +`func (o *UserList) GetKind() string` + +GetKind returns the Kind field if non-nil, zero value otherwise. + +### GetKindOk + +`func (o *UserList) GetKindOk() (*string, bool)` + +GetKindOk returns a tuple with the Kind field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetKind + +`func (o *UserList) SetKind(v string)` + +SetKind sets Kind field to given value. + +### HasKind + +`func (o *UserList) HasKind() bool` + +HasKind returns a boolean if a field has been set. + +### GetPage + +`func (o *UserList) GetPage() int32` + +GetPage returns the Page field if non-nil, zero value otherwise. + +### GetPageOk + +`func (o *UserList) GetPageOk() (*int32, bool)` + +GetPageOk returns a tuple with the Page field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetPage + +`func (o *UserList) SetPage(v int32)` + +SetPage sets Page field to given value. + +### HasPage + +`func (o *UserList) HasPage() bool` + +HasPage returns a boolean if a field has been set. + +### GetSize + +`func (o *UserList) GetSize() int32` + +GetSize returns the Size field if non-nil, zero value otherwise. + +### GetSizeOk + +`func (o *UserList) GetSizeOk() (*int32, bool)` + +GetSizeOk returns a tuple with the Size field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetSize + +`func (o *UserList) SetSize(v int32)` + +SetSize sets Size field to given value. + +### HasSize + +`func (o *UserList) HasSize() bool` + +HasSize returns a boolean if a field has been set. + +### GetTotal + +`func (o *UserList) GetTotal() int32` + +GetTotal returns the Total field if non-nil, zero value otherwise. + +### GetTotalOk + +`func (o *UserList) GetTotalOk() (*int32, bool)` + +GetTotalOk returns a tuple with the Total field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetTotal + +`func (o *UserList) SetTotal(v int32)` + +SetTotal sets Total field to given value. + +### HasTotal + +`func (o *UserList) HasTotal() bool` + +HasTotal returns a boolean if a field has been set. + +### GetId + +`func (o *UserList) GetId() string` + +GetId returns the Id field if non-nil, zero value otherwise. + +### GetIdOk + +`func (o *UserList) GetIdOk() (*string, bool)` + +GetIdOk returns a tuple with the Id field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetId + +`func (o *UserList) SetId(v string)` + +SetId sets Id field to given value. + +### HasId + +`func (o *UserList) HasId() bool` + +HasId returns a boolean if a field has been set. + +### GetHref + +`func (o *UserList) GetHref() string` + +GetHref returns the Href field if non-nil, zero value otherwise. + +### GetHrefOk + +`func (o *UserList) GetHrefOk() (*string, bool)` + +GetHrefOk returns a tuple with the Href field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetHref + +`func (o *UserList) SetHref(v string)` + +SetHref sets Href field to given value. + +### HasHref + +`func (o *UserList) HasHref() bool` + +HasHref returns a boolean if a field has been set. + +### GetCreatedAt + +`func (o *UserList) GetCreatedAt() time.Time` + +GetCreatedAt returns the CreatedAt field if non-nil, zero value otherwise. + +### GetCreatedAtOk + +`func (o *UserList) GetCreatedAtOk() (*time.Time, bool)` + +GetCreatedAtOk returns a tuple with the CreatedAt field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetCreatedAt + +`func (o *UserList) SetCreatedAt(v time.Time)` + +SetCreatedAt sets CreatedAt field to given value. + +### HasCreatedAt + +`func (o *UserList) HasCreatedAt() bool` + +HasCreatedAt returns a boolean if a field has been set. + +### GetUpdatedAt + +`func (o *UserList) GetUpdatedAt() time.Time` + +GetUpdatedAt returns the UpdatedAt field if non-nil, zero value otherwise. + +### GetUpdatedAtOk + +`func (o *UserList) GetUpdatedAtOk() (*time.Time, bool)` + +GetUpdatedAtOk returns a tuple with the UpdatedAt field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUpdatedAt + +`func (o *UserList) SetUpdatedAt(v time.Time)` + +SetUpdatedAt sets UpdatedAt field to given value. + +### HasUpdatedAt + +`func (o *UserList) HasUpdatedAt() bool` + +HasUpdatedAt returns a boolean if a field has been set. + +### GetItems + +`func (o *UserList) GetItems() []User` + +GetItems returns the Items field if non-nil, zero value otherwise. + +### GetItemsOk + +`func (o *UserList) GetItemsOk() (*[]User, bool)` + +GetItemsOk returns a tuple with the Items field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetItems + +`func (o *UserList) SetItems(v []User)` + +SetItems sets Items field to given value. + +### HasItems + +`func (o *UserList) HasItems() bool` + +HasItems returns a boolean if a field has been set. + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/model_user.go b/components/api-server/pkg/api/openapi/model_user.go new file mode 100644 index 00000000..4b4c4185 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_user.go @@ -0,0 +1,409 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// checks if the User type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &User{} + +// User struct for User +type User struct { + Id *string `json:"id,omitempty"` + Kind *string `json:"kind,omitempty"` + Href *string `json:"href,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Username string `json:"username"` + Email *string `json:"email,omitempty"` + Name *string `json:"name,omitempty"` +} + +type _User User + +// NewUser instantiates a new User object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUser(username string) *User { + this := User{} + this.Username = username + return &this +} + +// NewUserWithDefaults instantiates a new User object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUserWithDefaults() *User { + this := User{} + return &this +} + +// GetId returns the Id field value if set, zero value otherwise. +func (o *User) GetId() string { + if o == nil || IsNil(o.Id) { + var ret string + return ret + } + return *o.Id +} + +// GetIdOk returns a tuple with the Id field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *User) GetIdOk() (*string, bool) { + if o == nil || IsNil(o.Id) { + return nil, false + } + return o.Id, true +} + +// HasId returns a boolean if a field has been set. +func (o *User) HasId() bool { + if o != nil && !IsNil(o.Id) { + return true + } + + return false +} + +// SetId gets a reference to the given string and assigns it to the Id field. +func (o *User) SetId(v string) { + o.Id = &v +} + +// GetKind returns the Kind field value if set, zero value otherwise. +func (o *User) GetKind() string { + if o == nil || IsNil(o.Kind) { + var ret string + return ret + } + return *o.Kind +} + +// GetKindOk returns a tuple with the Kind field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *User) GetKindOk() (*string, bool) { + if o == nil || IsNil(o.Kind) { + return nil, false + } + return o.Kind, true +} + +// HasKind returns a boolean if a field has been set. +func (o *User) HasKind() bool { + if o != nil && !IsNil(o.Kind) { + return true + } + + return false +} + +// SetKind gets a reference to the given string and assigns it to the Kind field. +func (o *User) SetKind(v string) { + o.Kind = &v +} + +// GetHref returns the Href field value if set, zero value otherwise. +func (o *User) GetHref() string { + if o == nil || IsNil(o.Href) { + var ret string + return ret + } + return *o.Href +} + +// GetHrefOk returns a tuple with the Href field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *User) GetHrefOk() (*string, bool) { + if o == nil || IsNil(o.Href) { + return nil, false + } + return o.Href, true +} + +// HasHref returns a boolean if a field has been set. +func (o *User) HasHref() bool { + if o != nil && !IsNil(o.Href) { + return true + } + + return false +} + +// SetHref gets a reference to the given string and assigns it to the Href field. +func (o *User) SetHref(v string) { + o.Href = &v +} + +// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. +func (o *User) GetCreatedAt() time.Time { + if o == nil || IsNil(o.CreatedAt) { + var ret time.Time + return ret + } + return *o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *User) GetCreatedAtOk() (*time.Time, bool) { + if o == nil || IsNil(o.CreatedAt) { + return nil, false + } + return o.CreatedAt, true +} + +// HasCreatedAt returns a boolean if a field has been set. +func (o *User) HasCreatedAt() bool { + if o != nil && !IsNil(o.CreatedAt) { + return true + } + + return false +} + +// SetCreatedAt gets a reference to the given time.Time and assigns it to the CreatedAt field. +func (o *User) SetCreatedAt(v time.Time) { + o.CreatedAt = &v +} + +// GetUpdatedAt returns the UpdatedAt field value if set, zero value otherwise. +func (o *User) GetUpdatedAt() time.Time { + if o == nil || IsNil(o.UpdatedAt) { + var ret time.Time + return ret + } + return *o.UpdatedAt +} + +// GetUpdatedAtOk returns a tuple with the UpdatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *User) GetUpdatedAtOk() (*time.Time, bool) { + if o == nil || IsNil(o.UpdatedAt) { + return nil, false + } + return o.UpdatedAt, true +} + +// HasUpdatedAt returns a boolean if a field has been set. +func (o *User) HasUpdatedAt() bool { + if o != nil && !IsNil(o.UpdatedAt) { + return true + } + + return false +} + +// SetUpdatedAt gets a reference to the given time.Time and assigns it to the UpdatedAt field. +func (o *User) SetUpdatedAt(v time.Time) { + o.UpdatedAt = &v +} + +// GetUsername returns the Username field value +func (o *User) GetUsername() string { + if o == nil { + var ret string + return ret + } + + return o.Username +} + +// GetUsernameOk returns a tuple with the Username field value +// and a boolean to check if the value has been set. +func (o *User) GetUsernameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Username, true +} + +// SetUsername sets field value +func (o *User) SetUsername(v string) { + o.Username = v +} + +// GetEmail returns the Email field value if set, zero value otherwise. +func (o *User) GetEmail() string { + if o == nil || IsNil(o.Email) { + var ret string + return ret + } + return *o.Email +} + +// GetEmailOk returns a tuple with the Email field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *User) GetEmailOk() (*string, bool) { + if o == nil || IsNil(o.Email) { + return nil, false + } + return o.Email, true +} + +// HasEmail returns a boolean if a field has been set. +func (o *User) HasEmail() bool { + if o != nil && !IsNil(o.Email) { + return true + } + + return false +} + +// SetEmail gets a reference to the given string and assigns it to the Email field. +func (o *User) SetEmail(v string) { + o.Email = &v +} + +// GetName returns the Name field value if set, zero value otherwise. +func (o *User) GetName() string { + if o == nil || IsNil(o.Name) { + var ret string + return ret + } + return *o.Name +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *User) GetNameOk() (*string, bool) { + if o == nil || IsNil(o.Name) { + return nil, false + } + return o.Name, true +} + +// HasName returns a boolean if a field has been set. +func (o *User) HasName() bool { + if o != nil && !IsNil(o.Name) { + return true + } + + return false +} + +// SetName gets a reference to the given string and assigns it to the Name field. +func (o *User) SetName(v string) { + o.Name = &v +} + +func (o User) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o User) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Id) { + toSerialize["id"] = o.Id + } + if !IsNil(o.Kind) { + toSerialize["kind"] = o.Kind + } + if !IsNil(o.Href) { + toSerialize["href"] = o.Href + } + if !IsNil(o.CreatedAt) { + toSerialize["created_at"] = o.CreatedAt + } + if !IsNil(o.UpdatedAt) { + toSerialize["updated_at"] = o.UpdatedAt + } + toSerialize["username"] = o.Username + if !IsNil(o.Email) { + toSerialize["email"] = o.Email + } + if !IsNil(o.Name) { + toSerialize["name"] = o.Name + } + return toSerialize, nil +} + +func (o *User) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "username", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varUser := _User{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varUser) + + if err != nil { + return err + } + + *o = User(varUser) + + return err +} + +type NullableUser struct { + value *User + isSet bool +} + +func (v NullableUser) Get() *User { + return v.value +} + +func (v *NullableUser) Set(val *User) { + v.value = val + v.isSet = true +} + +func (v NullableUser) IsSet() bool { + return v.isSet +} + +func (v *NullableUser) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUser(val *User) *NullableUser { + return &NullableUser{value: val, isSet: true} +} + +func (v NullableUser) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUser) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_user_list.go b/components/api-server/pkg/api/openapi/model_user_list.go new file mode 100644 index 00000000..6d2195f4 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_user_list.go @@ -0,0 +1,413 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "encoding/json" + "time" +) + +// checks if the UserList type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UserList{} + +// UserList struct for UserList +type UserList struct { + Kind *string `json:"kind,omitempty"` + Page *int32 `json:"page,omitempty"` + Size *int32 `json:"size,omitempty"` + Total *int32 `json:"total,omitempty"` + Id *string `json:"id,omitempty"` + Href *string `json:"href,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Items []User `json:"items,omitempty"` +} + +// NewUserList instantiates a new UserList object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUserList() *UserList { + this := UserList{} + return &this +} + +// NewUserListWithDefaults instantiates a new UserList object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUserListWithDefaults() *UserList { + this := UserList{} + return &this +} + +// GetKind returns the Kind field value if set, zero value otherwise. +func (o *UserList) GetKind() string { + if o == nil || IsNil(o.Kind) { + var ret string + return ret + } + return *o.Kind +} + +// GetKindOk returns a tuple with the Kind field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserList) GetKindOk() (*string, bool) { + if o == nil || IsNil(o.Kind) { + return nil, false + } + return o.Kind, true +} + +// HasKind returns a boolean if a field has been set. +func (o *UserList) HasKind() bool { + if o != nil && !IsNil(o.Kind) { + return true + } + + return false +} + +// SetKind gets a reference to the given string and assigns it to the Kind field. +func (o *UserList) SetKind(v string) { + o.Kind = &v +} + +// GetPage returns the Page field value if set, zero value otherwise. +func (o *UserList) GetPage() int32 { + if o == nil || IsNil(o.Page) { + var ret int32 + return ret + } + return *o.Page +} + +// GetPageOk returns a tuple with the Page field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserList) GetPageOk() (*int32, bool) { + if o == nil || IsNil(o.Page) { + return nil, false + } + return o.Page, true +} + +// HasPage returns a boolean if a field has been set. +func (o *UserList) HasPage() bool { + if o != nil && !IsNil(o.Page) { + return true + } + + return false +} + +// SetPage gets a reference to the given int32 and assigns it to the Page field. +func (o *UserList) SetPage(v int32) { + o.Page = &v +} + +// GetSize returns the Size field value if set, zero value otherwise. +func (o *UserList) GetSize() int32 { + if o == nil || IsNil(o.Size) { + var ret int32 + return ret + } + return *o.Size +} + +// GetSizeOk returns a tuple with the Size field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserList) GetSizeOk() (*int32, bool) { + if o == nil || IsNil(o.Size) { + return nil, false + } + return o.Size, true +} + +// HasSize returns a boolean if a field has been set. +func (o *UserList) HasSize() bool { + if o != nil && !IsNil(o.Size) { + return true + } + + return false +} + +// SetSize gets a reference to the given int32 and assigns it to the Size field. +func (o *UserList) SetSize(v int32) { + o.Size = &v +} + +// GetTotal returns the Total field value if set, zero value otherwise. +func (o *UserList) GetTotal() int32 { + if o == nil || IsNil(o.Total) { + var ret int32 + return ret + } + return *o.Total +} + +// GetTotalOk returns a tuple with the Total field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserList) GetTotalOk() (*int32, bool) { + if o == nil || IsNil(o.Total) { + return nil, false + } + return o.Total, true +} + +// HasTotal returns a boolean if a field has been set. +func (o *UserList) HasTotal() bool { + if o != nil && !IsNil(o.Total) { + return true + } + + return false +} + +// SetTotal gets a reference to the given int32 and assigns it to the Total field. +func (o *UserList) SetTotal(v int32) { + o.Total = &v +} + +// GetId returns the Id field value if set, zero value otherwise. +func (o *UserList) GetId() string { + if o == nil || IsNil(o.Id) { + var ret string + return ret + } + return *o.Id +} + +// GetIdOk returns a tuple with the Id field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserList) GetIdOk() (*string, bool) { + if o == nil || IsNil(o.Id) { + return nil, false + } + return o.Id, true +} + +// HasId returns a boolean if a field has been set. +func (o *UserList) HasId() bool { + if o != nil && !IsNil(o.Id) { + return true + } + + return false +} + +// SetId gets a reference to the given string and assigns it to the Id field. +func (o *UserList) SetId(v string) { + o.Id = &v +} + +// GetHref returns the Href field value if set, zero value otherwise. +func (o *UserList) GetHref() string { + if o == nil || IsNil(o.Href) { + var ret string + return ret + } + return *o.Href +} + +// GetHrefOk returns a tuple with the Href field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserList) GetHrefOk() (*string, bool) { + if o == nil || IsNil(o.Href) { + return nil, false + } + return o.Href, true +} + +// HasHref returns a boolean if a field has been set. +func (o *UserList) HasHref() bool { + if o != nil && !IsNil(o.Href) { + return true + } + + return false +} + +// SetHref gets a reference to the given string and assigns it to the Href field. +func (o *UserList) SetHref(v string) { + o.Href = &v +} + +// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. +func (o *UserList) GetCreatedAt() time.Time { + if o == nil || IsNil(o.CreatedAt) { + var ret time.Time + return ret + } + return *o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserList) GetCreatedAtOk() (*time.Time, bool) { + if o == nil || IsNil(o.CreatedAt) { + return nil, false + } + return o.CreatedAt, true +} + +// HasCreatedAt returns a boolean if a field has been set. +func (o *UserList) HasCreatedAt() bool { + if o != nil && !IsNil(o.CreatedAt) { + return true + } + + return false +} + +// SetCreatedAt gets a reference to the given time.Time and assigns it to the CreatedAt field. +func (o *UserList) SetCreatedAt(v time.Time) { + o.CreatedAt = &v +} + +// GetUpdatedAt returns the UpdatedAt field value if set, zero value otherwise. +func (o *UserList) GetUpdatedAt() time.Time { + if o == nil || IsNil(o.UpdatedAt) { + var ret time.Time + return ret + } + return *o.UpdatedAt +} + +// GetUpdatedAtOk returns a tuple with the UpdatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserList) GetUpdatedAtOk() (*time.Time, bool) { + if o == nil || IsNil(o.UpdatedAt) { + return nil, false + } + return o.UpdatedAt, true +} + +// HasUpdatedAt returns a boolean if a field has been set. +func (o *UserList) HasUpdatedAt() bool { + if o != nil && !IsNil(o.UpdatedAt) { + return true + } + + return false +} + +// SetUpdatedAt gets a reference to the given time.Time and assigns it to the UpdatedAt field. +func (o *UserList) SetUpdatedAt(v time.Time) { + o.UpdatedAt = &v +} + +// GetItems returns the Items field value if set, zero value otherwise. +func (o *UserList) GetItems() []User { + if o == nil || IsNil(o.Items) { + var ret []User + return ret + } + return o.Items +} + +// GetItemsOk returns a tuple with the Items field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UserList) GetItemsOk() ([]User, bool) { + if o == nil || IsNil(o.Items) { + return nil, false + } + return o.Items, true +} + +// HasItems returns a boolean if a field has been set. +func (o *UserList) HasItems() bool { + if o != nil && !IsNil(o.Items) { + return true + } + + return false +} + +// SetItems gets a reference to the given []User and assigns it to the Items field. +func (o *UserList) SetItems(v []User) { + o.Items = v +} + +func (o UserList) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UserList) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Kind) { + toSerialize["kind"] = o.Kind + } + if !IsNil(o.Page) { + toSerialize["page"] = o.Page + } + if !IsNil(o.Size) { + toSerialize["size"] = o.Size + } + if !IsNil(o.Total) { + toSerialize["total"] = o.Total + } + if !IsNil(o.Id) { + toSerialize["id"] = o.Id + } + if !IsNil(o.Href) { + toSerialize["href"] = o.Href + } + if !IsNil(o.CreatedAt) { + toSerialize["created_at"] = o.CreatedAt + } + if !IsNil(o.UpdatedAt) { + toSerialize["updated_at"] = o.UpdatedAt + } + if !IsNil(o.Items) { + toSerialize["items"] = o.Items + } + return toSerialize, nil +} + +type NullableUserList struct { + value *UserList + isSet bool +} + +func (v NullableUserList) Get() *UserList { + return v.value +} + +func (v *NullableUserList) Set(val *UserList) { + v.value = val + v.isSet = true +} + +func (v NullableUserList) IsSet() bool { + return v.isSet +} + +func (v *NullableUserList) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUserList(val *UserList) *NullableUserList { + return &NullableUserList{value: val, isSet: true} +} + +func (v NullableUserList) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUserList) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/rbac/authorization.go b/components/api-server/pkg/rbac/authorization.go index cb717e2e..a32444d7 100644 --- a/components/api-server/pkg/rbac/authorization.go +++ b/components/api-server/pkg/rbac/authorization.go @@ -80,8 +80,9 @@ func (m *rbacAuthzMiddleware) AuthorizeApi(next http.Handler) http.Handler { resource, resourceID := extractResourceInfo(r) gatewayID := extractGatewayID(r, resource) + jwtRoles := GetJWTRolesFromContext(r.Context()) - if !isAuthorized(r.Method, resource, resourceID, gatewayID, bindings) { + if !isAuthorized(r.Method, resource, resourceID, gatewayID, bindings, jwtRoles) { if resource == "service_accounts" || (r.Method == http.MethodGet && resourceID != "") { http.Error(w, "Not Found", http.StatusNotFound) } else { @@ -134,7 +135,19 @@ func hasPlatformAdmin(bindings []BindingSummary) bool { return false } +func hasUsersInventoryAccess(bindings []BindingSummary, jwtRoles []string) bool { + return hasPlatformAdmin(bindings) || HasHypershellAdminRole(jwtRoles) +} + func extractResourceInfo(r *http.Request) (resource string, resourceID string) { + resource, resourceID = extractResourceInfoFromRoute(r) + if resource != "" { + return resource, resourceID + } + return extractResourceInfoFromPath(r.URL.Path) +} + +func extractResourceInfoFromRoute(r *http.Request) (resource string, resourceID string) { route := mux.CurrentRoute(r) if route == nil { return "", "" @@ -166,18 +179,71 @@ func extractResourceInfo(r *http.Request) (resource string, resourceID string) { return resource, "" } +func extractResourceInfoFromPath(path string) (resource string, resourceID string) { + const prefix = "/api/hypershell/v1/" + if !strings.HasPrefix(path, prefix) { + return "", "" + } + + remainder := strings.Trim(strings.TrimPrefix(path, prefix), "/") + if remainder == "" { + return "", "" + } + + parts := strings.Split(remainder, "/") + if strings.Contains(remainder, "gateways/") && strings.Contains(remainder, "/service_accounts") { + for i, part := range parts { + if part == "service_accounts" && i+1 < len(parts) { + return "service_accounts", parts[i+1] + } + } + } + + resource = parts[0] + if len(parts) > 1 { + resourceID = parts[1] + } + return resource, resourceID +} + func extractGatewayID(r *http.Request, resource string) string { if resource == "service_accounts" { - return mux.Vars(r)["gateway_id"] + if gatewayID := mux.Vars(r)["gateway_id"]; gatewayID != "" { + return gatewayID + } + _, gatewayID := extractGatewayIDFromPath(r.URL.Path) + return gatewayID } if resource == "gateways" { vars := mux.Vars(r) - return vars["id"] + if gatewayID := vars["id"]; gatewayID != "" { + return gatewayID + } + _, gatewayID := extractGatewayIDFromPath(r.URL.Path) + return gatewayID } return "" } -func isAuthorized(method string, resource string, resourceID string, gatewayID string, bindings []BindingSummary) bool { +func extractGatewayIDFromPath(path string) (resource string, gatewayID string) { + const prefix = "/api/hypershell/v1/" + if !strings.HasPrefix(path, prefix) { + return "", "" + } + + remainder := strings.Trim(strings.TrimPrefix(path, prefix), "/") + parts := strings.Split(remainder, "/") + if len(parts) >= 2 && parts[0] == "gateways" { + return "gateways", parts[1] + } + return "", "" +} + +func isAuthorized(method string, resource string, resourceID string, gatewayID string, bindings []BindingSummary, jwtRoles []string) bool { + if resource == "users" { + return hasUsersInventoryAccess(bindings, jwtRoles) + } + if resource == "gateways" && method == http.MethodPost && resourceID == "" { return hasGatewayCreator(bindings) } diff --git a/components/api-server/pkg/rbac/authorization_test.go b/components/api-server/pkg/rbac/authorization_test.go index 517a7bee..9334ea25 100644 --- a/components/api-server/pkg/rbac/authorization_test.go +++ b/components/api-server/pkg/rbac/authorization_test.go @@ -45,7 +45,7 @@ func TestIsAuthorized_GatewayCreatorCanCreateGateways(t *testing.T) { {RoleName: "gateway:creator", Scope: "global"}, } - if !isAuthorized(http.MethodPost, "gateways", "", "", bindings) { + if !isAuthorized(http.MethodPost, "gateways", "", "", bindings, nil) { t.Error("gateway:creator should be authorized for POST /gateways") } } @@ -55,7 +55,7 @@ func TestIsAuthorized_GatewayCreatorCannotGetGatewayWithoutBinding(t *testing.T) {RoleName: "gateway:creator", Scope: "global"}, } - if isAuthorized(http.MethodGet, "gateways", "gw-1", "gw-1", bindings) { + if isAuthorized(http.MethodGet, "gateways", "gw-1", "gw-1", bindings, nil) { t.Error("gateway:creator without per-gateway binding should not GET a specific gateway") } } @@ -66,7 +66,7 @@ func TestIsAuthorized_GatewayOwnerCanReadOwnGateway(t *testing.T) { {RoleName: "gateway:owner", Scope: "gateway", GatewayID: &gwID}, } - if !isAuthorized(http.MethodGet, "gateways", gwID, gwID, bindings) { + if !isAuthorized(http.MethodGet, "gateways", gwID, gwID, bindings, nil) { t.Error("gateway:owner should be authorized for GET on owned gateway") } } @@ -77,7 +77,7 @@ func TestIsAuthorized_GatewayOwnerCanDeleteOwnGateway(t *testing.T) { {RoleName: "gateway:owner", Scope: "gateway", GatewayID: &gwID}, } - if !isAuthorized(http.MethodDelete, "gateways", gwID, gwID, bindings) { + if !isAuthorized(http.MethodDelete, "gateways", gwID, gwID, bindings, nil) { t.Error("gateway:owner should be authorized for DELETE on owned gateway") } } @@ -89,7 +89,7 @@ func TestIsAuthorized_GatewayOwnerCannotAccessOtherGateway(t *testing.T) { {RoleName: "gateway:owner", Scope: "gateway", GatewayID: &gwA}, } - if isAuthorized(http.MethodGet, "gateways", gwB, gwB, bindings) { + if isAuthorized(http.MethodGet, "gateways", gwB, gwB, bindings, nil) { t.Error("gateway:owner must not access another gateway") } } @@ -100,7 +100,7 @@ func TestIsAuthorized_GatewayViewerCanReadOwnGateway(t *testing.T) { {RoleName: "gateway:viewer", Scope: "gateway", GatewayID: &gwID}, } - if !isAuthorized(http.MethodGet, "gateways", gwID, gwID, bindings) { + if !isAuthorized(http.MethodGet, "gateways", gwID, gwID, bindings, nil) { t.Error("gateway:viewer should be authorized for GET on own gateway") } } @@ -111,10 +111,10 @@ func TestIsAuthorized_GatewayViewerCannotMutateGateway(t *testing.T) { {RoleName: "gateway:viewer", Scope: "gateway", GatewayID: &gwID}, } - if isAuthorized(http.MethodPatch, "gateways", gwID, gwID, bindings) { + if isAuthorized(http.MethodPatch, "gateways", gwID, gwID, bindings, nil) { t.Error("gateway:viewer must not PATCH a gateway") } - if isAuthorized(http.MethodDelete, "gateways", gwID, gwID, bindings) { + if isAuthorized(http.MethodDelete, "gateways", gwID, gwID, bindings, nil) { t.Error("gateway:viewer must not DELETE a gateway") } } @@ -125,7 +125,7 @@ func TestIsAuthorized_NonCreatorCannotCreateGateways(t *testing.T) { {RoleName: "gateway:owner", Scope: "gateway", GatewayID: &gwID}, } - if isAuthorized(http.MethodPost, "gateways", "", "", bindings) { + if isAuthorized(http.MethodPost, "gateways", "", "", bindings, nil) { t.Error("gateway:owner without gateway:creator must not POST /gateways") } } @@ -135,7 +135,7 @@ func TestIsAuthorized_RoleBindingsRequireAnyBinding(t *testing.T) { {RoleName: "gateway:viewer", Scope: "gateway", GatewayID: strPtr("gw-1")}, } - if !isAuthorized(http.MethodGet, "role_bindings", "", "", bindings) { + if !isAuthorized(http.MethodGet, "role_bindings", "", "", bindings, nil) { t.Error("any binding should authorize role_bindings access") } } @@ -143,7 +143,7 @@ func TestIsAuthorized_RoleBindingsRequireAnyBinding(t *testing.T) { func TestIsAuthorized_NoBindingsDenied(t *testing.T) { bindings := []BindingSummary{} - if isAuthorized(http.MethodGet, "gateways", "", "", bindings) { + if isAuthorized(http.MethodGet, "gateways", "", "", bindings, nil) { t.Error("empty bindings must be denied") } } @@ -153,7 +153,7 @@ func TestIsAuthorized_GatewayViewerCannotAccessGatewayReleases(t *testing.T) { {RoleName: "gateway:viewer", Scope: "gateway", GatewayID: strPtr("gw-1")}, } - if isAuthorized(http.MethodGet, "gateway_releases", "", "", bindings) { + if isAuthorized(http.MethodGet, "gateway_releases", "", "", bindings, nil) { t.Error("gateway:viewer must not access gateway_releases") } } @@ -163,7 +163,7 @@ func TestIsAuthorized_GatewayOwnerCannotAccessManagedClusters(t *testing.T) { {RoleName: "gateway:owner", Scope: "gateway", GatewayID: strPtr("gw-1")}, } - if isAuthorized(http.MethodGet, "managed_clusters", "", "", bindings) { + if isAuthorized(http.MethodGet, "managed_clusters", "", "", bindings, nil) { t.Error("gateway:owner must not access managed_clusters without gateway:creator") } } @@ -173,7 +173,7 @@ func TestIsAuthorized_GatewayCreatorCanAccessGatewayReleases(t *testing.T) { {RoleName: "gateway:creator", Scope: "global"}, } - if !isAuthorized(http.MethodGet, "gateway_releases", "", "", bindings) { + if !isAuthorized(http.MethodGet, "gateway_releases", "", "", bindings, nil) { t.Error("gateway:creator should access gateway_releases") } } @@ -188,7 +188,7 @@ func TestIsAuthorized_PlatformAdminCanListAllGateways(t *testing.T) { {RoleName: "platform:admin", Scope: "global"}, } - if !isAuthorized(http.MethodGet, "gateways", "", "", bindings) { + if !isAuthorized(http.MethodGet, "gateways", "", "", bindings, nil) { t.Error("platform:admin should be authorized to list all gateways") } } @@ -199,7 +199,7 @@ func TestIsAuthorized_PlatformAdminCanReadAnyGateway(t *testing.T) { {RoleName: "platform:admin", Scope: "global"}, } - if !isAuthorized(http.MethodGet, "gateways", gwID, gwID, bindings) { + if !isAuthorized(http.MethodGet, "gateways", gwID, gwID, bindings, nil) { t.Error("platform:admin should be authorized to read any gateway") } } @@ -210,7 +210,7 @@ func TestIsAuthorized_PlatformAdminCanDeleteAnyGateway(t *testing.T) { {RoleName: "platform:admin", Scope: "global"}, } - if !isAuthorized(http.MethodDelete, "gateways", gwID, gwID, bindings) { + if !isAuthorized(http.MethodDelete, "gateways", gwID, gwID, bindings, nil) { t.Error("platform:admin should be authorized to delete any gateway") } } @@ -221,7 +221,7 @@ func TestIsAuthorized_PlatformAdminCannotModifyGateway(t *testing.T) { {RoleName: "platform:admin", Scope: "global"}, } - if isAuthorized(http.MethodPatch, "gateways", gwID, gwID, bindings) { + if isAuthorized(http.MethodPatch, "gateways", gwID, gwID, bindings, nil) { t.Error("platform:admin must not be able to PATCH gateways without gateway:owner") } } @@ -231,7 +231,7 @@ func TestIsAuthorized_PlatformAdminCannotCreateGateways(t *testing.T) { {RoleName: "platform:admin", Scope: "global"}, } - if isAuthorized(http.MethodPost, "gateways", "", "", bindings) { + if isAuthorized(http.MethodPost, "gateways", "", "", bindings, nil) { t.Error("platform:admin must not be able to create gateways without gateway:creator") } } @@ -242,7 +242,7 @@ func TestIsAuthorized_PlatformAdminWithGatewayCreatorCanCreate(t *testing.T) { {RoleName: "gateway:creator", Scope: "global"}, } - if !isAuthorized(http.MethodPost, "gateways", "", "", bindings) { + if !isAuthorized(http.MethodPost, "gateways", "", "", bindings, nil) { t.Error("platform:admin + gateway:creator should be able to create gateways") } } @@ -254,7 +254,7 @@ func TestIsAuthorized_PlatformAdminWithOwnershipCanModify(t *testing.T) { {RoleName: "gateway:owner", Scope: "gateway", GatewayID: &gwID}, } - if !isAuthorized(http.MethodPatch, "gateways", gwID, gwID, bindings) { + if !isAuthorized(http.MethodPatch, "gateways", gwID, gwID, bindings, nil) { t.Error("platform:admin + gateway:owner should be able to modify owned gateway") } } @@ -264,7 +264,7 @@ func TestIsAuthorized_PlatformAdminCanAccessRoleBindings(t *testing.T) { {RoleName: "platform:admin", Scope: "global"}, } - if !isAuthorized(http.MethodGet, "role_bindings", "", "", bindings) { + if !isAuthorized(http.MethodGet, "role_bindings", "", "", bindings, nil) { t.Error("platform:admin should be able to access role_bindings") } } @@ -275,20 +275,48 @@ func TestServiceAccountAuthorizationRequiresExactGatewayBinding(t *testing.T) { for _, role := range []string{"gateway:owner", "gateway:viewer"} { bindings := []BindingSummary{{RoleName: role, Scope: "gateway", GatewayID: &gatewayID}} for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { - if !isAuthorized(method, "service_accounts", "sa-1", gatewayID, bindings) { + if !isAuthorized(method, "service_accounts", "sa-1", gatewayID, bindings, nil) { t.Errorf("%s should authorize %s on the bound gateway", role, method) } - if isAuthorized(method, "service_accounts", "sa-1", otherGatewayID, bindings) { + if isAuthorized(method, "service_accounts", "sa-1", otherGatewayID, bindings, nil) { t.Errorf("%s must not authorize %s on another gateway", role, method) } } } platformOnly := []BindingSummary{{RoleName: "platform:admin", Scope: "global"}} - if isAuthorized(http.MethodGet, "service_accounts", "sa-1", gatewayID, platformOnly) { + if isAuthorized(http.MethodGet, "service_accounts", "sa-1", gatewayID, platformOnly, nil) { t.Error("platform:admin without an exact gateway binding must be denied") } } +func TestIsAuthorized_UsersInventoryRequiresDashboardOperator(t *testing.T) { + creatorOnly := []BindingSummary{{RoleName: "gateway:creator", Scope: "global"}} + if isAuthorized(http.MethodGet, "users", "", "", creatorOnly, nil) { + t.Error("gateway:creator without platform:admin must not list users") + } + + platformAdmin := []BindingSummary{{RoleName: "platform:admin", Scope: "global"}} + if !isAuthorized(http.MethodGet, "users", "", "", platformAdmin, nil) { + t.Error("platform:admin should list users") + } + + if !isAuthorized(http.MethodGet, "users", "", "", nil, []string{HypershellAdminRole}) { + t.Error("hypershell-admins JWT role should list users") + } + + if isAuthorized(http.MethodGet, "users", "user-1", "user-1", creatorOnly, nil) { + t.Error("gateway:creator must not get user by id") + } +} + +func TestExtractResourceInfoFromPath_Users(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/hypershell/v1/users/user-1", nil) + resource, resourceID := extractResourceInfo(request) + if resource != "users" || resourceID != "user-1" { + t.Fatalf("resource = %q, id = %q", resource, resourceID) + } +} + func TestExtractResourceInfoRecognizesNestedServiceAccountRoutes(t *testing.T) { router := mux.NewRouter() router.HandleFunc("/api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id}/revoke", func(w http.ResponseWriter, r *http.Request) { @@ -372,3 +400,45 @@ func TestAuthorizeApiAllowsBoundUserFromJWTContext(t *testing.T) { t.Fatalf("status = %d, want 200", recorder.Code) } } + +func TestAuthorizeApiDeniesGatewayCreatorOnUsersList(t *testing.T) { + lookup := authorizationLookup{bindings: []BindingSummary{{RoleName: "gateway:creator", Scope: "global"}}} + middleware := NewRBACAuthzMiddleware(lookup, AuthzConfig{EnforceRBAC: true}) + + router := mux.NewRouter() + router.Handle("/api/hypershell/v1/users", middleware.AuthorizeApi(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("denied request reached the handler") + }))).Methods(http.MethodGet) + + request := httptest.NewRequest(http.MethodGet, "/api/hypershell/v1/users", nil) + token := &jwt.Token{Claims: jwt.MapClaims{"preferred_username": "creator-user"}} + ctx := context.WithValue(request.Context(), auth.ContextAuthKey, token) + ctx = context.WithValue(ctx, ContextUserIDKey, "user-id") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request.WithContext(ctx)) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", recorder.Code) + } +} + +func TestAuthorizeApiConcealsDeniedUsersGet(t *testing.T) { + lookup := authorizationLookup{bindings: []BindingSummary{{RoleName: "gateway:creator", Scope: "global"}}} + middleware := NewRBACAuthzMiddleware(lookup, AuthzConfig{EnforceRBAC: true}) + + router := mux.NewRouter() + router.Handle("/api/hypershell/v1/users/{id}", middleware.AuthorizeApi(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("denied request reached the handler") + }))).Methods(http.MethodGet) + + request := httptest.NewRequest(http.MethodGet, "/api/hypershell/v1/users/user-1", nil) + token := &jwt.Token{Claims: jwt.MapClaims{"preferred_username": "creator-user"}} + ctx := context.WithValue(request.Context(), auth.ContextAuthKey, token) + ctx = context.WithValue(ctx, ContextUserIDKey, "user-id") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request.WithContext(ctx)) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", recorder.Code) + } +} diff --git a/components/api-server/pkg/rbac/grpc_interceptor.go b/components/api-server/pkg/rbac/grpc_interceptor.go index 2056a013..b4529817 100644 --- a/components/api-server/pkg/rbac/grpc_interceptor.go +++ b/components/api-server/pkg/rbac/grpc_interceptor.go @@ -4,7 +4,6 @@ import ( "context" "strings" - "github.com/golang-jwt/jwt/v4" "github.com/golang/glog" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -212,46 +211,6 @@ func provisionUserForGRPC(ctx context.Context, provisioner UserProvisioner, sync return ctx } -func extractJWTRolesFromContext(ctx context.Context) []string { - token, err := auth.TokenFromContext(ctx) - if err != nil { - return nil - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil - } - - realmAccess, ok := claims["realm_access"] - if !ok { - return nil - } - - raMap, ok := realmAccess.(map[string]interface{}) - if !ok { - return nil - } - - rolesRaw, ok := raMap["roles"] - if !ok { - return nil - } - - rolesSlice, ok := rolesRaw.([]interface{}) - if !ok { - return nil - } - - result := make([]string, 0, len(rolesSlice)) - for _, r := range rolesSlice { - if s, ok := r.(string); ok { - result = append(result, s) - } - } - return result -} - type wrappedServerStream struct { grpc.ServerStream ctx context.Context diff --git a/components/api-server/pkg/rbac/jwt_roles.go b/components/api-server/pkg/rbac/jwt_roles.go new file mode 100644 index 00000000..3aced023 --- /dev/null +++ b/components/api-server/pkg/rbac/jwt_roles.go @@ -0,0 +1,117 @@ +package rbac + +import ( + "context" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v4" + + "github.com/openshift-online/rh-trex-ai/pkg/auth" +) + +// extractRealmRolesFromClaims reads realm roles from OIDC claims emitted by HyperShell Keycloak. +// +// The hypershell-frontend client maps realm roles into the top-level groups claim on +// tokens via oidc-usermodel-realm-role-mapper. Access tokens may also carry +// realm_access.roles. The roles claim is honored when present. Group-path values such +// as /hypershell-admins are normalized by stripping a leading slash. +// +// This mirrors components/web-console/bff/src/auth.ts extractRealmRoles so the API +// server and BFF agree on dashboard-operator access for the same bearer token. +func extractRealmRolesFromClaims(claims jwt.MapClaims) []string { + if roles, ok := readStringClaim(claims, "roles"); ok { + return roles + } + + if groups, ok := readStringClaim(claims, "groups"); ok { + return groups + } + + return readRealmAccessRoles(claims) +} + +func readStringClaim(claims jwt.MapClaims, claimName string) ([]string, bool) { + raw, ok := claims[claimName] + if !ok { + return nil, false + } + + rolesSlice, ok := raw.([]interface{}) + if !ok { + return nil, false + } + + result := make([]string, 0, len(rolesSlice)) + for _, role := range rolesSlice { + if s, ok := role.(string); ok { + result = append(result, normalizeRoleName(s)) + } + } + return result, true +} + +func readRealmAccessRoles(claims jwt.MapClaims) []string { + realmAccess, ok := claims["realm_access"] + if !ok { + return nil + } + + raMap, ok := realmAccess.(map[string]interface{}) + if !ok { + return nil + } + + rolesRaw, ok := raMap["roles"] + if !ok { + return nil + } + + rolesSlice, ok := rolesRaw.([]interface{}) + if !ok { + return nil + } + + result := make([]string, 0, len(rolesSlice)) + for _, role := range rolesSlice { + if s, ok := role.(string); ok { + result = append(result, normalizeRoleName(s)) + } + } + return result +} + +func normalizeRoleName(role string) string { + if strings.HasPrefix(role, "/") { + return role[1:] + } + return role +} + +func extractJWTRoles(r *http.Request) []string { + token, err := auth.TokenFromContext(r.Context()) + if err != nil { + return nil + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil + } + + return extractRealmRolesFromClaims(claims) +} + +func extractJWTRolesFromContext(ctx context.Context) []string { + token, err := auth.TokenFromContext(ctx) + if err != nil { + return nil + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil + } + + return extractRealmRolesFromClaims(claims) +} diff --git a/components/api-server/pkg/rbac/jwt_roles_test.go b/components/api-server/pkg/rbac/jwt_roles_test.go new file mode 100644 index 00000000..0af6e6b7 --- /dev/null +++ b/components/api-server/pkg/rbac/jwt_roles_test.go @@ -0,0 +1,106 @@ +package rbac + +import ( + "testing" + + "github.com/golang-jwt/jwt/v4" +) + +func TestExtractRealmRolesFromClaims(t *testing.T) { + tests := []struct { + name string + claims jwt.MapClaims + want []string + }{ + { + name: "reads roles from the roles claim", + claims: jwt.MapClaims{ + "roles": []interface{}{"hypershell-admins", "hypershell-users"}, + }, + want: []string{"hypershell-admins", "hypershell-users"}, + }, + { + name: "reads realm roles from the groups claim used by HyperShell Keycloak", + claims: jwt.MapClaims{ + "groups": []interface{}{"hypershell-admins", "hypershell-users"}, + }, + want: []string{"hypershell-admins", "hypershell-users"}, + }, + { + name: "normalizes leading slashes on groups claim values", + claims: jwt.MapClaims{ + "groups": []interface{}{"/hypershell-admins", "/hypershell-users"}, + }, + want: []string{"hypershell-admins", "hypershell-users"}, + }, + { + name: "prefers the roles claim over groups and realm_access", + claims: jwt.MapClaims{ + "groups": []interface{}{"hypershell-users"}, + "realm_access": map[string]interface{}{"roles": []interface{}{"platform:admin"}}, + "roles": []interface{}{"hypershell-admins"}, + }, + want: []string{"hypershell-admins"}, + }, + { + name: "falls back to realm_access.roles when roles and groups are absent", + claims: jwt.MapClaims{ + "realm_access": map[string]interface{}{ + "roles": []interface{}{"platform:admin", "hypershell-users"}, + }, + }, + want: []string{"platform:admin", "hypershell-users"}, + }, + { + name: "normalizes leading slashes on realm_access.roles values", + claims: jwt.MapClaims{ + "realm_access": map[string]interface{}{ + "roles": []interface{}{"/hypershell-admins"}, + }, + }, + want: []string{"hypershell-admins"}, + }, + { + name: "ignores non-string role entries", + claims: jwt.MapClaims{ + "roles": []interface{}{"hypershell-admins", 42, nil}, + }, + want: []string{"hypershell-admins"}, + }, + { + name: "returns nil when no role claims are present", + claims: jwt.MapClaims{"sub": "user-1"}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractRealmRolesFromClaims(tt.claims) + if !stringSlicesEqual(got, tt.want) { + t.Fatalf("extractRealmRolesFromClaims() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHasHypershellAdminRole_FromGroupsClaim(t *testing.T) { + roles := extractRealmRolesFromClaims(jwt.MapClaims{ + "groups": []interface{}{"/hypershell-admins"}, + }) + if !HasHypershellAdminRole(roles) { + t.Fatal("hypershell-admins in groups claim should grant dashboard-operator access") + } +} + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/components/api-server/pkg/rbac/user_provisioning.go b/components/api-server/pkg/rbac/user_provisioning.go index d509afd0..ce68ec37 100644 --- a/components/api-server/pkg/rbac/user_provisioning.go +++ b/components/api-server/pkg/rbac/user_provisioning.go @@ -4,7 +4,6 @@ import ( "context" "net/http" - "github.com/golang-jwt/jwt/v4" "github.com/golang/glog" "github.com/openshift-online/rh-trex-ai/pkg/auth" @@ -19,6 +18,9 @@ type contextKey string const ContextUserIDKey contextKey = "rbac_user_id" const ContextJWTRolesKey contextKey = "rbac_jwt_roles" +// HypershellAdminRole is the Keycloak realm role that grants dashboard-operator access. +const HypershellAdminRole = "hypershell-admins" + type UserProvisioner interface { UpsertFromJWT(ctx context.Context, payload *auth.Payload) (userID string, err error) } @@ -61,52 +63,33 @@ func UserProvisioningMiddleware(provisioner UserProvisioner, syncer JWTRoleSynce } } -func extractJWTRoles(r *http.Request) []string { - token, err := auth.TokenFromContext(r.Context()) - if err != nil { - return nil - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil - } - - realmAccess, ok := claims["realm_access"] - if !ok { - return nil - } - - raMap, ok := realmAccess.(map[string]interface{}) - if !ok { - return nil +func GetUserIDFromContext(ctx context.Context) string { + v := ctx.Value(ContextUserIDKey) + if v == nil { + return "" } + return v.(string) +} - rolesRaw, ok := raMap["roles"] - if !ok { +func GetJWTRolesFromContext(ctx context.Context) []string { + v := ctx.Value(ContextJWTRolesKey) + if v == nil { return nil } - - rolesSlice, ok := rolesRaw.([]interface{}) + roles, ok := v.([]string) if !ok { return nil } - - result := make([]string, 0, len(rolesSlice)) - for _, r := range rolesSlice { - if s, ok := r.(string); ok { - result = append(result, s) - } - } - return result + return roles } -func GetUserIDFromContext(ctx context.Context) string { - v := ctx.Value(ContextUserIDKey) - if v == nil { - return "" +func HasHypershellAdminRole(jwtRoles []string) bool { + for _, role := range jwtRoles { + if role == HypershellAdminRole { + return true + } } - return v.(string) + return false } func HasPlatformAdminRole(ctx context.Context, userID string) bool { diff --git a/components/api-server/plugins/users/handler.go b/components/api-server/plugins/users/handler.go new file mode 100644 index 00000000..8cae0c59 --- /dev/null +++ b/components/api-server/plugins/users/handler.go @@ -0,0 +1,87 @@ +package users + +import ( + "net/http" + + "github.com/gorilla/mux" + + "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" + "github.com/openshift-online/rh-trex-ai/pkg/api/presenters" + "github.com/openshift-online/rh-trex-ai/pkg/errors" + "github.com/openshift-online/rh-trex-ai/pkg/handlers" + "github.com/openshift-online/rh-trex-ai/pkg/services" +) + +type userHandler struct { + user UserService + generic services.GenericService +} + +func NewUserHandler(user UserService, generic services.GenericService) *userHandler { + return &userHandler{ + user: user, + generic: generic, + } +} + +func (h userHandler) List(w http.ResponseWriter, r *http.Request) { + cfg := &handlers.HandlerConfig{ + Action: func() (interface{}, *errors.ServiceError) { + ctx := r.Context() + + listArgs := services.NewListArguments(r.URL.Query()) + if len(listArgs.OrderBy) == 0 { + listArgs.OrderBy = []string{"username asc"} + } + + var users []User + paging, err := h.generic.List(ctx, "id", listArgs, &users) + if err != nil { + return nil, err + } + kindStr := "UserList" + pageVal := int32(paging.Page) + sizeVal := int32(paging.Size) + totalVal := int32(paging.Total) + userList := openapi.UserList{ + Kind: &kindStr, + Page: &pageVal, + Size: &sizeVal, + Total: &totalVal, + Items: []openapi.User{}, + } + + for _, user := range users { + converted := PresentUser(&user) + userList.Items = append(userList.Items, converted) + } + if listArgs.Fields != nil { + filteredItems, filterErr := presenters.SliceFilter(listArgs.Fields, userList.Items) + if filterErr != nil { + return nil, filterErr + } + return filteredItems, nil + } + return userList, nil + }, + } + + handlers.HandleList(w, r, cfg) +} + +func (h userHandler) Get(w http.ResponseWriter, r *http.Request) { + cfg := &handlers.HandlerConfig{ + Action: func() (interface{}, *errors.ServiceError) { + id := mux.Vars(r)["id"] + ctx := r.Context() + user, err := h.user.Get(ctx, id) + if err != nil { + return nil, err + } + + return PresentUser(user), nil + }, + } + + handlers.HandleGet(w, r, cfg) +} diff --git a/components/api-server/plugins/users/integration_test.go b/components/api-server/plugins/users/integration_test.go new file mode 100644 index 00000000..669010de --- /dev/null +++ b/components/api-server/plugins/users/integration_test.go @@ -0,0 +1,139 @@ +package users_test + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + . "github.com/onsi/gomega" + + "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" + "github.com/openshift-online/hypershell/components/api-server/pkg/rbac" + "github.com/openshift-online/hypershell/components/api-server/plugins/roles" + "github.com/openshift-online/hypershell/components/api-server/plugins/users" + "github.com/openshift-online/hypershell/components/api-server/test" + "github.com/openshift-online/rh-trex-ai/pkg/environments" + "github.com/openshift-online/rh-trex-ai/pkg/testutil" +) + +func jwtContextWithRealmRoles(h *test.Helper, account *testutil.TestAccount, realmRoles []string) context.Context { + roleValues := make([]interface{}, len(realmRoles)) + for i, role := range realmRoles { + roleValues[i] = role + } + + claims := jwt.MapClaims{ + "iss": h.Env().Config.APIClient.TokenURL, + "username": account.Username, + "typ": "Bearer", + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + "realm_access": map[string]interface{}{ + "roles": roleValues, + }, + } + if account.Email != "" { + claims["email"] = account.Email + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = testutil.JwkKID + + signedToken, err := token.SignedString(h.JWTPrivateKey) + Expect(err).NotTo(HaveOccurred()) + + return context.WithValue(context.Background(), openapi.ContextAccessToken, signedToken) +} + +func seedUsers(count int) []string { + userService := users.Service(&environments.Environment().Services) + ids := make([]string, 0, count) + for i := 0; i < count; i++ { + username := fmt.Sprintf("registered-user-%d", i) + id, err := userService.UpsertByUsername(context.Background(), username, nil, nil) + Expect(err).NotTo(HaveOccurred()) + ids = append(ids, id) + } + return ids +} + +func TestUserList_ForbiddenForGatewayCreator(t *testing.T) { + h, client := test.RegisterIntegration(t) + + account := h.NewAccount("gateway-creator", "Gateway Creator", "creator@example.com") + ctx := jwtContextWithRealmRoles(h, account, []string{roles.RoleGatewayCreator}) + + _, resp, err := client.DefaultAPI.ListUsers(ctx).Execute() + Expect(err).To(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) +} + +func TestUserList_AllowedForHypershellAdmin(t *testing.T) { + h, client := test.RegisterIntegration(t) + + seedUsers(2) + account := h.NewAccount("dashboard-admin", "Dashboard Admin", "admin@example.com") + ctx := jwtContextWithRealmRoles(h, account, []string{rbac.HypershellAdminRole}) + + list, resp, err := client.DefaultAPI.ListUsers(ctx).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(len(list.Items)).To(BeNumerically(">=", 2)) +} + +func TestUserList_AllowedForPlatformAdminBinding(t *testing.T) { + h, client := test.RegisterIntegration(t) + + seedUsers(1) + account := h.NewAccount("platform-admin", "Platform Admin", "platform@example.com") + ctx := jwtContextWithRealmRoles(h, account, []string{roles.RolePlatformAdmin}) + + list, resp, err := client.DefaultAPI.ListUsers(ctx).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(*list.Total).To(BeNumerically(">=", 1)) +} + +func TestUserGet_Opaque404ForUnauthorizedCaller(t *testing.T) { + h, client := test.RegisterIntegration(t) + + ids := seedUsers(1) + account := h.NewAccount("unauthorized-viewer", "Unauthorized", "denied@example.com") + ctx := jwtContextWithRealmRoles(h, account, []string{roles.RoleGatewayCreator}) + + _, resp, err := client.DefaultAPI.GetUser(ctx, ids[0]).Execute() + Expect(err).To(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) +} + +func TestUserList_TotalAvailableWithSizeOne(t *testing.T) { + h, client := test.RegisterIntegration(t) + + seedUsers(3) + account := h.NewAccount("count-admin", "Count Admin", "count@example.com") + ctx := jwtContextWithRealmRoles(h, account, []string{rbac.HypershellAdminRole}) + + list, resp, err := client.DefaultAPI.ListUsers(ctx).Page(1).Size(1).OrderBy("username asc").Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(*list.Total).To(BeNumerically(">=", 3)) + Expect(len(list.Items)).To(Equal(1)) +} + +func TestUserGet_AllowedForAuthorizedCaller(t *testing.T) { + h, client := test.RegisterIntegration(t) + + ids := seedUsers(1) + account := h.NewAccount("get-admin", "Get Admin", "get@example.com") + ctx := jwtContextWithRealmRoles(h, account, []string{rbac.HypershellAdminRole}) + + user, resp, err := client.DefaultAPI.GetUser(ctx, ids[0]).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(*user.Id).To(Equal(ids[0])) + Expect(user.Username).NotTo(BeEmpty()) + Expect(user.CreatedAt).NotTo(BeNil()) +} diff --git a/components/api-server/plugins/users/plugin.go b/components/api-server/plugins/users/plugin.go index eb0f3cef..3e4b0001 100644 --- a/components/api-server/plugins/users/plugin.go +++ b/components/api-server/plugins/users/plugin.go @@ -1,10 +1,17 @@ package users import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/openshift-online/rh-trex-ai/pkg/api/presenters" + "github.com/openshift-online/rh-trex-ai/pkg/auth" "github.com/openshift-online/rh-trex-ai/pkg/db" "github.com/openshift-online/rh-trex-ai/pkg/environments" "github.com/openshift-online/rh-trex-ai/pkg/registry" + pkgserver "github.com/openshift-online/rh-trex-ai/pkg/server" + "github.com/openshift-online/rh-trex-ai/plugins/generic" ) type ServiceLocator func() UserService @@ -33,6 +40,17 @@ func init() { return NewServiceLocator(env.(*environments.Env)) }) + pkgserver.RegisterRoutes("users", func(apiV1Router *mux.Router, services pkgserver.ServicesInterface, authMiddleware environments.JWTMiddleware, authzMiddleware auth.AuthorizationMiddleware) { + envServices := services.(*environments.Services) + userHandler := NewUserHandler(Service(envServices), generic.Service(envServices)) + + usersRouter := apiV1Router.PathPrefix("/users").Subrouter() + usersRouter.HandleFunc("", userHandler.List).Methods(http.MethodGet) + usersRouter.HandleFunc("/{id}", userHandler.Get).Methods(http.MethodGet) + usersRouter.Use(authMiddleware.AuthenticateAccountJWT) + usersRouter.Use(authzMiddleware.AuthorizeApi) + }) + presenters.RegisterPath(User{}, "users") presenters.RegisterPath(&User{}, "users") presenters.RegisterKind(User{}, "User") diff --git a/components/api-server/plugins/users/presenter.go b/components/api-server/plugins/users/presenter.go new file mode 100644 index 00000000..1b3fe358 --- /dev/null +++ b/components/api-server/plugins/users/presenter.go @@ -0,0 +1,20 @@ +package users + +import ( + "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" + "github.com/openshift-online/rh-trex-ai/pkg/api/presenters" +) + +func PresentUser(user *User) openapi.User { + reference := presenters.PresentReference(user.ID, user) + return openapi.User{ + Id: reference.Id, + Kind: reference.Kind, + Href: reference.Href, + CreatedAt: openapi.PtrTime(user.CreatedAt), + UpdatedAt: openapi.PtrTime(user.UpdatedAt), + Username: user.Username, + Email: user.Email, + Name: user.Name, + } +} diff --git a/components/api-server/plugins/users/testmain_test.go b/components/api-server/plugins/users/testmain_test.go new file mode 100644 index 00000000..9eea7e3c --- /dev/null +++ b/components/api-server/plugins/users/testmain_test.go @@ -0,0 +1,25 @@ +package users_test + +import ( + "flag" + "os" + "runtime" + "testing" + + "github.com/golang/glog" + + _ "github.com/openshift-online/hypershell/components/api-server/plugins/rbac" + "github.com/openshift-online/hypershell/components/api-server/test" +) + +func TestMain(m *testing.M) { + flag.Parse() + _ = os.Setenv("API_ENV", "integration_testing") + _ = os.Setenv("DB_FACTORY_MODE", "external") + _ = os.Setenv("RBAC_ENFORCE", "true") + glog.Infof("Starting users integration test using go version %s", runtime.Version()) + helper := test.NewHelper(&testing.T{}) + exitCode := m.Run() + helper.Teardown() + os.Exit(exitCode) +} diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index 3049004e..540574ff 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index 3093afc8..188ef2db 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { SDKClientConfig } from './base.js'; import { GatewayAPI } from './gateway_api.js'; @@ -11,6 +11,7 @@ import { ManagedDatabaseAPI } from './managed_database_api.js'; import { OpenShellGatewayServiceAccountAPI } from './open_shell_gateway_service_account_api.js'; import { RoleAPI } from './role_api.js'; import { RoleBindingAPI } from './role_binding_api.js'; +import { UserAPI } from './user_api.js'; export class SDKClient { @@ -24,6 +25,7 @@ export class SDKClient { readonly openShellGatewayServiceAccounts: OpenShellGatewayServiceAccountAPI; readonly roles: RoleAPI; readonly roleBindings: RoleBindingAPI; + readonly users: UserAPI; constructor(config: SDKClientConfig = {}) { if (config.token !== undefined && config.getToken !== undefined) { @@ -43,6 +45,7 @@ export class SDKClient { this.openShellGatewayServiceAccounts = new OpenShellGatewayServiceAccountAPI(this.config); this.roles = new RoleAPI(this.config); this.roleBindings = new RoleBindingAPI(this.config); + this.users = new UserAPI(this.config); } static fromEnv(): SDKClient { diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index de4e3184..f737a51f 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index 4e71256d..d685716b 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network.ts b/components/sdk-typescript/src/gateway_network.ts index 97fe414a..0ba5eef8 100644 --- a/components/sdk-typescript/src/gateway_network.ts +++ b/components/sdk-typescript/src/gateway_network.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network_api.ts b/components/sdk-typescript/src/gateway_network_api.ts index d08e74a2..2c468dde 100644 --- a/components/sdk-typescript/src/gateway_network_api.ts +++ b/components/sdk-typescript/src/gateway_network_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release.ts b/components/sdk-typescript/src/gateway_release.ts index c915a33f..1286fb8d 100644 --- a/components/sdk-typescript/src/gateway_release.ts +++ b/components/sdk-typescript/src/gateway_release.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release_api.ts b/components/sdk-typescript/src/gateway_release_api.ts index f990e48a..855237e2 100644 --- a/components/sdk-typescript/src/gateway_release_api.ts +++ b/components/sdk-typescript/src/gateway_release_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index ee5fcee8..d572bc11 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; @@ -62,3 +62,9 @@ export { RoleAPI } from './role_api.js'; export type { RoleBinding, RoleBindingList, RoleBindingCreateRequest, RoleBindingPatchRequest } from './role_binding.js'; export { RoleBindingBuilder, RoleBindingPatchBuilder } from './role_binding.js'; export { RoleBindingAPI } from './role_binding_api.js'; + + + +export type { User, UserList, UserCreateRequest, UserPatchRequest } from './user.js'; +export { UserBuilder, UserPatchBuilder } from './user.js'; +export { UserAPI } from './user_api.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index a648937d..b1a9d5fe 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index f83d1b15..97d183c6 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/managed_database.ts b/components/sdk-typescript/src/managed_database.ts index a156b437..e8beaf9c 100644 --- a/components/sdk-typescript/src/managed_database.ts +++ b/components/sdk-typescript/src/managed_database.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_database_api.ts b/components/sdk-typescript/src/managed_database_api.ts index e3b31217..8dc81a83 100644 --- a/components/sdk-typescript/src/managed_database_api.ts +++ b/components/sdk-typescript/src/managed_database_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account.ts b/components/sdk-typescript/src/open_shell_gateway_service_account.ts index fdb26eb3..0d2c329e 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df export type OpenShellGatewayServiceAccountCapabilities = { diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index 3e2d5f93..6e0b9256 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index f433f5cf..e06e81c7 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index 1ba0e062..48eed07e 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index d995b20d..f6e67eab 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index 9678dcbc..90bbc29c 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/user.ts b/components/sdk-typescript/src/user.ts new file mode 100644 index 00000000..b0d8af03 --- /dev/null +++ b/components/sdk-typescript/src/user.ts @@ -0,0 +1,60 @@ +// Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. +// Source: components/api-server/openapi/openapi.yaml +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df + +import type { ObjectReference, ListMeta } from './base.js'; + +export type User = ObjectReference & { + email: string; + name: string; + username: string; +}; + +export type UserList = ListMeta & { + items: User[]; +}; + +export type UserCreateRequest = { + email?: string; + name?: string; + username: string; +}; + +export type UserPatchRequest = { +}; + +export class UserBuilder { + private data: Record = {}; + + + email(value: string): this { + this.data['email'] = value; + return this; + } + + name(value: string): this { + this.data['name'] = value; + return this; + } + + username(value: string): this { + this.data['username'] = value; + return this; + } + + build(): UserCreateRequest { + if (!this.data['username']) { + throw new Error('username is required'); + } + return this.data as UserCreateRequest; + } +} + +export class UserPatchBuilder { + private data: Record = {}; + + + build(): UserPatchRequest { + return this.data as UserPatchRequest; + } +} diff --git a/components/sdk-typescript/src/user_api.ts b/components/sdk-typescript/src/user_api.ts new file mode 100644 index 00000000..e3cd985c --- /dev/null +++ b/components/sdk-typescript/src/user_api.ts @@ -0,0 +1,38 @@ +// Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. +// Source: components/api-server/openapi/openapi.yaml +// Spec SHA256: 8aa699294ae5fc468c70d36dcd5a302f3f6023eac294b477f36501ddfa2117df + +import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; +import { sdkFetch, buildQueryString } from './base.js'; +import type { User, UserList, UserCreateRequest } from './user.js'; + +export class UserAPI { + constructor(private readonly config: SDKClientConfig) {} + + async create(data: UserCreateRequest, opts?: RequestOptions): Promise { + return sdkFetch(this.config, 'POST', '/users', data, opts); + } + + async get(id: string, opts?: RequestOptions): Promise { + return sdkFetch(this.config, 'GET', `/users/${id}`, undefined, opts); + } + + async list(listOpts?: ListOptions, opts?: RequestOptions): Promise { + const qs = buildQueryString(listOpts); + return sdkFetch(this.config, 'GET', `/users${qs}`, undefined, opts); + } + + async *listAll(size: number = 100, opts?: RequestOptions): AsyncGenerator { + let page = 1; + while (true) { + const result = await this.list({ page, size }, opts); + for (const item of result.items) { + yield item; + } + if (page * size >= result.total) { + break; + } + page++; + } + } +} diff --git a/components/web-console/Dockerfile b/components/web-console/Dockerfile index 35e533f0..c9f52945 100644 --- a/components/web-console/Dockerfile +++ b/components/web-console/Dockerfile @@ -20,6 +20,7 @@ WORKDIR /app COPY --chown=${CONTAINER_DEFAULT_USER} package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY --chown=${CONTAINER_DEFAULT_USER} components/sdk-typescript/package.json components/sdk-typescript/package.json COPY --chown=${CONTAINER_DEFAULT_USER} packages/gateway-management-ui/package.json packages/gateway-management-ui/package.json +COPY --chown=${CONTAINER_DEFAULT_USER} packages/operational-dashboard-ui/package.json packages/operational-dashboard-ui/package.json COPY --chown=${CONTAINER_DEFAULT_USER} components/web-console/package.json components/web-console/package.json COPY --chown=${CONTAINER_DEFAULT_USER} components/web-console/bff/package.json components/web-console/bff/package.json COPY --chown=${CONTAINER_DEFAULT_USER} components/web-console/domain-probes/package.json components/web-console/domain-probes/package.json @@ -28,6 +29,7 @@ RUN pnpm install --frozen-lockfile COPY --chown=${CONTAINER_DEFAULT_USER} components/sdk-typescript components/sdk-typescript COPY --chown=${CONTAINER_DEFAULT_USER} packages/gateway-management-ui packages/gateway-management-ui +COPY --chown=${CONTAINER_DEFAULT_USER} packages/operational-dashboard-ui packages/operational-dashboard-ui COPY --chown=${CONTAINER_DEFAULT_USER} components/web-console components/web-console COPY --chown=${CONTAINER_DEFAULT_USER} images/brand images/brand @@ -55,4 +57,4 @@ COPY --from=build /tmp/web-console/ ./ EXPOSE 8080 -CMD ["node", "dist/index.js"] +CMD ["node", "dist/bff/src/index.js"] diff --git a/components/web-console/app/adapters/api/dashboard-control-plane.test.ts b/components/web-console/app/adapters/api/dashboard-control-plane.test.ts new file mode 100644 index 00000000..14e7b625 --- /dev/null +++ b/components/web-console/app/adapters/api/dashboard-control-plane.test.ts @@ -0,0 +1,795 @@ +import type { Gateway, GatewayList } from "@openshift-online/hypershell-sdk"; +import type { SDKClient } from "@openshift-online/hypershell-sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createDashboardControlPlaneAdapter } from "./dashboard-control-plane"; + +const gatewayListApi = vi.fn(); +const usersListApi = vi.fn(); +const fetchMock = vi.fn(); +const apiFactory = vi.fn( + () => + ({ + gateways: { + list: gatewayListApi, + }, + users: { + list: usersListApi, + }, + }) as unknown as SDKClient, +); + +const adapter = createDashboardControlPlaneAdapter(apiFactory); +const context = { + correlationId: "11111111-1111-4111-8111-111111111111", +}; + +const mockClusterPodsResponse = { + available_pods: 1452, + capacity_pods: 2000, + phase_failed_pods: 16, + phase_pending_pods: 12, + phase_running_pods: 500, + phase_succeeded_pods: 20, + phase_unknown_pods: 0, + used_pods: 548, +}; + +const mockGatewayProvisionDurationResponse = { + mean_seconds: 315, + observation_count: 2, + p50_seconds: 288, + p95_seconds: 726, +}; + +function mockClusterMetricsResponses( + capacityBytes: number, + usedBytes: number, +): void { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/metrics/cluster-memory") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_bytes: capacityBytes - usedBytes, + capacity_bytes: capacityBytes, + used_bytes: usedBytes, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-cpu") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_cores: 11.8, + capacity_cores: 60, + used_cores: 48.2, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-pods") { + return Promise.resolve({ + json: () => Promise.resolve(mockClusterPodsResponse), + ok: true, + }); + } + if (url === "/api/metrics/cluster-nodes") { + return Promise.resolve({ + json: () => + Promise.resolve({ + not_ready_nodes: 0, + ready_nodes: 8, + total_nodes: 8, + }), + ok: true, + }); + } + if (url === "/api/metrics/gateway-provision-duration") { + return Promise.resolve({ + json: () => Promise.resolve(mockGatewayProvisionDurationResponse), + ok: true, + }); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); +} + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + gatewayListApi.mockReset(); + usersListApi.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function gateway(overrides: Partial = {}): Gateway { + const phase = overrides.phase ?? "Running"; + const runningTimestamps = + phase === "Running" + ? { + created_at: "2026-08-01T10:00:00.000Z", + updated_at: "2026-08-01T10:05:15.000Z", + } + : { + created_at: null, + updated_at: null, + }; + + return { + active_sandbox_count: 2, + cluster_id: "", + console_address: "", + created_by: "", + credential_driver: "", + database_id: "database-1", + external_dns: "gateway.example.com", + href: "/api/hypershell/v1/gateways/gateway-1", + id: "gateway-1", + image: "", + kind: "Gateway", + name: "Team gateway", + namespace: "openshell", + oidc: "", + phase: "Running", + release_id: "release-1", + route: "", + route_address: "", + server_dns_names: "", + service_type: "", + status: "Healthy", + supervisor_image: "", + tls_mode: "", + ...runningTimestamps, + ...overrides, + }; +} + +function gatewayList( + items: Gateway[], + total = items.length, + page = 1, +): GatewayList { + return { + items, + kind: "GatewayList", + page, + size: items.length, + total, + }; +} + +describe("createDashboardControlPlaneAdapter", () => { + it("aggregates paginated gateway lists into operational metrics", async () => { + mockClusterMetricsResponses(254468212736, 236223201280); + + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 42, + }); + + const firstPage = Array.from({ length: 100 }, (_, index) => + gateway({ + active_sandbox_count: 1, + id: `gateway-${String(index)}`, + phase: "Running", + status: "Healthy", + }), + ); + const secondPage = Array.from({ length: 50 }, (_, index) => + gateway({ + active_sandbox_count: 2, + id: `gateway-${String(index + 100)}`, + phase: "Provisioning", + status: "route pending", + }), + ); + + gatewayListApi + .mockResolvedValueOnce(gatewayList(firstPage, 150, 1)) + .mockResolvedValueOnce(gatewayList(secondPage, 150, 2)); + + const metrics = await adapter.getOperationalMetrics(context); + + expect(gatewayListApi).toHaveBeenCalledTimes(2); + expect(gatewayListApi).toHaveBeenNthCalledWith( + 1, + { orderBy: "name asc", page: 1, size: 100 }, + { signal: undefined }, + ); + expect(gatewayListApi).toHaveBeenNthCalledWith( + 2, + { orderBy: "name asc", page: 2, size: 100 }, + { signal: undefined }, + ); + + const gatewaysMetric = metrics.metrics.find( + (metric) => metric.id === "provisioned-gateways", + ); + const sandboxesMetric = metrics.metrics.find( + (metric) => metric.id === "provisioned-sandboxes", + ); + const registeredUsersMetric = metrics.metrics.find( + (metric) => metric.id === "registered-users", + ); + const memoryMetric = metrics.metrics.find( + (metric) => metric.id === "memory", + ); + const cpuMetric = metrics.metrics.find((metric) => metric.id === "cpu"); + const podsMetric = metrics.metrics.find((metric) => metric.id === "pods"); + const nodesMetric = metrics.metrics.find((metric) => metric.id === "nodes"); + const provisionTimeMetric = metrics.metrics.find( + (metric) => metric.id === "provision-time", + ); + + expect(gatewaysMetric?.value).toBe("150"); + expect(gatewaysMetric?.status).toEqual({ + degraded: 0, + failed: 0, + healthy: 100, + provisioning: 50, + }); + expect(sandboxesMetric?.value).toBe("200"); + expect(registeredUsersMetric?.value).toBe("42"); + expect(memoryMetric).toEqual({ + id: "memory", + total: "237", + unit: "GiB", + value: "220", + }); + expect(cpuMetric).toEqual({ + id: "cpu", + total: "60", + unit: "cores", + value: "48", + }); + expect(podsMetric).toEqual({ + id: "pods", + podPhases: { + failed: 16, + pending: 12, + running: 500, + succeeded: 20, + unknown: 0, + }, + total: "2000", + unit: "pods", + value: "548", + }); + expect(nodesMetric).toEqual({ + id: "nodes", + status: { + failed: 0, + healthy: 8, + }, + value: "8", + }); + expect(provisionTimeMetric).toEqual({ + id: "provision-time", + provisionDuration: { + mean: "5.25", + p50: "4.80", + p95: "12.10", + }, + unit: "minutes", + value: "5.25", + }); + expect(fetchMock).toHaveBeenCalledWith("/api/metrics/cluster-memory", { + credentials: "same-origin", + signal: undefined, + }); + expect(fetchMock).toHaveBeenCalledWith("/api/metrics/cluster-cpu", { + credentials: "same-origin", + signal: undefined, + }); + expect(fetchMock).toHaveBeenCalledWith("/api/metrics/cluster-pods", { + credentials: "same-origin", + signal: undefined, + }); + expect(fetchMock).toHaveBeenCalledWith("/api/metrics/cluster-nodes", { + credentials: "same-origin", + signal: undefined, + }); + expect(usersListApi).toHaveBeenCalledWith( + { orderBy: "username asc", page: 1, size: 1 }, + { signal: undefined }, + ); + }); + + it("maps gateway lifecycle fields into display-status buckets", async () => { + mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); + + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + + gatewayListApi.mockResolvedValueOnce( + gatewayList( + [ + gateway({ phase: "Running", status: "Healthy" }), + gateway({ phase: "Degraded", status: "CrashLoopBackOff" }), + gateway({ phase: "Failed", status: "apply error" }), + ], + 3, + 1, + ), + ); + + const metrics = await adapter.getOperationalMetrics(context); + const gatewaysMetric = metrics.metrics.find( + (metric) => metric.id === "provisioned-gateways", + ); + + expect(gatewaysMetric?.status).toEqual({ + degraded: 1, + failed: 1, + healthy: 1, + provisioning: 0, + }); + }); + + it("treats omitted active_sandbox_count as zero when summing sandboxes", async () => { + mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); + + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + + gatewayListApi.mockResolvedValueOnce( + gatewayList( + [ + gateway({ active_sandbox_count: 2 }), + gateway({ active_sandbox_count: undefined }), + gateway({ active_sandbox_count: 3 }), + ], + 3, + 1, + ), + ); + + const metrics = await adapter.getOperationalMetrics(context); + const sandboxesMetric = metrics.metrics.find( + (metric) => metric.id === "provisioned-sandboxes", + ); + + expect(sandboxesMetric?.value).toBe("5"); + }); + + it("maps gateway provision duration histogram into average, P50, and P95 minutes", async () => { + mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); + + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + + gatewayListApi.mockResolvedValueOnce( + gatewayList( + [gateway({ phase: "Provisioning", status: "route pending" })], + 1, + 1, + ), + ); + + const metrics = await adapter.getOperationalMetrics(context); + const provisionTimeMetric = metrics.metrics.find( + (metric) => metric.id === "provision-time", + ); + + expect(provisionTimeMetric).toEqual({ + id: "provision-time", + provisionDuration: { + mean: "5.25", + p50: "4.80", + p95: "12.10", + }, + unit: "minutes", + value: "5.25", + }); + }); + + it("omits provision time when the BFF provision duration route is unavailable", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/metrics/gateway-provision-duration") { + return Promise.resolve({ + ok: false, + status: 502, + }); + } + if (url === "/api/metrics/cluster-memory") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_bytes: 512 * 1024 ** 2, + capacity_bytes: 1024 ** 3, + used_bytes: 512 * 1024 ** 2, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-cpu") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_cores: 11.8, + capacity_cores: 60, + used_cores: 48.2, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-pods") { + return Promise.resolve({ + json: () => Promise.resolve(mockClusterPodsResponse), + ok: true, + }); + } + if (url === "/api/metrics/cluster-nodes") { + return Promise.resolve({ + json: () => + Promise.resolve({ + not_ready_nodes: 0, + ready_nodes: 8, + total_nodes: 8, + }), + ok: true, + }); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); + + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + + gatewayListApi.mockResolvedValueOnce( + gatewayList( + [ + gateway({ phase: "Provisioning", status: "route pending" }), + gateway({ phase: "Failed", status: "apply error" }), + ], + 2, + 1, + ), + ); + + const metrics = await adapter.getOperationalMetrics(context); + const provisionTimeMetric = metrics.metrics.find( + (metric) => metric.id === "provision-time", + ); + const memoryMetric = metrics.metrics.find( + (metric) => metric.id === "memory", + ); + + expect(provisionTimeMetric).toBeUndefined(); + expect(memoryMetric).toEqual({ + id: "memory", + total: "1", + unit: "GiB", + value: "1", + }); + }); + + it("rejects inconsistent pagination responses", async () => { + mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); + + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 2)); + + await expect(adapter.getOperationalMetrics(context)).rejects.toThrow( + "Gateway list response was inconsistent", + ); + }); + + it("forwards abort signals to the gateway list client", async () => { + const controller = new AbortController(); + mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); + + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); + + await adapter.getOperationalMetrics({ + ...context, + signal: controller.signal, + }); + + expect(gatewayListApi).toHaveBeenCalledWith( + { orderBy: "name asc", page: 1, size: 100 }, + { signal: controller.signal }, + ); + expect(usersListApi).toHaveBeenCalledWith( + { orderBy: "username asc", page: 1, size: 1 }, + { signal: controller.signal }, + ); + expect(fetchMock).toHaveBeenCalledWith("/api/metrics/cluster-memory", { + credentials: "same-origin", + signal: controller.signal, + }); + expect(fetchMock).toHaveBeenCalledWith("/api/metrics/cluster-cpu", { + credentials: "same-origin", + signal: controller.signal, + }); + expect(fetchMock).toHaveBeenCalledWith("/api/metrics/cluster-pods", { + credentials: "same-origin", + signal: controller.signal, + }); + expect(fetchMock).toHaveBeenCalledWith("/api/metrics/cluster-nodes", { + credentials: "same-origin", + signal: controller.signal, + }); + }); + + it("fails when cluster memory metrics are unavailable", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/metrics/cluster-memory") { + return Promise.resolve({ + ok: false, + status: 502, + }); + } + if (url === "/api/metrics/cluster-cpu") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_cores: 11.8, + capacity_cores: 60, + used_cores: 48.2, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-pods") { + return Promise.resolve({ + json: () => + Promise.resolve({ + ...mockClusterPodsResponse, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-nodes") { + return Promise.resolve({ + json: () => + Promise.resolve({ + not_ready_nodes: 0, + ready_nodes: 8, + total_nodes: 8, + }), + ok: true, + }); + } + if (url === "/api/metrics/gateway-provision-duration") { + return Promise.resolve({ + json: () => Promise.resolve(mockGatewayProvisionDurationResponse), + ok: true, + }); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); + + await expect(adapter.getOperationalMetrics(context)).rejects.toThrow( + "Failed to fetch cluster memory metrics: 502", + ); + }); + + it("fails when cluster CPU metrics are unavailable", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/metrics/cluster-memory") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_bytes: 512 * 1024 ** 2, + capacity_bytes: 1024 ** 3, + used_bytes: 512 * 1024 ** 2, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-cpu") { + return Promise.resolve({ + ok: false, + status: 502, + }); + } + if (url === "/api/metrics/cluster-pods") { + return Promise.resolve({ + json: () => + Promise.resolve({ + ...mockClusterPodsResponse, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-nodes") { + return Promise.resolve({ + json: () => + Promise.resolve({ + not_ready_nodes: 0, + ready_nodes: 8, + total_nodes: 8, + }), + ok: true, + }); + } + if (url === "/api/metrics/gateway-provision-duration") { + return Promise.resolve({ + json: () => Promise.resolve(mockGatewayProvisionDurationResponse), + ok: true, + }); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); + + await expect(adapter.getOperationalMetrics(context)).rejects.toThrow( + "Failed to fetch cluster CPU metrics: 502", + ); + }); + + it("fails when cluster pods metrics are unavailable", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/metrics/cluster-memory") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_bytes: 512 * 1024 ** 2, + capacity_bytes: 1024 ** 3, + used_bytes: 512 * 1024 ** 2, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-cpu") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_cores: 11.8, + capacity_cores: 60, + used_cores: 48.2, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-pods") { + return Promise.resolve({ + ok: false, + status: 502, + }); + } + if (url === "/api/metrics/cluster-nodes") { + return Promise.resolve({ + json: () => + Promise.resolve({ + not_ready_nodes: 0, + ready_nodes: 8, + total_nodes: 8, + }), + ok: true, + }); + } + if (url === "/api/metrics/gateway-provision-duration") { + return Promise.resolve({ + json: () => Promise.resolve(mockGatewayProvisionDurationResponse), + ok: true, + }); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); + + await expect(adapter.getOperationalMetrics(context)).rejects.toThrow( + "Failed to fetch cluster pods metrics: 502", + ); + }); + + it("fails when cluster nodes metrics are unavailable", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === "/api/metrics/cluster-memory") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_bytes: 512 * 1024 ** 2, + capacity_bytes: 1024 ** 3, + used_bytes: 512 * 1024 ** 2, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-cpu") { + return Promise.resolve({ + json: () => + Promise.resolve({ + available_cores: 11.8, + capacity_cores: 60, + used_cores: 48.2, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-pods") { + return Promise.resolve({ + json: () => + Promise.resolve({ + ...mockClusterPodsResponse, + }), + ok: true, + }); + } + if (url === "/api/metrics/cluster-nodes") { + return Promise.resolve({ + ok: false, + status: 502, + }); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); + usersListApi.mockResolvedValueOnce({ + items: [], + kind: "UserList", + page: 1, + size: 1, + total: 0, + }); + gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); + + await expect(adapter.getOperationalMetrics(context)).rejects.toThrow( + "Failed to fetch cluster nodes metrics: 502", + ); + }); +}); diff --git a/components/web-console/app/adapters/api/dashboard-control-plane.ts b/components/web-console/app/adapters/api/dashboard-control-plane.ts new file mode 100644 index 00000000..128c2306 --- /dev/null +++ b/components/web-console/app/adapters/api/dashboard-control-plane.ts @@ -0,0 +1,336 @@ +import { + aggregateGatewayDisplayStatusCounts, + type GatewayDisplayStatusCounts, +} from "@openshift-online/hypershell-gateway-management-ui"; +import type { + DashboardControlPlane, + DashboardInvocationContext, + OperationalDashboardMetrics, + OperationalMetric, +} from "@openshift-online/hypershell-operational-dashboard-ui"; +import type { SDKClient } from "@openshift-online/hypershell-sdk"; + +type DashboardApiFactory = (correlationId: string) => SDKClient; + +const gatewayListPageSize = 100; +const gibibyteDivisor = 1024 ** 3; +const secondsPerMinute = 60; + +interface ClusterMemoryResponse { + available_bytes: number; + capacity_bytes: number; + used_bytes: number; +} + +interface ClusterCpuResponse { + available_cores: number; + capacity_cores: number; + used_cores: number; +} + +interface ClusterPodsResponse { + available_pods: number; + capacity_pods: number; + phase_failed_pods: number; + phase_pending_pods: number; + phase_running_pods: number; + phase_succeeded_pods: number; + phase_unknown_pods: number; + used_pods: number; +} + +interface ClusterNodesResponse { + not_ready_nodes: number; + ready_nodes: number; + total_nodes: number; +} + +interface GatewayProvisionDurationResponse { + mean_seconds: number; + observation_count: number; + p50_seconds: number; + p95_seconds: number; +} + +function bytesToRoundedGib(bytes: number): string { + return String(Math.round(bytes / gibibyteDivisor)); +} + +function coresToRoundedString(cores: number): string { + return String(Math.round(cores)); +} + +async function fetchClusterMemoryMetric( + signal?: AbortSignal, +): Promise { + const response = await fetch("/api/metrics/cluster-memory", { + credentials: "same-origin", + signal, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch cluster memory metrics: ${String(response.status)}`, + ); + } + + const body = (await response.json()) as ClusterMemoryResponse; + + return { + id: "memory", + total: bytesToRoundedGib(body.capacity_bytes), + unit: "GiB", + value: bytesToRoundedGib(body.used_bytes), + }; +} + +async function fetchClusterCpuMetric( + signal?: AbortSignal, +): Promise { + const response = await fetch("/api/metrics/cluster-cpu", { + credentials: "same-origin", + signal, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch cluster CPU metrics: ${String(response.status)}`, + ); + } + + const body = (await response.json()) as ClusterCpuResponse; + + return { + id: "cpu", + total: coresToRoundedString(body.capacity_cores), + unit: "cores", + value: coresToRoundedString(body.used_cores), + }; +} + +async function fetchClusterPodsMetric( + signal?: AbortSignal, +): Promise { + const response = await fetch("/api/metrics/cluster-pods", { + credentials: "same-origin", + signal, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch cluster pods metrics: ${String(response.status)}`, + ); + } + + const body = (await response.json()) as ClusterPodsResponse; + + return { + id: "pods", + podPhases: { + failed: body.phase_failed_pods, + pending: body.phase_pending_pods, + running: body.phase_running_pods, + succeeded: body.phase_succeeded_pods, + unknown: body.phase_unknown_pods, + }, + total: String(body.capacity_pods), + unit: "pods", + value: String(body.used_pods), + }; +} + +async function fetchClusterNodesMetric( + signal?: AbortSignal, +): Promise { + const response = await fetch("/api/metrics/cluster-nodes", { + credentials: "same-origin", + signal, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch cluster nodes metrics: ${String(response.status)}`, + ); + } + + const body = (await response.json()) as ClusterNodesResponse; + + return { + id: "nodes", + status: { + failed: body.not_ready_nodes, + healthy: body.ready_nodes, + }, + value: String(body.total_nodes), + }; +} + +function formatProvisionMinutesFromSeconds(seconds: number): string { + return (seconds / secondsPerMinute).toFixed(2); +} + +async function fetchGatewayProvisionDurationMetric( + signal?: AbortSignal, +): Promise { + try { + const response = await fetch("/api/metrics/gateway-provision-duration", { + credentials: "same-origin", + signal, + }); + if (!response.ok) { + return undefined; + } + + const body = (await response.json()) as GatewayProvisionDurationResponse; + const mean = formatProvisionMinutesFromSeconds(body.mean_seconds); + const p50 = formatProvisionMinutesFromSeconds(body.p50_seconds); + const p95 = formatProvisionMinutesFromSeconds(body.p95_seconds); + + return { + id: "provision-time", + provisionDuration: { + mean, + p50, + p95, + }, + unit: "minutes", + value: mean, + }; + } catch { + return undefined; + } +} + +function gatewayDisplayCountsToMetric( + total: number, + counts: GatewayDisplayStatusCounts, +): OperationalMetric { + return { + id: "provisioned-gateways", + status: { + degraded: counts.degraded, + failed: counts.failed, + healthy: counts.healthy, + provisioning: counts.provisioning, + }, + value: String(total), + }; +} + +interface GatewayListAggregate { + activeSandboxCount: number; + displayStatusCounts: GatewayDisplayStatusCounts; + total: number; +} + +async function aggregateGatewayList( + context: DashboardInvocationContext, + apiFactory: DashboardApiFactory, +): Promise { + const client = apiFactory(context.correlationId); + let page = 1; + let total = 0; + let activeSandboxCount = 0; + const lifecycleRecords: { phase?: string; status?: string }[] = []; + + do { + const result = await client.gateways.list( + { + orderBy: "name asc", + page, + size: gatewayListPageSize, + }, + { signal: context.signal }, + ); + + if ( + result.page !== page || + result.total < 0 || + result.items.length > + Math.max( + 0, + Math.min( + gatewayListPageSize, + result.total - (page - 1) * gatewayListPageSize, + ), + ) + ) { + throw new Error("Gateway list response was inconsistent"); + } + + for (const gateway of result.items) { + const sandboxCount = gateway.active_sandbox_count; + activeSandboxCount += typeof sandboxCount === "number" ? sandboxCount : 0; + lifecycleRecords.push({ + phase: gateway.phase, + status: gateway.status, + }); + } + + total = result.total; + page += 1; + } while ((page - 1) * gatewayListPageSize < total); + + return { + activeSandboxCount, + displayStatusCounts: aggregateGatewayDisplayStatusCounts(lifecycleRecords), + total, + }; +} + +export function createDashboardControlPlaneAdapter( + apiFactory: DashboardApiFactory, +): DashboardControlPlane { + return { + async getOperationalMetrics( + context: DashboardInvocationContext, + ): Promise { + context.signal?.throwIfAborted(); + + const aggregate = await aggregateGatewayList(context, apiFactory); + const client = apiFactory(context.correlationId); + const [ + userList, + memoryMetric, + cpuMetric, + podsMetric, + nodesMetric, + provisionTimeMetric, + ] = await Promise.all([ + client.users.list( + { orderBy: "username asc", page: 1, size: 1 }, + { signal: context.signal }, + ), + fetchClusterMemoryMetric(context.signal), + fetchClusterCpuMetric(context.signal), + fetchClusterPodsMetric(context.signal), + fetchClusterNodesMetric(context.signal), + fetchGatewayProvisionDurationMetric(context.signal), + ]); + + const metrics: OperationalMetric[] = [ + gatewayDisplayCountsToMetric( + aggregate.total, + aggregate.displayStatusCounts, + ), + { + id: "provisioned-sandboxes", + value: String(aggregate.activeSandboxCount), + }, + { + id: "registered-users", + value: String(userList.total), + }, + memoryMetric, + cpuMetric, + podsMetric, + nodesMetric, + ]; + + if (provisionTimeMetric !== undefined) { + metrics.push(provisionTimeMetric); + } + + return { + lastSuccessfulRefresh: new Date(), + metrics, + }; + }, + }; +} diff --git a/components/web-console/app/adapters/mock/dashboard-control-plane.ts b/components/web-console/app/adapters/mock/dashboard-control-plane.ts new file mode 100644 index 00000000..c1dcea8d --- /dev/null +++ b/components/web-console/app/adapters/mock/dashboard-control-plane.ts @@ -0,0 +1,20 @@ +import type { + DashboardControlPlane, + DashboardInvocationContext, +} from "@openshift-online/hypershell-operational-dashboard-ui"; +import { mockOperationalDashboardMetrics } from "@openshift-online/hypershell-operational-dashboard-ui/fixtures"; + +export function createMockDashboardControlPlane(): DashboardControlPlane { + return { + async getOperationalMetrics(context: DashboardInvocationContext) { + context.signal?.throwIfAborted(); + + await new Promise((resolve) => setTimeout(resolve, 2000)); // This is just for demos for now + + return { + ...mockOperationalDashboardMetrics, + lastSuccessfulRefresh: new Date(), + }; + }, + }; +} diff --git a/components/web-console/app/adapters/session/session-adapter.test.ts b/components/web-console/app/adapters/session/session-adapter.test.ts index 83fa0d72..a8a569b2 100644 --- a/components/web-console/app/adapters/session/session-adapter.test.ts +++ b/components/web-console/app/adapters/session/session-adapter.test.ts @@ -36,6 +36,7 @@ describe("session adapter", () => { ); expect(session).toEqual({ authenticated: true, + authEnabled: true, expiresAt: 1_723_401_600, roles: ["hypershell-admins"], user: { @@ -55,7 +56,11 @@ describe("session adapter", () => { const session = await createSessionAdapter(fetchImplementation).getSession(); - expect(session).toEqual({ authenticated: false, roles: [] }); + expect(session).toEqual({ + authenticated: false, + authEnabled: true, + roles: [], + }); }); it("treats an absent endpoint (no-auth mode) as no session", async () => { @@ -66,7 +71,11 @@ describe("session adapter", () => { const session = await createSessionAdapter(fetchImplementation).getSession(); - expect(session).toEqual({ authenticated: false, roles: [] }); + expect(session).toEqual({ + authenticated: false, + authEnabled: false, + roles: [], + }); }); it("treats a network failure as no session", async () => { @@ -77,6 +86,10 @@ describe("session adapter", () => { const session = await createSessionAdapter(fetchImplementation).getSession(); - expect(session).toEqual({ authenticated: false, roles: [] }); + expect(session).toEqual({ + authenticated: false, + authEnabled: false, + roles: [], + }); }); }); diff --git a/components/web-console/app/adapters/session/session-adapter.ts b/components/web-console/app/adapters/session/session-adapter.ts index df608f75..c9ae7d1b 100644 --- a/components/web-console/app/adapters/session/session-adapter.ts +++ b/components/web-console/app/adapters/session/session-adapter.ts @@ -15,6 +15,8 @@ export interface BrowserSessionUser { export interface BrowserSession { authenticated: boolean; + /** True when the BFF exposes `/auth/session` (OIDC mode). */ + authEnabled: boolean; expiresAt?: number; roles: string[]; user?: BrowserSessionUser; @@ -24,7 +26,17 @@ export interface SessionGateway { getSession(signal?: AbortSignal): Promise; } -const unauthenticated: BrowserSession = { authenticated: false, roles: [] }; +const noAuthSession: BrowserSession = { + authenticated: false, + authEnabled: false, + roles: [], +}; + +const unauthenticatedSession: BrowserSession = { + authenticated: false, + authEnabled: true, + roles: [], +}; function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; @@ -36,7 +48,7 @@ function toBrowserSession(body: unknown): BrowserSession { body === null || (body as { authenticated?: unknown }).authenticated !== true ) { - return unauthenticated; + return unauthenticatedSession; } const record = body as { expires_at?: unknown; @@ -67,6 +79,7 @@ function toBrowserSession(body: unknown): BrowserSession { return { authenticated: true, + authEnabled: true, ...(typeof record.expires_at === "number" ? { expiresAt: record.expires_at } : {}), @@ -91,15 +104,15 @@ export function createSessionAdapter( }); } catch { // Network failure or absent endpoint (no-auth mode): treat as no session. - return unauthenticated; + return noAuthSession; } if (!response.ok) { - return unauthenticated; + return response.status === 404 ? noAuthSession : unauthenticatedSession; } try { return toBrowserSession(await response.json()); } catch { - return unauthenticated; + return unauthenticatedSession; } }, }; diff --git a/components/web-console/app/composition/dashboard-composition.ts b/components/web-console/app/composition/dashboard-composition.ts new file mode 100644 index 00000000..25098042 --- /dev/null +++ b/components/web-console/app/composition/dashboard-composition.ts @@ -0,0 +1,10 @@ +import { createDashboardOperations } from "@openshift-online/hypershell-operational-dashboard-ui"; + +import { createApiClient } from "../adapters/api/api.client"; +import { createDashboardControlPlaneAdapter } from "../adapters/api/dashboard-control-plane"; + +export const dashboardOperations = createDashboardOperations({ + controlPlane: createDashboardControlPlaneAdapter((correlationId) => + createApiClient(correlationId), + ), +}); diff --git a/components/web-console/app/features/dashboard/operational-dashboard.stories.tsx b/components/web-console/app/features/dashboard/operational-dashboard.stories.tsx new file mode 100644 index 00000000..27db058d --- /dev/null +++ b/components/web-console/app/features/dashboard/operational-dashboard.stories.tsx @@ -0,0 +1,178 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + createDashboardOperations, + DashboardUiProvider, + OperationalDashboardPage, + type DashboardControlPlane, + type DashboardOperations, + type DashboardUiNavigation, +} from "@openshift-online/hypershell-operational-dashboard-ui"; +import { mockOperationalDashboardMetrics } from "@openshift-online/hypershell-operational-dashboard-ui/fixtures"; +import { IntlProvider } from "react-intl"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { expect, userEvent, within } from "storybook/test"; + +import { createMockDashboardControlPlane } from "../../adapters/mock/dashboard-control-plane"; +import { englishMessages } from "../../i18n/catalog"; +import { ApplicationShell } from "../shell/application-shell"; + +const stubNavigation: DashboardUiNavigation = { + collectionHref: "/", + navigate: () => undefined, +}; + +const stubDashboard = createDashboardOperations({ + controlPlane: { + getOperationalMetrics: (context) => { + context.signal?.throwIfAborted(); + return Promise.resolve(mockOperationalDashboardMetrics); + }, + }, +}); + +const mockDashboard = createDashboardOperations({ + controlPlane: createMockDashboardControlPlane(), +}); + +const initialLoadFailedDashboard = createDashboardOperations({ + controlPlane: { + getOperationalMetrics: (context) => { + context.signal?.throwIfAborted(); + return Promise.reject( + new Error("Unable to reach the operational metrics service."), + ); + }, + }, +}); + +function createRefreshFailedDashboard(): DashboardOperations { + let callCount = 0; + + const controlPlane: DashboardControlPlane = { + getOperationalMetrics: (context) => { + context.signal?.throwIfAborted(); + callCount += 1; + + if (callCount === 1) { + return Promise.resolve({ + ...mockOperationalDashboardMetrics, + lastSuccessfulRefresh: new Date(), + }); + } + + return Promise.reject( + new Error("Unable to refresh operational dashboard metrics."), + ); + }, + }; + + return createDashboardOperations({ controlPlane }); +} + +function DashboardPreview({ + metrics, + dashboard, +}: Readonly<{ + metrics?: typeof mockOperationalDashboardMetrics; + dashboard?: DashboardOperations; +}>) { + return ( + + + + ); +} + +function ShellDashboardPreview() { + return ( + + + }> + + } + /> + + + + ); +} + +const pseudoMessages = Object.fromEntries( + Object.entries(englishMessages).map(([id, message]) => [ + id, + `[${message.replaceAll("a", "à").replaceAll("e", "ë")}]`, + ]), +); + +const meta = { + title: "HyperShell/Operational dashboard", + component: OperationalDashboardPage, + parameters: { + layout: "fullscreen", + }, + render: () => , +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const MockedMetrics: Story = {}; + +export const WithRefresh: Story = { + render: () => , +}; + +export const InitialLoadFailed: Story = { + render: () => , +}; + +export const RefreshFailed: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText("Usage summary"); + await userEvent.click( + canvas.getByRole("button", { name: "Refresh dashboard metrics" }), + ); + await expect( + canvas.getByText("Could not refresh dashboard metrics"), + ).toBeVisible(); + }, +}; + +export const InShell: Story = { + render: () => , +}; + +export const PseudoLocalized: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +export const RightToLeft: Story = { + decorators: [ + (StoryComponent) => ( +
+ + + +
+ ), + ], +}; diff --git a/components/web-console/app/features/dashboard/require-dashboard-admin.test.tsx b/components/web-console/app/features/dashboard/require-dashboard-admin.test.tsx new file mode 100644 index 00000000..cea9cc7a --- /dev/null +++ b/components/web-console/app/features/dashboard/require-dashboard-admin.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { IntlProvider } from "react-intl"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { englishMessages } from "../../i18n/catalog"; + +const { getSessionMock } = vi.hoisted(() => ({ + getSessionMock: vi.fn(), +})); + +vi.mock("../../composition/session-composition", () => ({ + sessionGateway: { getSession: getSessionMock }, +})); + +import { RequireDashboardAdmin } from "./require-dashboard-admin"; + +function renderGuard() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + +
+ + + , + ); +} + +describe("RequireDashboardAdmin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders children when auth is disabled (no-auth mode)", async () => { + getSessionMock.mockResolvedValue({ + authenticated: false, + authEnabled: false, + roles: [], + }); + + renderGuard(); + + expect(await screen.findByTestId("dashboard-content")).toBeTruthy(); + }); + + it("shows access denied when auth is enabled but the session is unauthenticated", async () => { + getSessionMock.mockResolvedValue({ + authenticated: false, + authEnabled: true, + roles: [], + }); + + renderGuard(); + + expect( + await screen.findByRole("heading", { name: "Access denied" }), + ).toBeTruthy(); + expect(screen.queryByTestId("dashboard-content")).toBeNull(); + }); + + it("renders children for hypershell-admins", async () => { + getSessionMock.mockResolvedValue({ + authenticated: true, + authEnabled: true, + roles: ["hypershell-admins"], + }); + + renderGuard(); + + expect(await screen.findByTestId("dashboard-content")).toBeTruthy(); + }); + + it("renders children for platform:admin", async () => { + getSessionMock.mockResolvedValue({ + authenticated: true, + authEnabled: true, + roles: ["platform:admin"], + }); + + renderGuard(); + + expect(await screen.findByTestId("dashboard-content")).toBeTruthy(); + }); + + it("shows access denied for authenticated non-admin users", async () => { + getSessionMock.mockResolvedValue({ + authenticated: true, + authEnabled: true, + roles: ["hypershell-users"], + }); + + renderGuard(); + + expect( + await screen.findByRole("heading", { name: "Access denied" }), + ).toBeTruthy(); + expect(screen.queryByTestId("dashboard-content")).toBeNull(); + }); +}); diff --git a/components/web-console/app/features/dashboard/require-dashboard-admin.tsx b/components/web-console/app/features/dashboard/require-dashboard-admin.tsx new file mode 100644 index 00000000..3e6355f4 --- /dev/null +++ b/components/web-console/app/features/dashboard/require-dashboard-admin.tsx @@ -0,0 +1,57 @@ +import { + EmptyState, + EmptyStateBody, + EmptyStateVariant, + PageSection, + Spinner, +} from "@patternfly/react-core"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { messages } from "../../i18n/messages"; +import { hasDashboardAdminRole } from "../../lib/session-roles"; +import { useSession } from "../shell/use-session"; + +export function RequireDashboardAdmin({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const intl = useIntl(); + const { data: session, isLoading } = useSession(); + + if (isLoading) { + return ( + + + + ); + } + + const accessDenied = ( + + + } + headingLevel="h1" + > + + + + + + ); + + if (session?.authEnabled && !session.authenticated) { + return accessDenied; + } + + if (session?.authenticated && !hasDashboardAdminRole(session.roles)) { + return accessDenied; + } + + return children; +} diff --git a/components/web-console/app/features/shell/application-shell.tsx b/components/web-console/app/features/shell/application-shell.tsx index 242a80ba..73d6f89b 100644 --- a/components/web-console/app/features/shell/application-shell.tsx +++ b/components/web-console/app/features/shell/application-shell.tsx @@ -20,11 +20,16 @@ import { GatewayUiProvider, type GatewayUiNavigation, } from "@openshift-online/hypershell-gateway-management-ui"; +import { + DashboardUiProvider, + type DashboardUiNavigation, +} from "@openshift-online/hypershell-operational-dashboard-ui"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Link, Outlet, useLocation, useNavigate } from "react-router"; +import { dashboardOperations } from "../../composition/dashboard-composition"; import { gatewayOperations } from "../../composition/gateway-composition"; import { messages } from "../../i18n/messages"; import productLogo from "../../../../../images/brand/logo.png"; @@ -46,6 +51,13 @@ export function ApplicationShell() { }), [navigate], ); + const dashboardNavigation = useMemo( + () => ({ + collectionHref: "/", + navigate: (href) => navigate(href), + }), + [navigate], + ); const { scheme, toggle: toggleColorScheme } = useColorScheme(); useRouteHeadingFocus(pathname); const segments = pathname.split("/").filter(Boolean); @@ -131,19 +143,24 @@ export function ApplicationShell() { } return ( - - - - - + + + + + ); } diff --git a/components/web-console/app/i18n/messages.ts b/components/web-console/app/i18n/messages.ts index ea5163bb..d532e95a 100644 --- a/components/web-console/app/i18n/messages.ts +++ b/components/web-console/app/i18n/messages.ts @@ -11,6 +11,36 @@ export const messages = defineMessages({ defaultMessage: "Breadcrumb", description: "Accessible label for the application breadcrumb navigation.", }, + dashboardNav: { + id: "app.nav.dashboard", + defaultMessage: "Operational dashboard", + description: "Page and navigation label for the HyperShell dashboard.", + }, + dashboardPageDescription: { + id: "app.page.dashboard.description", + defaultMessage: + "Operational metrics dashboard for HyperShell adoption and provisioned resources.", + description: + "Browser metadata description for the operational dashboard page.", + }, + dashboardAccessDeniedBody: { + id: "app.page.dashboard.accessDenied.body", + defaultMessage: + "The operational dashboard is available only to HyperShell administrators.", + description: + "Recovery guidance shown when a signed-in user lacks the admin role for the dashboard.", + }, + dashboardAccessDeniedTitle: { + id: "app.page.dashboard.accessDenied.title", + defaultMessage: "Access denied", + description: + "Heading shown when a signed-in user lacks the admin role for the dashboard.", + }, + sessionLoadingLabel: { + id: "app.session.loading.ariaLabel", + defaultMessage: "Loading session", + description: "Accessible label for the session loading spinner.", + }, errorBody: { id: "app.error.body", defaultMessage: "Refresh the page to try again.", diff --git a/components/web-console/app/lib/dashboard-host.test.ts b/components/web-console/app/lib/dashboard-host.test.ts new file mode 100644 index 00000000..94d1ce1a --- /dev/null +++ b/components/web-console/app/lib/dashboard-host.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { isDashboardHost } from "./dashboard-host"; + +describe("isDashboardHost", () => { + it("matches dashboard hostnames with any domain suffix", () => { + expect(isDashboardHost("dashboard.hypershell.localhost")).toBe(true); + expect(isDashboardHost("dashboard.example.com")).toBe(true); + }); + + it("rejects non-dashboard hostnames", () => { + expect(isDashboardHost("console.hypershell.localhost")).toBe(false); + expect(isDashboardHost("hypershell.localhost")).toBe(false); + expect(isDashboardHost("not-dashboard.example.com")).toBe(false); + }); +}); diff --git a/components/web-console/app/lib/dashboard-host.ts b/components/web-console/app/lib/dashboard-host.ts new file mode 100644 index 00000000..99a9d0a4 --- /dev/null +++ b/components/web-console/app/lib/dashboard-host.ts @@ -0,0 +1,5 @@ +export const DASHBOARD_HOST_PREFIX = "dashboard."; + +export function isDashboardHost(hostname: string): boolean { + return hostname.startsWith(DASHBOARD_HOST_PREFIX); +} diff --git a/components/web-console/app/lib/session-roles.test.ts b/components/web-console/app/lib/session-roles.test.ts new file mode 100644 index 00000000..7b5f728d --- /dev/null +++ b/components/web-console/app/lib/session-roles.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { hasDashboardAdminRole } from "./session-roles"; + +describe("hasDashboardAdminRole", () => { + it("returns true when hypershell-admins is present", () => { + expect( + hasDashboardAdminRole(["hypershell-users", "hypershell-admins"]), + ).toBe(true); + }); + + it("returns true when platform:admin is present", () => { + expect(hasDashboardAdminRole(["hypershell-users", "platform:admin"])).toBe( + true, + ); + }); + + it("returns false for non-admin roles", () => { + expect(hasDashboardAdminRole(["hypershell-users", "gateway:creator"])).toBe( + false, + ); + }); +}); diff --git a/components/web-console/app/lib/session-roles.ts b/components/web-console/app/lib/session-roles.ts new file mode 100644 index 00000000..613193aa --- /dev/null +++ b/components/web-console/app/lib/session-roles.ts @@ -0,0 +1,5 @@ +export { + HYPERSHELL_ADMIN_ROLE, + PLATFORM_ADMIN_ROLE, + hasDashboardAdminRole, +} from "../../shared/dashboard-roles"; diff --git a/components/web-console/app/routes.ts b/components/web-console/app/routes.ts index ea92b299..c61734eb 100644 --- a/components/web-console/app/routes.ts +++ b/components/web-console/app/routes.ts @@ -11,6 +11,7 @@ export default [ route(routeContract.login, "./routes/login.tsx"), layout("./routes/application.tsx", [ index("./routes/home.tsx"), + route(routeContract.dashboard, "./routes/dashboard.tsx"), route(routeContract.gatewayNew, "./routes/gateway-new.tsx"), route(routeContract.gatewayDetail, "./routes/gateway.tsx"), route(routeContract.metrics, "./routes/metrics.tsx"), diff --git a/components/web-console/app/routes/dashboard.tsx b/components/web-console/app/routes/dashboard.tsx new file mode 100644 index 00000000..a10096af --- /dev/null +++ b/components/web-console/app/routes/dashboard.tsx @@ -0,0 +1,17 @@ +import { OperationalDashboardPage } from "@openshift-online/hypershell-operational-dashboard-ui"; + +import { RequireDashboardAdmin } from "../features/dashboard/require-dashboard-admin"; +import { createPageMeta } from "../lib/page-meta"; + +export const meta = createPageMeta( + "app.nav.dashboard", + "app.page.dashboard.description", +); + +export default function DashboardRoute() { + return ( + + + + ); +} diff --git a/components/web-console/app/routes/home.tsx b/components/web-console/app/routes/home.tsx index bd342380..26a9b804 100644 --- a/components/web-console/app/routes/home.tsx +++ b/components/web-console/app/routes/home.tsx @@ -1,10 +1,13 @@ import { GatewaysPage } from "@openshift-online/hypershell-gateway-management-ui"; +import { OperationalDashboardPage } from "@openshift-online/hypershell-operational-dashboard-ui"; import { useLocation, useNavigate, useSearchParams } from "react-router"; +import { RequireDashboardAdmin } from "../features/dashboard/require-dashboard-admin"; import { parseGatewayListState, serializeGatewayListState, } from "../features/gateways/gateway-list-state"; +import { isDashboardHost } from "../lib/dashboard-host"; import { createPageMeta } from "../lib/page-meta"; export const meta = createPageMeta( @@ -16,6 +19,13 @@ export default function HomeRoute() { const location = useLocation(); const navigate = useNavigate(); const [searchParameters, setSearchParameters] = useSearchParams(); + if (isDashboardHost(globalThis.location.hostname)) { + return ( + + + + ); + } const collectionState = parseGatewayListState(searchParameters); const deletedGatewayName = typeof (location.state as { deletedGatewayName?: unknown } | null) diff --git a/components/web-console/app/routes/metrics.tsx b/components/web-console/app/routes/metrics.tsx index 7c00cca5..0c88d3c1 100644 --- a/components/web-console/app/routes/metrics.tsx +++ b/components/web-console/app/routes/metrics.tsx @@ -1,5 +1,6 @@ import { GatewayMetricsDashboard } from "@openshift-online/hypershell-gateway-management-ui"; +import { RequireDashboardAdmin } from "../features/dashboard/require-dashboard-admin"; import { createPageMeta } from "../lib/page-meta"; export const meta = createPageMeta( @@ -8,5 +9,9 @@ export const meta = createPageMeta( ); export default function MetricsRoute() { - return ; + return ( + + + + ); } diff --git a/components/web-console/bff/package.json b/components/web-console/bff/package.json index 93da6534..0354ac76 100644 --- a/components/web-console/bff/package.json +++ b/components/web-console/bff/package.json @@ -13,7 +13,7 @@ "dev": "tsx watch src/index.ts", "format:check": "prettier --check .", "lint": "eslint . --max-warnings=0", - "start": "node dist/index.js", + "start": "node dist/bff/src/index.js", "test:run": "vitest run", "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.test.json --noEmit" }, diff --git a/components/web-console/bff/src/app.ts b/components/web-console/bff/src/app.ts index 838ef601..d058fc85 100644 --- a/components/web-console/bff/src/app.ts +++ b/components/web-console/bff/src/app.ts @@ -5,14 +5,26 @@ import path from "node:path"; import compress from "@fastify/compress"; import helmet from "@fastify/helmet"; import fastifyStatic from "@fastify/static"; -import Fastify, { type FastifyInstance, LogController } from "fastify"; +import Fastify, { + type FastifyInstance, + type FastifyReply, + type FastifyRequest, + LogController, +} from "fastify"; import { clearSession, persistTokenSet, registerAuth } from "./auth.js"; +import { hasDashboardAdminRole } from "./roles.js"; import { browserRuntimeConfig, type BrowserRuntimeConfig, type ServerConfig, } from "./config.js"; +import { queryGatewayPhaseCounts } from "./metrics-gateways.js"; +import { queryClusterCpu } from "./metrics-cluster-cpu.js"; +import { queryClusterMemory } from "./metrics-cluster-memory.js"; +import { queryGatewayProvisionDuration } from "./metrics-gateway-provision-duration.js"; +import { queryClusterPods } from "./metrics-cluster-pods.js"; +import { queryClusterNodes } from "./metrics-cluster-nodes.js"; import { tokenExpired } from "./tokens.js"; import { disabledTracing, @@ -47,6 +59,7 @@ declare module "fastify" { function isApplicationRoute(pathname: string): boolean { return ( pathname === "/" || + pathname === "/dashboard" || pathname === "/login" || pathname === "/gateways/new" || pathname === "/metrics" || @@ -54,6 +67,42 @@ function isApplicationRoute(pathname: string): boolean { ); } +const DASHBOARD_HOST_PREFIX = "dashboard."; + +function isDashboardHost(hostHeader: string | undefined): boolean { + if (typeof hostHeader !== "string") { + return false; + } + + const host = hostHeader.split(":")[0] ?? ""; + return host.startsWith(DASHBOARD_HOST_PREFIX); +} + +function consoleRedirectForDashboardHost(request: FastifyRequest): string { + const hostHeader = request.headers.host ?? ""; + const [, port] = hostHeader.split(":"); + const host = hostHeader.split(":")[0] ?? ""; + const portSuffix = port ? `:${port}` : ""; + + if (host.startsWith(DASHBOARD_HOST_PREFIX)) { + const rest = host.slice(DASHBOARD_HOST_PREFIX.length); + return `${request.protocol}://console.${rest}${portSuffix}/`; + } + + return "/"; +} + +function requiresDashboardAdminAccess( + pathname: string, + hostHeader: string | undefined, +): boolean { + return ( + pathname === "/dashboard" || + pathname === "/metrics" || + (pathname === "/" && isDashboardHost(hostHeader)) + ); +} + function proxyBody( method: string, body: unknown, @@ -287,6 +336,17 @@ export async function buildApp( reply.redirect("/auth/login"); return; } + if ( + requiresDashboardAdminAccess(pathname, request.headers.host) && + !hasDashboardAdminRole(request.session.get("roles") ?? []) + ) { + reply.redirect( + pathname === "/" && isDashboardHost(request.headers.host) + ? consoleRedirectForDashboardHost(request) + : "/", + ); + return; + } } }); } @@ -316,12 +376,28 @@ export async function buildApp( }); const sendApplication = ( - _request: unknown, + request: FastifyRequest, reply: { header(name: string, value: string): unknown; + redirect(location: string): unknown; type(value: string): { send(payload: string): unknown }; }, ) => { + const pathname = new URL(request.url, "http://bff.invalid").pathname; + // Dashboard admin enforcement applies only when OIDC is configured. In + // no-auth dev mode (OIDC_ISSUER unset) the operational dashboard is open. + if ( + config.oidcIssuer && + requiresDashboardAdminAccess(pathname, request.headers.host) && + !hasDashboardAdminRole(request.session.get("roles") ?? []) + ) { + return reply.redirect( + pathname === "/" && isDashboardHost(request.headers.host) + ? consoleRedirectForDashboardHost(request) + : "/", + ); + } + reply.header("Cache-Control", "no-store"); return reply.type("text/html; charset=utf-8").send(indexDocument); }; @@ -343,6 +419,22 @@ export async function buildApp( }; }; + const requireDashboardMetricsAccess = async ( + request: FastifyRequest, + reply: FastifyReply, + ) => { + reply.header("Cache-Control", "no-store"); + if (!config.oidcIssuer) { + return; + } + if (!request.session.get("accessToken")) { + return reply.send(respondReauth(reply)); + } + if (!hasDashboardAdminRole(request.session.get("roles") ?? [])) { + return reply.code(403).send({ error: "Forbidden", statusCode: 403 }); + } + }; + // Same-origin browser telemetry ingest. The browser exporter posts OTLP/HTTP // JSON here; the BFF validates it and relays it to the configured collector, // keeping the collector origin out of the browser and reusing the session and @@ -365,6 +457,112 @@ export async function buildApp( return { status: "accepted" }; }); + app.get( + "/api/metrics/gateways", + { preHandler: requireDashboardMetricsAccess }, + async (request, reply) => { + try { + const counts = await queryGatewayPhaseCounts( + config.prometheusUrl, + config.prometheusQueryTimeoutMs, + ); + return { counts }; + } catch (error) { + request.log.warn({ err: error }, "gateway metrics query failed"); + reply.code(502); + return { error: "Metrics unavailable", statusCode: 502 }; + } + }, + ); + + app.get( + "/api/metrics/cluster-memory", + { preHandler: requireDashboardMetricsAccess }, + async (request, reply) => { + try { + return await queryClusterMemory( + config.prometheusUrl, + config.prometheusQueryTimeoutMs, + ); + } catch (error) { + request.log.warn({ err: error }, "cluster memory metrics query failed"); + reply.code(502); + return { error: "Metrics unavailable", statusCode: 502 }; + } + }, + ); + + app.get( + "/api/metrics/cluster-cpu", + { preHandler: requireDashboardMetricsAccess }, + async (request, reply) => { + try { + return await queryClusterCpu( + config.prometheusUrl, + config.prometheusQueryTimeoutMs, + ); + } catch (error) { + request.log.warn({ err: error }, "cluster CPU metrics query failed"); + reply.code(502); + return { error: "Metrics unavailable", statusCode: 502 }; + } + }, + ); + + app.get( + "/api/metrics/cluster-pods", + { preHandler: requireDashboardMetricsAccess }, + async (request, reply) => { + try { + return await queryClusterPods( + config.prometheusUrl, + config.prometheusQueryTimeoutMs, + ); + } catch (error) { + request.log.warn({ err: error }, "cluster pods metrics query failed"); + reply.code(502); + return { error: "Metrics unavailable", statusCode: 502 }; + } + }, + ); + + app.get( + "/api/metrics/cluster-nodes", + { preHandler: requireDashboardMetricsAccess }, + async (request, reply) => { + try { + return await queryClusterNodes( + config.prometheusUrl, + config.prometheusQueryTimeoutMs, + ); + } catch (error) { + request.log.warn({ err: error }, "cluster nodes metrics query failed"); + reply.code(502); + return { error: "Metrics unavailable", statusCode: 502 }; + } + }, + ); + + app.get( + "/api/metrics/gateway-provision-duration", + { preHandler: requireDashboardMetricsAccess }, + async (request, reply) => { + try { + return await queryGatewayProvisionDuration( + config.prometheusUrl, + config.prometheusQueryTimeoutMs, + ); + } catch (error) { + request.log.warn( + { err: error }, + "gateway provision duration metrics query failed", + ); + reply.code(502); + return { error: "Metrics unavailable", statusCode: 502 }; + } + }, + ); + app.all("/api/*", async (request, reply) => { // Start one BFF server span per proxied request. It continues a valid // inbound W3C context and yields the validated upstream context to set on diff --git a/components/web-console/bff/src/auth-roles.test.ts b/components/web-console/bff/src/auth-roles.test.ts new file mode 100644 index 00000000..b981d1d6 --- /dev/null +++ b/components/web-console/bff/src/auth-roles.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { extractRealmRoles } from "./auth.js"; + +describe("extractRealmRoles", () => { + it("reads roles from the roles claim", () => { + expect( + extractRealmRoles({ + roles: ["hypershell-admins", "hypershell-users"], + }), + ).toEqual(["hypershell-admins", "hypershell-users"]); + }); + + it("reads realm roles from the groups claim used by HyperShell Keycloak", () => { + expect( + extractRealmRoles({ + groups: ["hypershell-admins", "hypershell-users"], + }), + ).toEqual(["hypershell-admins", "hypershell-users"]); + }); + + it("normalizes leading slashes on groups claim values", () => { + expect( + extractRealmRoles({ + groups: ["/hypershell-admins", "/hypershell-users"], + }), + ).toEqual(["hypershell-admins", "hypershell-users"]); + }); + + it("prefers the roles claim over groups and realm_access", () => { + expect( + extractRealmRoles({ + groups: ["hypershell-users"], + realm_access: { roles: ["platform:admin"] }, + roles: ["hypershell-admins"], + }), + ).toEqual(["hypershell-admins"]); + }); + + it("falls back to realm_access.roles when roles and groups are absent", () => { + expect( + extractRealmRoles({ + realm_access: { roles: ["platform:admin", "hypershell-users"] }, + }), + ).toEqual(["platform:admin", "hypershell-users"]); + }); + + it("ignores non-string role entries", () => { + expect( + extractRealmRoles({ + roles: ["hypershell-admins", 42, null], + }), + ).toEqual(["hypershell-admins"]); + }); +}); diff --git a/components/web-console/bff/src/auth.ts b/components/web-console/bff/src/auth.ts index bf53deb5..29db6993 100644 --- a/components/web-console/bff/src/auth.ts +++ b/components/web-console/bff/src/auth.ts @@ -65,6 +65,64 @@ export function persistTokenSet( } } +function normalizeRoleName(role: string): string { + return role.startsWith("/") ? role.slice(1) : role; +} + +function readStringClaim( + claims: Record, + claimName: string, +): string[] | undefined { + const rawRoles = claims[claimName]; + if (!Array.isArray(rawRoles)) { + return undefined; + } + return rawRoles + .filter((role): role is string => typeof role === "string") + .map(normalizeRoleName); +} + +function readRealmAccessRoles(claims: Record): string[] { + const realmAccess = claims.realm_access; + if ( + typeof realmAccess !== "object" || + realmAccess === null || + Array.isArray(realmAccess) + ) { + return []; + } + const rawRoles = (realmAccess as Record).roles; + if (!Array.isArray(rawRoles)) { + return []; + } + return rawRoles + .filter((role): role is string => typeof role === "string") + .map(normalizeRoleName); +} + +/** + * Reads realm roles from OIDC claims emitted by HyperShell Keycloak. + * + * The hypershell-frontend client maps realm roles into the top-level `groups` + * claim on ID tokens via `oidc-usermodel-realm-role-mapper` (not Keycloak group + * paths). Access tokens may also carry `realm_access.roles`. The `roles` claim + * is a BFF/session convention when present. Group-path values such as + * `/hypershell-admins` are normalized by stripping a leading slash. + */ +export function extractRealmRoles(claims: Record): string[] { + const roles = readStringClaim(claims, "roles"); + if (roles !== undefined) { + return roles; + } + + const groups = readStringClaim(claims, "groups"); + if (groups !== undefined) { + return groups; + } + + return readRealmAccessRoles(claims); +} + /** Clears both session cookies on terminal authentication failure. */ export function clearSession(request: { session: secureSession.Session; @@ -223,11 +281,7 @@ export async function registerAuth( if (typeof claims.name === "string") { request.session.set("name", claims.name); } - const rawRoles = claims.roles; - const roles = Array.isArray(rawRoles) - ? rawRoles.filter((r): r is string => typeof r === "string") - : []; - request.session.set("roles", roles); + request.session.set("roles", extractRealmRoles(claims)); } request.session.options({ maxAge: config.sessionTtlSeconds }); diff --git a/components/web-console/bff/src/config.ts b/components/web-console/bff/src/config.ts index 229c0b19..cd6b0efb 100644 --- a/components/web-console/bff/src/config.ts +++ b/components/web-console/bff/src/config.ts @@ -57,6 +57,13 @@ const configSchema = z.object({ OIDC_POST_LOGOUT_REDIRECT_URI: httpUrl.optional(), OIDC_REDIRECT_URI: httpUrl.optional(), PORT: z.coerce.number().int().min(1).max(65_535).default(8080), + PROMETHEUS_QUERY_TIMEOUT_MS: z.coerce + .number() + .int() + .min(100) + .max(120_000) + .default(10_000), + PROMETHEUS_URL: httpOrigin.default("http://127.0.0.1:9090"), SESSION_SECRET: z .string() .regex(/^[0-9a-f]{64}$/iu, "must be a 64-character hex string (32 bytes)") @@ -93,6 +100,8 @@ export interface ServerConfig { oidcPostLogoutRedirectUri?: string; oidcRedirectUri?: string; port: number; + prometheusQueryTimeoutMs: number; + prometheusUrl: string; sessionSecret?: Buffer; sessionTtlSeconds: number; staticRoot: string; @@ -169,6 +178,8 @@ export function loadConfig( oidcPostLogoutRedirectUri: result.data.OIDC_POST_LOGOUT_REDIRECT_URI, oidcRedirectUri: result.data.OIDC_REDIRECT_URI, port: result.data.PORT, + prometheusQueryTimeoutMs: result.data.PROMETHEUS_QUERY_TIMEOUT_MS, + prometheusUrl: result.data.PROMETHEUS_URL, sessionSecret: result.data.SESSION_SECRET ? Buffer.from(result.data.SESSION_SECRET, "hex") : undefined, diff --git a/components/web-console/bff/src/dashboard-roles.ts b/components/web-console/bff/src/dashboard-roles.ts new file mode 100644 index 00000000..5586e960 --- /dev/null +++ b/components/web-console/bff/src/dashboard-roles.ts @@ -0,0 +1,5 @@ +export { + HYPERSHELL_ADMIN_ROLE, + PLATFORM_ADMIN_ROLE, + hasDashboardAdminRole, +} from "../../shared/dashboard-roles.js"; diff --git a/components/web-console/bff/src/metrics-cluster-cpu.ts b/components/web-console/bff/src/metrics-cluster-cpu.ts new file mode 100644 index 00000000..414ac302 --- /dev/null +++ b/components/web-console/bff/src/metrics-cluster-cpu.ts @@ -0,0 +1,94 @@ +export const clusterCpuCapacityPromql = + 'sum(count by (instance) (node_cpu_seconds_total{mode="idle"}))'; +export const clusterCpuUsedPromql = + 'sum(rate(node_cpu_seconds_total{mode!="idle"}[5m]))'; + +const usedExceedsCapacityToleranceCores = 0.01; + +export interface ClusterCpuCores { + available_cores: number; + capacity_cores: number; + used_cores: number; +} + +interface PrometheusQueryResponse { + status: string; + data?: { + result: { + value: [string, string]; + }[]; + }; +} + +async function queryPrometheusInstant( + prometheusUrl: string, + query: string, + timeoutMs: number, +): Promise { + const queryUrl = new URL("/api/v1/query", prometheusUrl); + queryUrl.searchParams.set("query", query); + + const controller = new AbortController(); + const timeoutReason = new Error("Prometheus query timed out"); + const timeout = setTimeout(() => { + controller.abort(timeoutReason); + }, timeoutMs); + + try { + const response = await fetch(queryUrl, { signal: controller.signal }); + if (!response.ok) { + throw new Error("Prometheus query request failed"); + } + + const body = (await response.json()) as PrometheusQueryResponse; + if (body.status !== "success") { + throw new Error("Prometheus query returned non-success status"); + } + + const samples = body.data?.result ?? []; + if (samples.length === 0) { + return 0; + } + + const sample = samples[0]; + const rawValue = sample?.value[1]; + if (rawValue === undefined) { + throw new Error("Prometheus query returned invalid sample"); + } + + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + throw new Error("Prometheus query returned invalid sample"); + } + + return value; + } finally { + clearTimeout(timeout); + } +} + +export async function queryClusterCpu( + prometheusUrl: string, + timeoutMs: number, +): Promise { + const [capacity_cores, used_cores] = await Promise.all([ + queryPrometheusInstant(prometheusUrl, clusterCpuCapacityPromql, timeoutMs), + queryPrometheusInstant(prometheusUrl, clusterCpuUsedPromql, timeoutMs), + ]); + + if (capacity_cores === 0) { + throw new Error("No cluster CPU capacity data"); + } + + if (used_cores > capacity_cores + usedExceedsCapacityToleranceCores) { + throw new Error("Inconsistent cluster CPU samples"); + } + + const available_cores = capacity_cores - used_cores; + + return { + available_cores, + capacity_cores, + used_cores, + }; +} diff --git a/components/web-console/bff/src/metrics-cluster-memory.ts b/components/web-console/bff/src/metrics-cluster-memory.ts new file mode 100644 index 00000000..3c4aef04 --- /dev/null +++ b/components/web-console/bff/src/metrics-cluster-memory.ts @@ -0,0 +1,99 @@ +export const clusterMemoryCapacityPromql = "sum(node_memory_MemTotal_bytes)"; +export const clusterMemoryAvailablePromql = + "sum(node_memory_MemAvailable_bytes)"; + +export interface ClusterMemoryBytes { + available_bytes: number; + capacity_bytes: number; + used_bytes: number; +} + +interface PrometheusQueryResponse { + status: string; + data?: { + result: { + value: [string, string]; + }[]; + }; +} + +async function queryPrometheusInstant( + prometheusUrl: string, + query: string, + timeoutMs: number, +): Promise { + const queryUrl = new URL("/api/v1/query", prometheusUrl); + queryUrl.searchParams.set("query", query); + + const controller = new AbortController(); + const timeoutReason = new Error("Prometheus query timed out"); + const timeout = setTimeout(() => { + controller.abort(timeoutReason); + }, timeoutMs); + + try { + const response = await fetch(queryUrl, { signal: controller.signal }); + if (!response.ok) { + throw new Error("Prometheus query request failed"); + } + + const body = (await response.json()) as PrometheusQueryResponse; + if (body.status !== "success") { + throw new Error("Prometheus query returned non-success status"); + } + + const samples = body.data?.result ?? []; + if (samples.length === 0) { + return 0; + } + + const sample = samples[0]; + const rawValue = sample?.value[1]; + if (rawValue === undefined) { + throw new Error("Prometheus query returned invalid sample"); + } + + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + throw new Error("Prometheus query returned invalid sample"); + } + + return Math.round(value); + } finally { + clearTimeout(timeout); + } +} + +export async function queryClusterMemory( + prometheusUrl: string, + timeoutMs: number, +): Promise { + const [capacity_bytes, available_bytes] = await Promise.all([ + queryPrometheusInstant( + prometheusUrl, + clusterMemoryCapacityPromql, + timeoutMs, + ), + queryPrometheusInstant( + prometheusUrl, + clusterMemoryAvailablePromql, + timeoutMs, + ), + ]); + + if (capacity_bytes === 0) { + throw new Error("No cluster memory capacity data"); + } + + if (available_bytes > capacity_bytes) { + throw new Error("Inconsistent cluster memory samples"); + } + + const used_bytes = capacity_bytes - available_bytes; + + return { + available_bytes, + capacity_bytes, + used_bytes, + }; +} diff --git a/components/web-console/bff/src/metrics-cluster-nodes.ts b/components/web-console/bff/src/metrics-cluster-nodes.ts new file mode 100644 index 00000000..46bac702 --- /dev/null +++ b/components/web-console/bff/src/metrics-cluster-nodes.ts @@ -0,0 +1,91 @@ +export const clusterNodesTotalPromql = "count(kube_node_info)"; +export const clusterNodesReadyPromql = + 'sum(kube_node_status_condition{condition="Ready",status="true"})'; + +export interface ClusterNodesCounts { + not_ready_nodes: number; + ready_nodes: number; + total_nodes: number; +} + +interface PrometheusQueryResponse { + status: string; + data?: { + result: { + value: [string, string]; + }[]; + }; +} + +async function queryPrometheusInstant( + prometheusUrl: string, + query: string, + timeoutMs: number, +): Promise { + const queryUrl = new URL("/api/v1/query", prometheusUrl); + queryUrl.searchParams.set("query", query); + + const controller = new AbortController(); + const timeoutReason = new Error("Prometheus query timed out"); + const timeout = setTimeout(() => { + controller.abort(timeoutReason); + }, timeoutMs); + + try { + const response = await fetch(queryUrl, { signal: controller.signal }); + if (!response.ok) { + throw new Error("Prometheus query request failed"); + } + + const body = (await response.json()) as PrometheusQueryResponse; + if (body.status !== "success") { + throw new Error("Prometheus query returned non-success status"); + } + + const samples = body.data?.result ?? []; + if (samples.length === 0) { + return 0; + } + + const sample = samples[0]; + const rawValue = sample?.value[1]; + if (rawValue === undefined) { + throw new Error("Prometheus query returned invalid sample"); + } + + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + throw new Error("Prometheus query returned invalid sample"); + } + + return Math.round(value); + } finally { + clearTimeout(timeout); + } +} + +export async function queryClusterNodes( + prometheusUrl: string, + timeoutMs: number, +): Promise { + const [total_nodes, ready_nodes] = await Promise.all([ + queryPrometheusInstant(prometheusUrl, clusterNodesTotalPromql, timeoutMs), + queryPrometheusInstant(prometheusUrl, clusterNodesReadyPromql, timeoutMs), + ]); + + if (total_nodes === 0) { + throw new Error("No cluster node data"); + } + + if (ready_nodes > total_nodes) { + throw new Error("Inconsistent cluster node samples"); + } + + const not_ready_nodes = total_nodes - ready_nodes; + + return { + not_ready_nodes, + ready_nodes, + total_nodes, + }; +} diff --git a/components/web-console/bff/src/metrics-cluster-pods.ts b/components/web-console/bff/src/metrics-cluster-pods.ts new file mode 100644 index 00000000..a95c2ace --- /dev/null +++ b/components/web-console/bff/src/metrics-cluster-pods.ts @@ -0,0 +1,160 @@ +export const clusterPodsCapacityPromql = + 'sum(kube_node_status_allocatable{resource="pods"})'; +export const clusterPodsUsedPromql = "count(kube_pod_info)"; + +export type ClusterPodPhase = + "Failed" | "Pending" | "Running" | "Succeeded" | "Unknown"; + +export const CLUSTER_POD_PHASES = [ + "Pending", + "Running", + "Succeeded", + "Failed", + "Unknown", +] as const satisfies readonly ClusterPodPhase[]; + +export function clusterPodPhasePromql(phase: ClusterPodPhase): string { + return `sum(kube_pod_status_phase{phase="${phase}"})`; +} + +export interface ClusterPodsCounts { + available_pods: number; + capacity_pods: number; + phase_failed_pods: number; + phase_pending_pods: number; + phase_running_pods: number; + phase_succeeded_pods: number; + phase_unknown_pods: number; + used_pods: number; +} + +interface PrometheusQueryResponse { + status: string; + data?: { + result: { + value: [string, string]; + }[]; + }; +} + +async function queryPrometheusInstant( + prometheusUrl: string, + query: string, + timeoutMs: number, +): Promise { + const queryUrl = new URL("/api/v1/query", prometheusUrl); + queryUrl.searchParams.set("query", query); + + const controller = new AbortController(); + const timeoutReason = new Error("Prometheus query timed out"); + const timeout = setTimeout(() => { + controller.abort(timeoutReason); + }, timeoutMs); + + try { + const response = await fetch(queryUrl, { signal: controller.signal }); + if (!response.ok) { + throw new Error("Prometheus query request failed"); + } + + const body = (await response.json()) as PrometheusQueryResponse; + if (body.status !== "success") { + throw new Error("Prometheus query returned non-success status"); + } + + const samples = body.data?.result ?? []; + if (samples.length === 0) { + return 0; + } + + const sample = samples[0]; + const rawValue = sample?.value[1]; + if (rawValue === undefined) { + throw new Error("Prometheus query returned invalid sample"); + } + + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + throw new Error("Prometheus query returned invalid sample"); + } + + return Math.round(value); + } finally { + clearTimeout(timeout); + } +} + +export async function queryClusterPods( + prometheusUrl: string, + timeoutMs: number, +): Promise { + const [ + capacity_pods, + used_pods, + phase_pending_pods, + phase_running_pods, + phase_succeeded_pods, + phase_failed_pods, + phase_unknown_pods, + ] = await Promise.all([ + queryPrometheusInstant(prometheusUrl, clusterPodsCapacityPromql, timeoutMs), + queryPrometheusInstant(prometheusUrl, clusterPodsUsedPromql, timeoutMs), + queryPrometheusInstant( + prometheusUrl, + clusterPodPhasePromql("Pending"), + timeoutMs, + ), + queryPrometheusInstant( + prometheusUrl, + clusterPodPhasePromql("Running"), + timeoutMs, + ), + queryPrometheusInstant( + prometheusUrl, + clusterPodPhasePromql("Succeeded"), + timeoutMs, + ), + queryPrometheusInstant( + prometheusUrl, + clusterPodPhasePromql("Failed"), + timeoutMs, + ), + queryPrometheusInstant( + prometheusUrl, + clusterPodPhasePromql("Unknown"), + timeoutMs, + ), + ]); + + if (capacity_pods === 0) { + throw new Error("No cluster pod capacity data"); + } + + if (used_pods > capacity_pods) { + throw new Error("Inconsistent cluster pod samples"); + } + + const phaseTotal = + phase_pending_pods + + phase_running_pods + + phase_succeeded_pods + + phase_failed_pods + + phase_unknown_pods; + + if (phaseTotal !== used_pods) { + throw new Error("Inconsistent cluster pod phase samples"); + } + + const available_pods = capacity_pods - used_pods; + + return { + available_pods, + capacity_pods, + phase_failed_pods, + phase_pending_pods, + phase_running_pods, + phase_succeeded_pods, + phase_unknown_pods, + used_pods, + }; +} diff --git a/components/web-console/bff/src/metrics-gateway-provision-duration.ts b/components/web-console/bff/src/metrics-gateway-provision-duration.ts new file mode 100644 index 00000000..2dd64e8e --- /dev/null +++ b/components/web-console/bff/src/metrics-gateway-provision-duration.ts @@ -0,0 +1,113 @@ +export const gatewayProvisionDurationCountPromql = + "gateway_provision_duration_seconds_count"; +export const gatewayProvisionDurationMeanPromql = + "gateway_provision_duration_seconds_sum / gateway_provision_duration_seconds_count"; +export const gatewayProvisionDurationP50Promql = + "histogram_quantile(0.50, sum(gateway_provision_duration_seconds_bucket) by (le))"; +export const gatewayProvisionDurationP95Promql = + "histogram_quantile(0.95, sum(gateway_provision_duration_seconds_bucket) by (le))"; + +export interface GatewayProvisionDurationSeconds { + mean_seconds: number; + observation_count: number; + p50_seconds: number; + p95_seconds: number; +} + +interface PrometheusQueryResponse { + status: string; + data?: { + result: { + value: [string, string]; + }[]; + }; +} + +async function queryPrometheusInstantNumber( + prometheusUrl: string, + query: string, + timeoutMs: number, +): Promise { + const queryUrl = new URL("/api/v1/query", prometheusUrl); + queryUrl.searchParams.set("query", query); + + const controller = new AbortController(); + const timeoutReason = new Error("Prometheus query timed out"); + const timeout = setTimeout(() => { + controller.abort(timeoutReason); + }, timeoutMs); + + try { + const response = await fetch(queryUrl, { signal: controller.signal }); + if (!response.ok) { + throw new Error("Prometheus query request failed"); + } + + const body = (await response.json()) as PrometheusQueryResponse; + if (body.status !== "success") { + throw new Error("Prometheus query returned non-success status"); + } + + const samples = body.data?.result ?? []; + if (samples.length === 0) { + throw new Error("Prometheus query returned no samples"); + } + + const sample = samples[0]; + const rawValue = sample?.value[1]; + if (rawValue === undefined) { + throw new Error("Prometheus query returned invalid sample"); + } + + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + throw new Error("Prometheus query returned invalid sample"); + } + + return value; + } finally { + clearTimeout(timeout); + } +} + +export async function queryGatewayProvisionDuration( + prometheusUrl: string, + timeoutMs: number, +): Promise { + const observation_count = Math.round( + await queryPrometheusInstantNumber( + prometheusUrl, + gatewayProvisionDurationCountPromql, + timeoutMs, + ), + ); + + if (observation_count === 0) { + throw new Error("No gateway provision duration observations"); + } + + const [mean_seconds, p50_seconds, p95_seconds] = await Promise.all([ + queryPrometheusInstantNumber( + prometheusUrl, + gatewayProvisionDurationMeanPromql, + timeoutMs, + ), + queryPrometheusInstantNumber( + prometheusUrl, + gatewayProvisionDurationP50Promql, + timeoutMs, + ), + queryPrometheusInstantNumber( + prometheusUrl, + gatewayProvisionDurationP95Promql, + timeoutMs, + ), + ]); + + return { + mean_seconds, + observation_count, + p50_seconds, + p95_seconds, + }; +} diff --git a/components/web-console/bff/src/metrics-gateways.ts b/components/web-console/bff/src/metrics-gateways.ts new file mode 100644 index 00000000..d9cfd9ae --- /dev/null +++ b/components/web-console/bff/src/metrics-gateways.ts @@ -0,0 +1,65 @@ +import { + emptyGatewayPhaseCounts, + gatewayCanonicalPhaseStrings, + type GatewayCanonicalPhase, +} from "../../shared/gateway-phases.js"; + +export type GatewayPhaseCounts = Record; + +export const gatewayPhases = gatewayCanonicalPhaseStrings; + +export { emptyGatewayPhaseCounts }; + +interface PrometheusQueryResponse { + status: string; + data?: { + result: { + metric: { phase?: string }; + value: [string, string]; + }[]; + }; +} + +function isGatewayMetricPhase( + value: string, +): value is keyof GatewayPhaseCounts { + return (gatewayPhases as readonly string[]).includes(value); +} + +export async function queryGatewayPhaseCounts( + prometheusUrl: string, + timeoutMs: number, +): Promise { + const queryUrl = new URL("/api/v1/query", prometheusUrl); + queryUrl.searchParams.set("query", "hypershell_gateways_total"); + + const controller = new AbortController(); + const timeoutReason = new Error("Prometheus query timed out"); + const timeout = setTimeout(() => { + controller.abort(timeoutReason); + }, timeoutMs); + + try { + const response = await fetch(queryUrl, { signal: controller.signal }); + if (!response.ok) { + throw new Error("Prometheus query request failed"); + } + + const body = (await response.json()) as PrometheusQueryResponse; + if (body.status !== "success") { + throw new Error("Prometheus query returned non-success status"); + } + + const counts = emptyGatewayPhaseCounts(); + for (const sample of body.data?.result ?? []) { + const phase = sample.metric.phase; + if (phase === undefined || !isGatewayMetricPhase(phase)) { + continue; + } + counts[phase] = Math.round(Number(sample.value[1])); + } + return counts; + } finally { + clearTimeout(timeout); + } +} diff --git a/components/web-console/bff/src/roles.test.ts b/components/web-console/bff/src/roles.test.ts new file mode 100644 index 00000000..0e900ca9 --- /dev/null +++ b/components/web-console/bff/src/roles.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { hasDashboardAdminRole } from "./roles.js"; + +describe("hasDashboardAdminRole", () => { + it("returns true when hypershell-admins is present", () => { + expect( + hasDashboardAdminRole(["hypershell-users", "hypershell-admins"]), + ).toBe(true); + }); + + it("returns true when platform:admin is present", () => { + expect(hasDashboardAdminRole(["hypershell-users", "platform:admin"])).toBe( + true, + ); + }); + + it("returns false for non-admin roles", () => { + expect(hasDashboardAdminRole(["hypershell-users", "gateway:creator"])).toBe( + false, + ); + }); +}); diff --git a/components/web-console/bff/src/roles.ts b/components/web-console/bff/src/roles.ts new file mode 100644 index 00000000..4faa32ac --- /dev/null +++ b/components/web-console/bff/src/roles.ts @@ -0,0 +1,5 @@ +export { + HYPERSHELL_ADMIN_ROLE, + PLATFORM_ADMIN_ROLE, + hasDashboardAdminRole, +} from "./dashboard-roles.js"; diff --git a/components/web-console/bff/test/app-tracing.test.ts b/components/web-console/bff/test/app-tracing.test.ts index 7463a2c8..fa7c72f4 100644 --- a/components/web-console/bff/test/app-tracing.test.ts +++ b/components/web-console/bff/test/app-tracing.test.ts @@ -91,6 +91,8 @@ describe("web-console BFF tracing wiring", () => { logLevel: "silent", nodeEnv: "test", port: 8080, + prometheusQueryTimeoutMs: 10_000, + prometheusUrl: "http://127.0.0.1:9090", sessionTtlSeconds: 28_800, staticRoot, }; diff --git a/components/web-console/bff/test/app.test.ts b/components/web-console/bff/test/app.test.ts index 60e2b48a..999f88e2 100644 --- a/components/web-console/bff/test/app.test.ts +++ b/components/web-console/bff/test/app.test.ts @@ -88,6 +88,8 @@ describe("web-console BFF", () => { logLevel: "silent", nodeEnv: "test", port: 8080, + prometheusQueryTimeoutMs: 10_000, + prometheusUrl: "http://127.0.0.1:9090", sessionTtlSeconds: 28_800, staticRoot, }; @@ -170,6 +172,8 @@ describe("web-console BFF", () => { logLevel: "silent", nodeEnv: "test", port: 8080, + prometheusQueryTimeoutMs: 10_000, + prometheusUrl: "http://127.0.0.1:9090", sessionTtlSeconds: 28_800, staticRoot, tracing: { diff --git a/components/web-console/bff/test/auth.test.ts b/components/web-console/bff/test/auth.test.ts index 43212dce..a0ca9ad2 100644 --- a/components/web-console/bff/test/auth.test.ts +++ b/components/web-console/bff/test/auth.test.ts @@ -277,6 +277,8 @@ describe("web-console BFF with OIDC enabled", () => { oidcIssuer: `http://127.0.0.1:${String(oidcCtx.port)}`, oidcRedirectUri: `http://127.0.0.1:8080/auth/callback`, port: 8080, + prometheusQueryTimeoutMs: 10_000, + prometheusUrl: "http://127.0.0.1:9090", sessionSecret: Buffer.from(testSessionSecret, "hex"), sessionTtlSeconds: 28_800, staticRoot, @@ -783,13 +785,134 @@ describe("web-console BFF with OIDC enabled", () => { // ----------------------------------------------------------------------- it("redirects unauthenticated GETs to application routes to /auth/login", async () => { - for (const route of ["/", "/gateways/new", "/gateways/gw-1"]) { + for (const route of [ + "/", + "/dashboard", + "/metrics", + "/gateways/new", + "/gateways/gw-1", + ]) { const response = await app.inject({ method: "GET", url: route }); expect(response.statusCode, route).toBe(302); expect(response.headers.location, route).toBe("/auth/login"); } }); + it("redirects non-admin users away from /dashboard", async () => { + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-users"], + }); + const cookie = `session=${encodeURIComponent(app.encodeSecureSession(session))}`; + const response = await app.inject({ + headers: { cookie }, + method: "GET", + url: "/dashboard", + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe("/"); + }); + + it("redirects non-admin users away from /metrics", async () => { + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-users"], + }); + const cookie = `session=${encodeURIComponent(app.encodeSecureSession(session))}`; + const response = await app.inject({ + headers: { cookie }, + method: "GET", + url: "/metrics", + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe("/"); + }); + + it("rejects non-admin callers from dashboard metrics routes", async () => { + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-users"], + }); + const cookie = `session=${encodeURIComponent(app.encodeSecureSession(session))}`; + + for (const route of [ + "/api/metrics/gateways", + "/api/metrics/cluster-memory", + "/api/metrics/cluster-cpu", + "/api/metrics/cluster-pods", + "/api/metrics/cluster-nodes", + "/api/metrics/gateway-provision-duration", + ]) { + const response = await app.inject({ + headers: { cookie }, + method: "GET", + url: route, + }); + + expect(response.statusCode, route).toBe(403); + expect(response.json(), route).toEqual({ + error: "Forbidden", + statusCode: 403, + }); + } + }); + + it("serves /dashboard to hypershell-admins", async () => { + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-admins"], + }); + const cookie = `session=${encodeURIComponent(app.encodeSecureSession(session))}`; + const response = await app.inject({ + headers: { cookie }, + method: "GET", + url: "/dashboard", + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("text/html"); + }); + + it("serves /dashboard to platform:admin", async () => { + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["platform:admin"], + }); + const cookie = `session=${encodeURIComponent(app.encodeSecureSession(session))}`; + const response = await app.inject({ + headers: { cookie }, + method: "GET", + url: "/dashboard", + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("text/html"); + }); + + it("serves /metrics to hypershell-admins", async () => { + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-admins"], + }); + const cookie = `session=${encodeURIComponent(app.encodeSecureSession(session))}`; + const response = await app.inject({ + headers: { cookie }, + method: "GET", + url: "/metrics", + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("text/html"); + }); + it("serves application routes when authenticated", async () => { const cookie = authenticateSession(); const response = await app.inject({ diff --git a/components/web-console/bff/test/config.test.ts b/components/web-console/bff/test/config.test.ts index d2fd10f8..b3abf5cd 100644 --- a/components/web-console/bff/test/config.test.ts +++ b/components/web-console/bff/test/config.test.ts @@ -34,6 +34,36 @@ describe("loadConfig", () => { ).toThrow(/HYPERSHELL_API_ORIGIN/u); }); + it("rejects a Prometheus URL that is not an origin", () => { + expect(() => + loadConfig({ PROMETHEUS_URL: "http://127.0.0.1:9090/metrics" }), + ).toThrow(/PROMETHEUS_URL/u); + }); + + it("normalizes the Prometheus origin", () => { + const config = loadConfig({ + PROMETHEUS_URL: "http://127.0.0.1:9090/", + STATIC_ROOT: "./public", + }); + + expect(config.prometheusUrl).toBe("http://127.0.0.1:9090"); + }); + + it("accepts a custom Prometheus query timeout", () => { + const config = loadConfig({ + PROMETHEUS_QUERY_TIMEOUT_MS: "15000", + STATIC_ROOT: "./public", + }); + + expect(config.prometheusQueryTimeoutMs).toBe(15_000); + }); + + it("defaults the Prometheus query timeout to ten seconds", () => { + const config = loadConfig({ STATIC_ROOT: "./public" }); + + expect(config.prometheusQueryTimeoutMs).toBe(10_000); + }); + it("leaves tracing disabled when no collector endpoint is set", () => { const config = loadConfig({ STATIC_ROOT: "./public" }); diff --git a/components/web-console/bff/test/metrics-cluster-cpu-route.test.ts b/components/web-console/bff/test/metrics-cluster-cpu-route.test.ts new file mode 100644 index 00000000..60ed59d4 --- /dev/null +++ b/components/web-console/bff/test/metrics-cluster-cpu-route.test.ts @@ -0,0 +1,326 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import type { ServerConfig } from "../src/config.js"; +import { + clusterCpuCapacityPromql, + clusterCpuUsedPromql, +} from "../src/metrics-cluster-cpu.js"; + +const testSessionSecret = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +function prometheusSample(value: string) { + return JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: {}, + value: ["1704067200", value], + }, + ], + }, + }); +} + +function createOidcServer(): Promise<{ close: () => void; issuer: string }> { + const server = createServer((request, response) => { + const address = server.address(); + if (address === null || typeof address === "string") { + response.statusCode = 500; + response.end(); + return; + } + const origin = `http://127.0.0.1:${String(address.port)}`; + const url = new URL(request.url ?? "/", origin); + if (url.pathname === "/.well-known/openid-configuration") { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + authorization_endpoint: `${origin}/authorize`, + issuer: origin, + jwks_uri: `${origin}/jwks`, + token_endpoint: `${origin}/token`, + }), + ); + return; + } + if (url.pathname === "/jwks") { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ keys: [] })); + return; + } + response.statusCode = 404; + response.end(); + }); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + resolve({ + close: () => server.close(), + issuer: `http://127.0.0.1:${String(address.port)}`, + }); + }); + }); +} + +describe("GET /api/metrics/cluster-cpu", () => { + let app: FastifyInstance; + let apiServer: Server; + let prometheusServer: Server | undefined; + let oidcServer: { close: () => void; issuer: string } | undefined; + let staticRoot: string; + let apiOrigin = ""; + + beforeEach(async () => { + apiServer = createServer((_request, response) => { + response.setHeader("content-type", "application/json"); + response.end('{"kind":"GatewayList","items":[]}'); + }); + await new Promise((resolve) => { + apiServer.listen(0, "127.0.0.1", resolve); + }); + const apiAddress = apiServer.address(); + if (apiAddress === null || typeof apiAddress === "string") { + throw new Error("expected tcp listener address"); + } + apiOrigin = `http://127.0.0.1:${String(apiAddress.port)}`; + + staticRoot = await mkdtemp(path.join(tmpdir(), "hypershell-web-console-")); + await mkdir(path.join(staticRoot, "assets")); + await writeFile( + path.join(staticRoot, "index.html"), + "
App
", + ); + }); + + afterEach(async () => { + await app.close(); + await new Promise((resolve, reject) => { + apiServer.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + if (prometheusServer !== undefined) { + await new Promise((resolve, reject) => { + prometheusServer?.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + prometheusServer = undefined; + } + oidcServer?.close(); + oidcServer = undefined; + }); + + async function buildTestApp( + overrides: Partial = {}, + ): Promise { + const config: ServerConfig = { + apiOrigin, + apiTimeoutMs: 5_000, + host: "127.0.0.1", + logLevel: "silent", + nodeEnv: "test", + port: 8080, + prometheusQueryTimeoutMs: 10_000, + prometheusUrl: "http://127.0.0.1:9090", + sessionTtlSeconds: 28_800, + staticRoot, + ...overrides, + }; + return buildApp(config); + } + + async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, + ): Promise { + prometheusServer = createServer(handler); + await new Promise((resolve) => { + prometheusServer?.listen(0, "127.0.0.1", resolve); + }); + const address = prometheusServer.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return `http://127.0.0.1:${String(address.port)}`; + } + + it("returns cluster CPU cores when Prometheus succeeds", async () => { + const prometheusUrl = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterCpuCapacityPromql) { + response.end(prometheusSample("60")); + return; + } + if (query === clusterCpuUsedPromql) { + response.end(prometheusSample("48")); + return; + } + response.statusCode = 400; + response.end(); + }); + + app = await buildTestApp({ prometheusUrl }); + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-cpu", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + available_cores: 12, + capacity_cores: 60, + used_cores: 48, + }); + }); + + it("returns 502 when Prometheus fails", async () => { + const prometheusUrl = await startPrometheusStub((_request, response) => { + response.statusCode = 500; + response.end(); + }); + + app = await buildTestApp({ prometheusUrl }); + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-cpu", + }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ + error: "Metrics unavailable", + statusCode: 502, + }); + }); + + it("requires a session when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-cpu", + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ + error: "reauth_required", + statusCode: 401, + }); + }); + + it("allows dashboard administrators when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + const prometheusUrl = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterCpuCapacityPromql) { + response.end(prometheusSample("8")); + return; + } + if (query === clusterCpuUsedPromql) { + response.end(prometheusSample("3.5")); + return; + } + response.statusCode = 400; + response.end(); + }); + + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + prometheusUrl, + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const session = app.createSecureSession({ + accessToken: "test-access-token", + email: "test@example.com", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + name: "Test User", + preferredUsername: "testuser", + roles: ["hypershell-admins"], + sub: "user-123", + }); + + const response = await app.inject({ + headers: { + cookie: `session=${encodeURIComponent(app.encodeSecureSession(session))}`, + }, + method: "GET", + url: "/api/metrics/cluster-cpu", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + available_cores: 4.5, + capacity_cores: 8, + used_cores: 3.5, + }); + }); + + it("rejects authenticated non-admin callers when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-users"], + sub: "user-123", + }); + + const response = await app.inject({ + headers: { + cookie: `session=${encodeURIComponent(app.encodeSecureSession(session))}`, + }, + method: "GET", + url: "/api/metrics/cluster-cpu", + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ + error: "Forbidden", + statusCode: 403, + }); + }); +}); diff --git a/components/web-console/bff/test/metrics-cluster-cpu.test.ts b/components/web-console/bff/test/metrics-cluster-cpu.test.ts new file mode 100644 index 00000000..a7ef5198 --- /dev/null +++ b/components/web-console/bff/test/metrics-cluster-cpu.test.ts @@ -0,0 +1,165 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { + clusterCpuCapacityPromql, + clusterCpuUsedPromql, + queryClusterCpu, +} from "../src/metrics-cluster-cpu.js"; + +async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, +): Promise<{ close: () => void; port: number }> { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return { + close: () => server.close(), + port: address.port, + }; +} + +function prometheusSample(value: string) { + return JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: {}, + value: ["1704067200", value], + }, + ], + }, + }); +} + +describe("queryClusterCpu", () => { + it("maps Prometheus capacity and used samples into fractional cores", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterCpuCapacityPromql) { + response.end(prometheusSample("60")); + return; + } + if (query === clusterCpuUsedPromql) { + response.end(prometheusSample("48")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + const cpu = await queryClusterCpu( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ); + expect(cpu).toEqual({ + available_cores: 12, + capacity_cores: 60, + used_cores: 48, + }); + } finally { + prometheus.close(); + } + }); + + it("preserves fractional used cores from Prometheus", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterCpuCapacityPromql) { + response.end(prometheusSample("60")); + return; + } + if (query === clusterCpuUsedPromql) { + response.end(prometheusSample("48.2")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + const cpu = await queryClusterCpu( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ); + expect(cpu.used_cores).toBe(48.2); + expect(cpu.capacity_cores).toBe(60); + expect(cpu.available_cores).toBeCloseTo(11.8); + } finally { + prometheus.close(); + } + }); + + it("fails when Prometheus returns no capacity samples", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterCpuCapacityPromql) { + response.end( + JSON.stringify({ + status: "success", + data: { result: [] }, + }), + ); + return; + } + if (query === clusterCpuUsedPromql) { + response.end(prometheusSample("48")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryClusterCpu(`http://127.0.0.1:${String(prometheus.port)}`, 5_000), + ).rejects.toThrow("No cluster CPU capacity data"); + } finally { + prometheus.close(); + } + }); + + it("fails when used exceeds capacity beyond tolerance", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterCpuCapacityPromql) { + response.end(prometheusSample("60")); + return; + } + if (query === clusterCpuUsedPromql) { + response.end(prometheusSample("60.02")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryClusterCpu(`http://127.0.0.1:${String(prometheus.port)}`, 5_000), + ).rejects.toThrow("Inconsistent cluster CPU samples"); + } finally { + prometheus.close(); + } + }); +}); diff --git a/components/web-console/bff/test/metrics-cluster-memory-route.test.ts b/components/web-console/bff/test/metrics-cluster-memory-route.test.ts new file mode 100644 index 00000000..20cc4174 --- /dev/null +++ b/components/web-console/bff/test/metrics-cluster-memory-route.test.ts @@ -0,0 +1,326 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import type { ServerConfig } from "../src/config.js"; +import { + clusterMemoryAvailablePromql, + clusterMemoryCapacityPromql, +} from "../src/metrics-cluster-memory.js"; + +const testSessionSecret = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +function prometheusSample(value: string) { + return JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: {}, + value: ["1704067200", value], + }, + ], + }, + }); +} + +function createOidcServer(): Promise<{ close: () => void; issuer: string }> { + const server = createServer((request, response) => { + const address = server.address(); + if (address === null || typeof address === "string") { + response.statusCode = 500; + response.end(); + return; + } + const origin = `http://127.0.0.1:${String(address.port)}`; + const url = new URL(request.url ?? "/", origin); + if (url.pathname === "/.well-known/openid-configuration") { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + authorization_endpoint: `${origin}/authorize`, + issuer: origin, + jwks_uri: `${origin}/jwks`, + token_endpoint: `${origin}/token`, + }), + ); + return; + } + if (url.pathname === "/jwks") { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ keys: [] })); + return; + } + response.statusCode = 404; + response.end(); + }); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + resolve({ + close: () => server.close(), + issuer: `http://127.0.0.1:${String(address.port)}`, + }); + }); + }); +} + +describe("GET /api/metrics/cluster-memory", () => { + let app: FastifyInstance; + let apiServer: Server; + let prometheusServer: Server | undefined; + let oidcServer: { close: () => void; issuer: string } | undefined; + let staticRoot: string; + let apiOrigin = ""; + + beforeEach(async () => { + apiServer = createServer((_request, response) => { + response.setHeader("content-type", "application/json"); + response.end('{"kind":"GatewayList","items":[]}'); + }); + await new Promise((resolve) => { + apiServer.listen(0, "127.0.0.1", resolve); + }); + const apiAddress = apiServer.address(); + if (apiAddress === null || typeof apiAddress === "string") { + throw new Error("expected tcp listener address"); + } + apiOrigin = `http://127.0.0.1:${String(apiAddress.port)}`; + + staticRoot = await mkdtemp(path.join(tmpdir(), "hypershell-web-console-")); + await mkdir(path.join(staticRoot, "assets")); + await writeFile( + path.join(staticRoot, "index.html"), + "
App
", + ); + }); + + afterEach(async () => { + await app.close(); + await new Promise((resolve, reject) => { + apiServer.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + if (prometheusServer !== undefined) { + await new Promise((resolve, reject) => { + prometheusServer?.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + prometheusServer = undefined; + } + oidcServer?.close(); + oidcServer = undefined; + }); + + async function buildTestApp( + overrides: Partial = {}, + ): Promise { + const config: ServerConfig = { + apiOrigin, + apiTimeoutMs: 5_000, + host: "127.0.0.1", + logLevel: "silent", + nodeEnv: "test", + port: 8080, + prometheusQueryTimeoutMs: 10_000, + prometheusUrl: "http://127.0.0.1:9090", + sessionTtlSeconds: 28_800, + staticRoot, + ...overrides, + }; + return buildApp(config); + } + + async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, + ): Promise { + prometheusServer = createServer(handler); + await new Promise((resolve) => { + prometheusServer?.listen(0, "127.0.0.1", resolve); + }); + const address = prometheusServer.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return `http://127.0.0.1:${String(address.port)}`; + } + + it("returns cluster memory bytes when Prometheus succeeds", async () => { + const prometheusUrl = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterMemoryCapacityPromql) { + response.end(prometheusSample("17179869184")); + return; + } + if (query === clusterMemoryAvailablePromql) { + response.end(prometheusSample("4294967296")); + return; + } + response.statusCode = 400; + response.end(); + }); + + app = await buildTestApp({ prometheusUrl }); + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-memory", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + available_bytes: 4294967296, + capacity_bytes: 17179869184, + used_bytes: 12884901888, + }); + }); + + it("returns 502 when Prometheus fails", async () => { + const prometheusUrl = await startPrometheusStub((_request, response) => { + response.statusCode = 500; + response.end(); + }); + + app = await buildTestApp({ prometheusUrl }); + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-memory", + }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ + error: "Metrics unavailable", + statusCode: 502, + }); + }); + + it("requires a session when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-memory", + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ + error: "reauth_required", + statusCode: 401, + }); + }); + + it("allows dashboard administrators when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + const prometheusUrl = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterMemoryCapacityPromql) { + response.end(prometheusSample("1000")); + return; + } + if (query === clusterMemoryAvailablePromql) { + response.end(prometheusSample("250")); + return; + } + response.statusCode = 400; + response.end(); + }); + + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + prometheusUrl, + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const session = app.createSecureSession({ + accessToken: "test-access-token", + email: "test@example.com", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + name: "Test User", + preferredUsername: "testuser", + roles: ["hypershell-admins"], + sub: "user-123", + }); + + const response = await app.inject({ + headers: { + cookie: `session=${encodeURIComponent(app.encodeSecureSession(session))}`, + }, + method: "GET", + url: "/api/metrics/cluster-memory", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + available_bytes: 250, + capacity_bytes: 1000, + used_bytes: 750, + }); + }); + + it("rejects authenticated non-admin callers when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-users"], + sub: "user-123", + }); + + const response = await app.inject({ + headers: { + cookie: `session=${encodeURIComponent(app.encodeSecureSession(session))}`, + }, + method: "GET", + url: "/api/metrics/cluster-memory", + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ + error: "Forbidden", + statusCode: 403, + }); + }); +}); diff --git a/components/web-console/bff/test/metrics-cluster-memory.test.ts b/components/web-console/bff/test/metrics-cluster-memory.test.ts new file mode 100644 index 00000000..394e45b7 --- /dev/null +++ b/components/web-console/bff/test/metrics-cluster-memory.test.ts @@ -0,0 +1,141 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { + clusterMemoryAvailablePromql, + clusterMemoryCapacityPromql, + queryClusterMemory, +} from "../src/metrics-cluster-memory.js"; + +async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, +): Promise<{ close: () => void; port: number }> { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return { + close: () => server.close(), + port: address.port, + }; +} + +function prometheusSample(value: string) { + return JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: {}, + value: ["1704067200", value], + }, + ], + }, + }); +} + +describe("queryClusterMemory", () => { + it("maps Prometheus capacity and available samples into bytes", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterMemoryCapacityPromql) { + response.end(prometheusSample("17179869184")); + return; + } + if (query === clusterMemoryAvailablePromql) { + response.end(prometheusSample("4294967296")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + const memory = await queryClusterMemory( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ); + expect(memory).toEqual({ + available_bytes: 4294967296, + capacity_bytes: 17179869184, + used_bytes: 12884901888, + }); + } finally { + prometheus.close(); + } + }); + + it("fails when Prometheus returns no capacity samples", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterMemoryCapacityPromql) { + response.end( + JSON.stringify({ + status: "success", + data: { result: [] }, + }), + ); + return; + } + if (query === clusterMemoryAvailablePromql) { + response.end(prometheusSample("4294967296")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryClusterMemory( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ), + ).rejects.toThrow("No cluster memory capacity data"); + } finally { + prometheus.close(); + } + }); + + it("fails when available exceeds capacity", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterMemoryCapacityPromql) { + response.end(prometheusSample("1000")); + return; + } + if (query === clusterMemoryAvailablePromql) { + response.end(prometheusSample("2000")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryClusterMemory( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ), + ).rejects.toThrow("Inconsistent cluster memory samples"); + } finally { + prometheus.close(); + } + }); +}); diff --git a/components/web-console/bff/test/metrics-cluster-nodes-route.test.ts b/components/web-console/bff/test/metrics-cluster-nodes-route.test.ts new file mode 100644 index 00000000..8f304ae2 --- /dev/null +++ b/components/web-console/bff/test/metrics-cluster-nodes-route.test.ts @@ -0,0 +1,326 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import type { ServerConfig } from "../src/config.js"; +import { + clusterNodesReadyPromql, + clusterNodesTotalPromql, +} from "../src/metrics-cluster-nodes.js"; + +const testSessionSecret = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +function prometheusSample(value: string) { + return JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: {}, + value: ["1704067200", value], + }, + ], + }, + }); +} + +function createOidcServer(): Promise<{ close: () => void; issuer: string }> { + const server = createServer((request, response) => { + const address = server.address(); + if (address === null || typeof address === "string") { + response.statusCode = 500; + response.end(); + return; + } + const origin = `http://127.0.0.1:${String(address.port)}`; + const url = new URL(request.url ?? "/", origin); + if (url.pathname === "/.well-known/openid-configuration") { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + authorization_endpoint: `${origin}/authorize`, + issuer: origin, + jwks_uri: `${origin}/jwks`, + token_endpoint: `${origin}/token`, + }), + ); + return; + } + if (url.pathname === "/jwks") { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ keys: [] })); + return; + } + response.statusCode = 404; + response.end(); + }); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + resolve({ + close: () => server.close(), + issuer: `http://127.0.0.1:${String(address.port)}`, + }); + }); + }); +} + +describe("GET /api/metrics/cluster-nodes", () => { + let app: FastifyInstance; + let apiServer: Server; + let prometheusServer: Server | undefined; + let oidcServer: { close: () => void; issuer: string } | undefined; + let staticRoot: string; + let apiOrigin = ""; + + beforeEach(async () => { + apiServer = createServer((_request, response) => { + response.setHeader("content-type", "application/json"); + response.end('{"kind":"GatewayList","items":[]}'); + }); + await new Promise((resolve) => { + apiServer.listen(0, "127.0.0.1", resolve); + }); + const apiAddress = apiServer.address(); + if (apiAddress === null || typeof apiAddress === "string") { + throw new Error("expected tcp listener address"); + } + apiOrigin = `http://127.0.0.1:${String(apiAddress.port)}`; + + staticRoot = await mkdtemp(path.join(tmpdir(), "hypershell-web-console-")); + await mkdir(path.join(staticRoot, "assets")); + await writeFile( + path.join(staticRoot, "index.html"), + "
App
", + ); + }); + + afterEach(async () => { + await app.close(); + await new Promise((resolve, reject) => { + apiServer.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + if (prometheusServer !== undefined) { + await new Promise((resolve, reject) => { + prometheusServer?.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + prometheusServer = undefined; + } + oidcServer?.close(); + oidcServer = undefined; + }); + + async function buildTestApp( + overrides: Partial = {}, + ): Promise { + const config: ServerConfig = { + apiOrigin, + apiTimeoutMs: 5_000, + host: "127.0.0.1", + logLevel: "silent", + nodeEnv: "test", + port: 8080, + prometheusQueryTimeoutMs: 10_000, + prometheusUrl: "http://127.0.0.1:9090", + sessionTtlSeconds: 28_800, + staticRoot, + ...overrides, + }; + return buildApp(config); + } + + async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, + ): Promise { + prometheusServer = createServer(handler); + await new Promise((resolve) => { + prometheusServer?.listen(0, "127.0.0.1", resolve); + }); + const address = prometheusServer.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return `http://127.0.0.1:${String(address.port)}`; + } + + it("returns cluster node counts when Prometheus succeeds", async () => { + const prometheusUrl = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterNodesTotalPromql) { + response.end(prometheusSample("8")); + return; + } + if (query === clusterNodesReadyPromql) { + response.end(prometheusSample("7")); + return; + } + response.statusCode = 400; + response.end(); + }); + + app = await buildTestApp({ prometheusUrl }); + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-nodes", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + not_ready_nodes: 1, + ready_nodes: 7, + total_nodes: 8, + }); + }); + + it("returns 502 when Prometheus fails", async () => { + const prometheusUrl = await startPrometheusStub((_request, response) => { + response.statusCode = 500; + response.end(); + }); + + app = await buildTestApp({ prometheusUrl }); + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-nodes", + }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ + error: "Metrics unavailable", + statusCode: 502, + }); + }); + + it("requires a session when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-nodes", + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ + error: "reauth_required", + statusCode: 401, + }); + }); + + it("allows dashboard administrators when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + const prometheusUrl = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterNodesTotalPromql) { + response.end(prometheusSample("1")); + return; + } + if (query === clusterNodesReadyPromql) { + response.end(prometheusSample("1")); + return; + } + response.statusCode = 400; + response.end(); + }); + + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + prometheusUrl, + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const session = app.createSecureSession({ + accessToken: "test-access-token", + email: "test@example.com", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + name: "Test User", + preferredUsername: "testuser", + roles: ["hypershell-admins"], + sub: "user-123", + }); + + const response = await app.inject({ + headers: { + cookie: `session=${encodeURIComponent(app.encodeSecureSession(session))}`, + }, + method: "GET", + url: "/api/metrics/cluster-nodes", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + not_ready_nodes: 0, + ready_nodes: 1, + total_nodes: 1, + }); + }); + + it("rejects authenticated non-admin callers when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-users"], + sub: "user-123", + }); + + const response = await app.inject({ + headers: { + cookie: `session=${encodeURIComponent(app.encodeSecureSession(session))}`, + }, + method: "GET", + url: "/api/metrics/cluster-nodes", + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ + error: "Forbidden", + statusCode: 403, + }); + }); +}); diff --git a/components/web-console/bff/test/metrics-cluster-nodes.test.ts b/components/web-console/bff/test/metrics-cluster-nodes.test.ts new file mode 100644 index 00000000..92b0ae6a --- /dev/null +++ b/components/web-console/bff/test/metrics-cluster-nodes.test.ts @@ -0,0 +1,135 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { + clusterNodesReadyPromql, + clusterNodesTotalPromql, + queryClusterNodes, +} from "../src/metrics-cluster-nodes.js"; + +async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, +): Promise<{ close: () => void; port: number }> { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return { + close: () => server.close(), + port: address.port, + }; +} + +function prometheusSample(value: string) { + return JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: {}, + value: ["1704067200", value], + }, + ], + }, + }); +} + +describe("queryClusterNodes", () => { + it("maps Prometheus total and ready samples into node counts", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterNodesTotalPromql) { + response.end(prometheusSample("8")); + return; + } + if (query === clusterNodesReadyPromql) { + response.end(prometheusSample("7")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + const nodes = await queryClusterNodes( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ); + expect(nodes).toEqual({ + not_ready_nodes: 1, + ready_nodes: 7, + total_nodes: 8, + }); + } finally { + prometheus.close(); + } + }); + + it("fails when Prometheus returns no total node samples", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterNodesTotalPromql) { + response.end( + JSON.stringify({ + status: "success", + data: { result: [] }, + }), + ); + return; + } + if (query === clusterNodesReadyPromql) { + response.end(prometheusSample("0")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryClusterNodes(`http://127.0.0.1:${String(prometheus.port)}`, 5_000), + ).rejects.toThrow("No cluster node data"); + } finally { + prometheus.close(); + } + }); + + it("fails when ready exceeds total", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterNodesTotalPromql) { + response.end(prometheusSample("3")); + return; + } + if (query === clusterNodesReadyPromql) { + response.end(prometheusSample("4")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryClusterNodes(`http://127.0.0.1:${String(prometheus.port)}`, 5_000), + ).rejects.toThrow("Inconsistent cluster node samples"); + } finally { + prometheus.close(); + } + }); +}); diff --git a/components/web-console/bff/test/metrics-cluster-pods-route.test.ts b/components/web-console/bff/test/metrics-cluster-pods-route.test.ts new file mode 100644 index 00000000..cc808363 --- /dev/null +++ b/components/web-console/bff/test/metrics-cluster-pods-route.test.ts @@ -0,0 +1,381 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import type { ServerConfig } from "../src/config.js"; +import { + clusterPodPhasePromql, + clusterPodsCapacityPromql, + clusterPodsUsedPromql, + type ClusterPodPhase, +} from "../src/metrics-cluster-pods.js"; + +const testSessionSecret = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +function prometheusSample(value: string) { + return JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: {}, + value: ["1704067200", value], + }, + ], + }, + }); +} + +const defaultPhaseCounts: Record = { + Failed: "16", + Pending: "12", + Running: "500", + Succeeded: "20", + Unknown: "0", +}; + +function handleClusterPodsQuery( + query: string | null, + response: ServerResponse, + options: { + capacity?: string; + phases?: Partial>; + used?: string; + } = {}, +): boolean { + response.setHeader("content-type", "application/json"); + if (query === clusterPodsCapacityPromql) { + response.end(prometheusSample(options.capacity ?? "2000")); + return true; + } + if (query === clusterPodsUsedPromql) { + response.end(prometheusSample(options.used ?? "548")); + return true; + } + for (const phase of [ + "Pending", + "Running", + "Succeeded", + "Failed", + "Unknown", + ] as const) { + if (query === clusterPodPhasePromql(phase)) { + response.end( + prometheusSample(options.phases?.[phase] ?? defaultPhaseCounts[phase]), + ); + return true; + } + } + return false; +} + +function createOidcServer(): Promise<{ close: () => void; issuer: string }> { + const server = createServer((request, response) => { + const address = server.address(); + if (address === null || typeof address === "string") { + response.statusCode = 500; + response.end(); + return; + } + const origin = `http://127.0.0.1:${String(address.port)}`; + const url = new URL(request.url ?? "/", origin); + if (url.pathname === "/.well-known/openid-configuration") { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + authorization_endpoint: `${origin}/authorize`, + issuer: origin, + jwks_uri: `${origin}/jwks`, + token_endpoint: `${origin}/token`, + }), + ); + return; + } + if (url.pathname === "/jwks") { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ keys: [] })); + return; + } + response.statusCode = 404; + response.end(); + }); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + resolve({ + close: () => server.close(), + issuer: `http://127.0.0.1:${String(address.port)}`, + }); + }); + }); +} + +describe("GET /api/metrics/cluster-pods", () => { + let app: FastifyInstance; + let apiServer: Server; + let prometheusServer: Server | undefined; + let oidcServer: { close: () => void; issuer: string } | undefined; + let staticRoot: string; + let apiOrigin = ""; + + beforeEach(async () => { + apiServer = createServer((_request, response) => { + response.setHeader("content-type", "application/json"); + response.end('{"kind":"GatewayList","items":[]}'); + }); + await new Promise((resolve) => { + apiServer.listen(0, "127.0.0.1", resolve); + }); + const apiAddress = apiServer.address(); + if (apiAddress === null || typeof apiAddress === "string") { + throw new Error("expected tcp listener address"); + } + apiOrigin = `http://127.0.0.1:${String(apiAddress.port)}`; + + staticRoot = await mkdtemp(path.join(tmpdir(), "hypershell-web-console-")); + await mkdir(path.join(staticRoot, "assets")); + await writeFile( + path.join(staticRoot, "index.html"), + "
App
", + ); + }); + + afterEach(async () => { + await app.close(); + await new Promise((resolve, reject) => { + apiServer.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + if (prometheusServer !== undefined) { + await new Promise((resolve, reject) => { + prometheusServer?.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + prometheusServer = undefined; + } + oidcServer?.close(); + oidcServer = undefined; + }); + + async function buildTestApp( + overrides: Partial = {}, + ): Promise { + const config: ServerConfig = { + apiOrigin, + apiTimeoutMs: 5_000, + host: "127.0.0.1", + logLevel: "silent", + nodeEnv: "test", + port: 8080, + prometheusQueryTimeoutMs: 10_000, + prometheusUrl: "http://127.0.0.1:9090", + sessionTtlSeconds: 28_800, + staticRoot, + ...overrides, + }; + return buildApp(config); + } + + async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, + ): Promise { + prometheusServer = createServer(handler); + await new Promise((resolve) => { + prometheusServer?.listen(0, "127.0.0.1", resolve); + }); + const address = prometheusServer.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return `http://127.0.0.1:${String(address.port)}`; + } + + it("returns cluster pod counts when Prometheus succeeds", async () => { + const prometheusUrl = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + if (handleClusterPodsQuery(query, response)) { + return; + } + response.statusCode = 400; + response.end(); + }); + + app = await buildTestApp({ prometheusUrl }); + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-pods", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + available_pods: 1452, + capacity_pods: 2000, + phase_failed_pods: 16, + phase_pending_pods: 12, + phase_running_pods: 500, + phase_succeeded_pods: 20, + phase_unknown_pods: 0, + used_pods: 548, + }); + }); + + it("returns 502 when Prometheus fails", async () => { + const prometheusUrl = await startPrometheusStub((_request, response) => { + response.statusCode = 500; + response.end(); + }); + + app = await buildTestApp({ prometheusUrl }); + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-pods", + }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ + error: "Metrics unavailable", + statusCode: 502, + }); + }); + + it("requires a session when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const response = await app.inject({ + method: "GET", + url: "/api/metrics/cluster-pods", + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ + error: "reauth_required", + statusCode: 401, + }); + }); + + it("allows dashboard administrators when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + const prometheusUrl = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + if ( + handleClusterPodsQuery(query, response, { + capacity: "100", + phases: { + Failed: "0", + Pending: "2", + Running: "40", + Succeeded: "0", + Unknown: "0", + }, + used: "42", + }) + ) { + return; + } + response.statusCode = 400; + response.end(); + }); + + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + prometheusUrl, + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const session = app.createSecureSession({ + accessToken: "test-access-token", + email: "test@example.com", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + name: "Test User", + preferredUsername: "testuser", + roles: ["hypershell-admins"], + sub: "user-123", + }); + + const response = await app.inject({ + headers: { + cookie: `session=${encodeURIComponent(app.encodeSecureSession(session))}`, + }, + method: "GET", + url: "/api/metrics/cluster-pods", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + available_pods: 58, + capacity_pods: 100, + phase_failed_pods: 0, + phase_pending_pods: 2, + phase_running_pods: 40, + phase_succeeded_pods: 0, + phase_unknown_pods: 0, + used_pods: 42, + }); + }); + + it("rejects authenticated non-admin callers when OIDC is enabled", async () => { + oidcServer = await createOidcServer(); + app = await buildTestApp({ + oidcClientId: "test-client", + oidcIssuer: oidcServer.issuer, + oidcRedirectUri: "http://127.0.0.1:8080/auth/callback", + sessionSecret: Buffer.from(testSessionSecret, "hex"), + }); + + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-users"], + sub: "user-123", + }); + + const response = await app.inject({ + headers: { + cookie: `session=${encodeURIComponent(app.encodeSecureSession(session))}`, + }, + method: "GET", + url: "/api/metrics/cluster-pods", + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ + error: "Forbidden", + statusCode: 403, + }); + }); +}); diff --git a/components/web-console/bff/test/metrics-cluster-pods.test.ts b/components/web-console/bff/test/metrics-cluster-pods.test.ts new file mode 100644 index 00000000..d821492f --- /dev/null +++ b/components/web-console/bff/test/metrics-cluster-pods.test.ts @@ -0,0 +1,214 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { + clusterPodPhasePromql, + clusterPodsCapacityPromql, + clusterPodsUsedPromql, + type ClusterPodPhase, + queryClusterPods, +} from "../src/metrics-cluster-pods.js"; + +async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, +): Promise<{ close: () => void; port: number }> { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return { + close: () => server.close(), + port: address.port, + }; +} + +function prometheusSample(value: string) { + return JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: {}, + value: ["1704067200", value], + }, + ], + }, + }); +} + +const defaultPhaseCounts: Record = { + Failed: "16", + Pending: "12", + Running: "500", + Succeeded: "20", + Unknown: "0", +}; + +function handleClusterPodsQuery( + query: string | null, + response: ServerResponse, + options: { + capacity?: string; + phases?: Partial>; + used?: string; + } = {}, +): boolean { + response.setHeader("content-type", "application/json"); + if (query === clusterPodsCapacityPromql) { + response.end(prometheusSample(options.capacity ?? "2000")); + return true; + } + if (query === clusterPodsUsedPromql) { + response.end(prometheusSample(options.used ?? "548")); + return true; + } + for (const phase of [ + "Pending", + "Running", + "Succeeded", + "Failed", + "Unknown", + ] as const) { + if (query === clusterPodPhasePromql(phase)) { + response.end( + prometheusSample(options.phases?.[phase] ?? defaultPhaseCounts[phase]), + ); + return true; + } + } + return false; +} + +describe("queryClusterPods", () => { + it("maps Prometheus capacity, used, and phase samples into pod counts", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + if (handleClusterPodsQuery(query, response)) { + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + const pods = await queryClusterPods( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ); + expect(pods).toEqual({ + available_pods: 1452, + capacity_pods: 2000, + phase_failed_pods: 16, + phase_pending_pods: 12, + phase_running_pods: 500, + phase_succeeded_pods: 20, + phase_unknown_pods: 0, + used_pods: 548, + }); + } finally { + prometheus.close(); + } + }); + + it("fails when Prometheus returns no capacity samples", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === clusterPodsCapacityPromql) { + response.end( + JSON.stringify({ + status: "success", + data: { result: [] }, + }), + ); + return; + } + if (handleClusterPodsQuery(query, response)) { + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryClusterPods(`http://127.0.0.1:${String(prometheus.port)}`, 5_000), + ).rejects.toThrow("No cluster pod capacity data"); + } finally { + prometheus.close(); + } + }); + + it("fails when used exceeds capacity", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + if ( + handleClusterPodsQuery(query, response, { + capacity: "100", + phases: { + Failed: "0", + Pending: "0", + Running: "101", + Succeeded: "0", + Unknown: "0", + }, + used: "101", + }) + ) { + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryClusterPods(`http://127.0.0.1:${String(prometheus.port)}`, 5_000), + ).rejects.toThrow("Inconsistent cluster pod samples"); + } finally { + prometheus.close(); + } + }); + + it("fails when phase counts do not sum to used pods", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + if ( + handleClusterPodsQuery(query, response, { + phases: { + Failed: "16", + Pending: "12", + Running: "499", + Succeeded: "20", + Unknown: "0", + }, + }) + ) { + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryClusterPods(`http://127.0.0.1:${String(prometheus.port)}`, 5_000), + ).rejects.toThrow("Inconsistent cluster pod phase samples"); + } finally { + prometheus.close(); + } + }); +}); diff --git a/components/web-console/bff/test/metrics-gateway-provision-duration.test.ts b/components/web-console/bff/test/metrics-gateway-provision-duration.test.ts new file mode 100644 index 00000000..a12107c4 --- /dev/null +++ b/components/web-console/bff/test/metrics-gateway-provision-duration.test.ts @@ -0,0 +1,114 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { + gatewayProvisionDurationCountPromql, + gatewayProvisionDurationMeanPromql, + gatewayProvisionDurationP50Promql, + gatewayProvisionDurationP95Promql, + queryGatewayProvisionDuration, +} from "../src/metrics-gateway-provision-duration.js"; + +async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, +): Promise<{ close: () => void; port: number }> { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return { + close: () => server.close(), + port: address.port, + }; +} + +function prometheusSample(value: string) { + return JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: {}, + value: ["1704067200", value], + }, + ], + }, + }); +} + +describe("queryGatewayProvisionDuration", () => { + it("maps Prometheus histogram samples into mean, P50, and P95 seconds", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === gatewayProvisionDurationCountPromql) { + response.end(prometheusSample("2")); + return; + } + if (query === gatewayProvisionDurationMeanPromql) { + response.end(prometheusSample("315")); + return; + } + if (query === gatewayProvisionDurationP50Promql) { + response.end(prometheusSample("288")); + return; + } + if (query === gatewayProvisionDurationP95Promql) { + response.end(prometheusSample("726")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + const duration = await queryGatewayProvisionDuration( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ); + expect(duration).toEqual({ + mean_seconds: 315, + observation_count: 2, + p50_seconds: 288, + p95_seconds: 726, + }); + } finally { + prometheus.close(); + } + }); + + it("fails when observation count is zero", async () => { + const prometheus = await startPrometheusStub((request, response) => { + const url = new URL(request.url ?? "", "http://127.0.0.1"); + const query = url.searchParams.get("query"); + response.setHeader("content-type", "application/json"); + if (query === gatewayProvisionDurationCountPromql) { + response.end(prometheusSample("0")); + return; + } + response.statusCode = 400; + response.end(); + }); + + try { + await expect( + queryGatewayProvisionDuration( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ), + ).rejects.toThrow("No gateway provision duration observations"); + } finally { + prometheus.close(); + } + }); +}); diff --git a/components/web-console/bff/test/metrics-gateways.test.ts b/components/web-console/bff/test/metrics-gateways.test.ts new file mode 100644 index 00000000..2f573e33 --- /dev/null +++ b/components/web-console/bff/test/metrics-gateways.test.ts @@ -0,0 +1,92 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { + emptyGatewayPhaseCounts, + queryGatewayPhaseCounts, +} from "../src/metrics-gateways.js"; + +async function startPrometheusStub( + handler: (request: IncomingMessage, response: ServerResponse) => void, +): Promise<{ close: () => void; port: number }> { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("expected tcp listener address"); + } + return { + close: () => server.close(), + port: address.port, + }; +} + +describe("queryGatewayPhaseCounts", () => { + it("maps Prometheus samples into phase counts", async () => { + const prometheus = await startPrometheusStub((request, response) => { + expect(request.url).toBe("/api/v1/query?query=hypershell_gateways_total"); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + status: "success", + data: { + result: [ + { + metric: { phase: "Running" }, + value: ["1704067200", "5"], + }, + { + metric: { phase: "Failed" }, + value: ["1704067200", "2"], + }, + ], + }, + }), + ); + }); + + try { + const counts = await queryGatewayPhaseCounts( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ); + expect(counts).toEqual({ + Pending: 0, + Running: 5, + Provisioning: 0, + Degraded: 0, + Failed: 2, + }); + } finally { + prometheus.close(); + } + }); + + it("returns zeroed counts when Prometheus has no samples", async () => { + const prometheus = await startPrometheusStub((_request, response) => { + response.end( + JSON.stringify({ + status: "success", + data: { result: [] }, + }), + ); + }); + + try { + const counts = await queryGatewayPhaseCounts( + `http://127.0.0.1:${String(prometheus.port)}`, + 5_000, + ); + expect(counts).toEqual(emptyGatewayPhaseCounts()); + } finally { + prometheus.close(); + } + }); +}); diff --git a/components/web-console/bff/tsconfig.json b/components/web-console/bff/tsconfig.json index d0948169..e223a938 100644 --- a/components/web-console/bff/tsconfig.json +++ b/components/web-console/bff/tsconfig.json @@ -9,12 +9,12 @@ "noImplicitOverride": true, "noUncheckedIndexedAccess": true, "outDir": "dist", - "rootDir": "src", + "rootDir": "..", "sourceMap": false, "strict": true, "target": "ES2022", "types": ["node"], "verbatimModuleSyntax": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "../shared/**/*.ts"] } diff --git a/components/web-console/bff/tsconfig.test.json b/components/web-console/bff/tsconfig.test.json index 0de90837..d88f6df1 100644 --- a/components/web-console/bff/tsconfig.test.json +++ b/components/web-console/bff/tsconfig.test.json @@ -2,8 +2,13 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, - "rootDir": ".", + "rootDir": "..", "types": ["node", "vitest/globals"] }, - "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../shared/**/*.ts", + "vitest.config.ts" + ] } diff --git a/components/web-console/domain-probes/package.json b/components/web-console/domain-probes/package.json index 71d9e864..aa36348e 100644 --- a/components/web-console/domain-probes/package.json +++ b/components/web-console/domain-probes/package.json @@ -2,18 +2,18 @@ "name": "@openshift-online/hypershell-domain-probes", "version": "0.0.0", "private": true, - "description": "Typed domain-probe contracts and fan-out delivery for HyperShell web runtimes", + "description": "Typed domain-probe contracts and fan-out delivery for HyperShell web runtimes. Monorepo consumers resolve types from src/ so typecheck works without a prior build; published artifacts ship dist/ only (see files).", "type": "module", "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "types": "./src/index.ts", "exports": { ".": { - "types": "./dist/index.d.ts", + "types": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, "./fan-out": { - "types": "./dist/fan-out.d.ts", + "types": "./src/fan-out.ts", "import": "./dist/fan-out.js", "default": "./dist/fan-out.js" } diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index 02740896..8144067b 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -27,6 +27,306 @@ "defaultMessage": "Copy", "description": "Tooltip for a button that copies text to the clipboard." }, + "app.dashboard.addWidgets": { + "defaultMessage": "Add widgets", + "description": "Label for the button that opens the widget drawer." + }, + "app.dashboard.description": { + "defaultMessage": "Live view of gateway fleet health, hub cluster capacity, and platform usage. Metrics refresh every 15 minutes; use Refresh to update now.", + "description": "Supporting text on the operational dashboard page." + }, + "app.dashboard.gatewayStatus.ariaDesc": { + "defaultMessage": "Gateway count by status", + "description": "Accessible description for the gateway status donut chart." + }, + "app.dashboard.gatewayStatus.chartTitle": { + "defaultMessage": "Gateway status chart", + "description": "Accessible title for the gateway status donut chart." + }, + "app.dashboard.gatewayStatus.dataLabel": { + "defaultMessage": "{status}: {count}", + "description": "Data label for a gateway status donut chart segment. Superseded by statusDonutDataLabel." + }, + "app.dashboard.gatewayStatus.degraded": { + "defaultMessage": "Degraded", + "description": "Legend label for degraded gateways." + }, + "app.dashboard.gatewayStatus.failed": { + "defaultMessage": "Failed", + "description": "Legend label for failed gateways." + }, + "app.dashboard.gatewayStatus.healthy": { + "defaultMessage": "Healthy", + "description": "Legend label for healthy gateways." + }, + "app.dashboard.gatewayStatus.legend": { + "defaultMessage": "{status}: {count}", + "description": "Legend entry for a gateway status donut chart segment." + }, + "app.dashboard.gatewayStatus.provisioning": { + "defaultMessage": "Provisioning", + "description": "Legend label for provisioning gateways." + }, + "app.dashboard.loadError.body": { + "defaultMessage": "An unexpected error occurred while loading dashboard metrics.", + "description": "Recovery guidance when operational dashboard metrics cannot be loaded." + }, + "app.dashboard.loadError.title": { + "defaultMessage": "Operational dashboard metrics are unavailable", + "description": "Title shown when operational dashboard metrics cannot be loaded." + }, + "app.dashboard.loading": { + "defaultMessage": "Loading operational dashboard metrics", + "description": "Accessible status shown while operational dashboard metrics load." + }, + "app.dashboard.metric.value": { + "defaultMessage": "{value} {label}", + "description": "Formatted count for a dashboard metric card heading." + }, + "app.dashboard.metricCouldNotBeDetermined": { + "defaultMessage": "Metric could not be determined", + "description": "Fallback when a dashboard metric value is non-finite or cannot be shown as a number." + }, + "app.dashboard.metricUnavailable.body": { + "defaultMessage": "This information is not currently available.", + "description": "Recovery guidance when an individual dashboard metric is missing." + }, + "app.dashboard.metricUnavailable.title": { + "defaultMessage": "Metric unavailable", + "description": "Heading shown when an individual dashboard metric is missing." + }, + "app.dashboard.nodeStatus.ariaDesc": { + "defaultMessage": "Node count by readiness", + "description": "Accessible description for the node status donut chart." + }, + "app.dashboard.nodeStatus.chartTitle": { + "defaultMessage": "Node status chart", + "description": "Accessible title for the node status donut chart." + }, + "app.dashboard.nodeStatus.notReady": { + "defaultMessage": "Not ready", + "description": "Legend label for not-ready nodes." + }, + "app.dashboard.nodeStatus.ready": { + "defaultMessage": "Ready", + "description": "Legend label for ready nodes." + }, + "app.dashboard.podStatus.ariaDesc": { + "defaultMessage": "Pod capacity by phase and unused slots", + "description": "Accessible description for the pod capacity donut chart." + }, + "app.dashboard.podStatus.chartTitle": { + "defaultMessage": "Pod capacity chart", + "description": "Accessible title for the pod capacity donut chart." + }, + "app.dashboard.podStatus.failed": { + "defaultMessage": "Failed", + "description": "Legend label for failed pods." + }, + "app.dashboard.podStatus.pending": { + "defaultMessage": "Pending", + "description": "Legend label for pending pods." + }, + "app.dashboard.podStatus.running": { + "defaultMessage": "Running", + "description": "Legend label for running pods." + }, + "app.dashboard.podStatus.succeeded": { + "defaultMessage": "Succeeded", + "description": "Legend label for succeeded pods." + }, + "app.dashboard.podStatus.unknown": { + "defaultMessage": "Unknown", + "description": "Legend label for unknown-phase pods." + }, + "app.dashboard.podStatus.unused": { + "defaultMessage": "Unused", + "description": "Legend label for unused pod capacity slots." + }, + "app.dashboard.provisionTime.average": { + "defaultMessage": "Average", + "description": "Label for average gateway provision duration." + }, + "app.dashboard.provisionTime.median": { + "defaultMessage": "Median (P50)", + "description": "Label for median gateway provision duration." + }, + "app.dashboard.provisionTime.p95": { + "defaultMessage": "P95", + "description": "Label for the 95th percentile gateway provision duration." + }, + "app.dashboard.provisionTime.p95Note": { + "defaultMessage": "95% of gateways were provisioned in under {duration} {unit}.", + "description": "Context note explaining the 95th percentile gateway provision duration." + }, + "app.dashboard.provisionTime.statsAriaLabel": { + "defaultMessage": "Gateway provision duration statistics", + "description": "Accessible label for the provision time statistics description list." + }, + "app.dashboard.refresh": { + "defaultMessage": "Refresh dashboard metrics", + "description": "Accessible label for refreshing operational dashboard metrics." + }, + "app.dashboard.refreshError.body": { + "defaultMessage": "Showing the last successful metrics. Try refreshing again.", + "description": "Recovery guidance when operational dashboard metrics cannot be refreshed." + }, + "app.dashboard.refreshError.title": { + "defaultMessage": "Could not refresh dashboard metrics", + "description": "Title shown when operational dashboard metrics cannot be refreshed." + }, + "app.dashboard.resetToDefault": { + "defaultMessage": "Reset to default", + "description": "Label for restoring the operational dashboard default layout." + }, + "app.dashboard.statusDonut.dataLabel": { + "defaultMessage": "{status}: {count}", + "description": "Data label for a status donut chart segment." + }, + "app.dashboard.statusDonut.legend": { + "defaultMessage": "{status}: {count}", + "description": "Legend entry for a status donut chart segment." + }, + "app.dashboard.summary.cpus": { + "defaultMessage": "CPUs", + "description": "Summary label for provisioned CPU capacity." + }, + "app.dashboard.summary.gateways": { + "defaultMessage": "Gateways", + "description": "Summary label for provisioned gateways." + }, + "app.dashboard.summary.memory": { + "defaultMessage": "Memory", + "description": "Summary label for memory utilization." + }, + "app.dashboard.summary.pods": { + "defaultMessage": "Pods", + "description": "Summary label for pod utilization." + }, + "app.dashboard.summary.provisionTime": { + "defaultMessage": "Provision time (average)", + "description": "Summary label for average gateway provision time." + }, + "app.dashboard.summary.provisionTimeP50": { + "defaultMessage": "Provision time (P50)", + "description": "Deprecated summary label retained for locale extraction." + }, + "app.dashboard.summary.provisionTimeP95": { + "defaultMessage": "Provision time (P95)", + "description": "Deprecated summary label retained for locale extraction." + }, + "app.dashboard.summary.registeredUsers": { + "defaultMessage": "Registered users", + "description": "Summary label for registered users." + }, + "app.dashboard.summary.sandboxes": { + "defaultMessage": "Sandboxes", + "description": "Label for provisioned sandboxes on the operational dashboard." + }, + "app.dashboard.summary.system": { + "defaultMessage": "System", + "description": "Heading for system utilization metrics in the summary widget." + }, + "app.dashboard.summary.systemAriaLabel": { + "defaultMessage": "System metrics", + "description": "Accessible label for the system metrics list in the summary widget." + }, + "app.dashboard.summary.trendDecrease": { + "defaultMessage": "{percent}% decrease", + "description": "Tooltip for a usage summary metric that decreased since the start of its trend." + }, + "app.dashboard.summary.trendIncrease": { + "defaultMessage": "{percent}% increase", + "description": "Tooltip for a usage summary metric that increased since the start of its trend." + }, + "app.dashboard.summary.usage": { + "defaultMessage": "Usage", + "description": "Heading for adoption metrics in the summary widget." + }, + "app.dashboard.summary.usageAriaLabel": { + "defaultMessage": "Usage metrics", + "description": "Accessible label for the usage metrics list in the summary widget." + }, + "app.dashboard.title": { + "defaultMessage": "HyperShell operational dashboard", + "description": "Main heading on the operational dashboard page." + }, + "app.dashboard.trend.lastDays": { + "defaultMessage": "Last {days} days", + "description": "Caption below a trend sparkline showing the lookback window." + }, + "app.dashboard.trend.tooltip": { + "defaultMessage": "{date}: {value} {metric}", + "description": "Tooltip for a dashboard metric trend sparkline point." + }, + "app.dashboard.utilization.capacity": { + "defaultMessage": "{unit} capacity", + "description": "Capacity label for a utilization donut chart." + }, + "app.dashboard.utilization.chartTitle": { + "defaultMessage": "{unit} utilization chart", + "description": "Accessible title for a utilization donut chart." + }, + "app.dashboard.utilization.dataLabel": { + "defaultMessage": "{capacity}: {percentage}%", + "description": "Data label for a utilization donut chart segment." + }, + "app.dashboard.utilization.label": { + "defaultMessage": "{value} {unit}", + "description": "Primary value label for a utilization donut chart." + }, + "app.dashboard.utilization.subtitle": { + "defaultMessage": "of {total} {unit}", + "description": "Subtitle for a utilization donut chart." + }, + "app.dashboard.utilization.summaryTooltip": { + "defaultMessage": "{percent}% capacity{separator}{value} of {total} {unit}", + "description": "Tooltip for utilization status in the system summary widget." + }, + "app.dashboard.widget.cpu": { + "defaultMessage": "CPU", + "description": "Title for the CPU utilization dashboard widget." + }, + "app.dashboard.widget.gatewayStatus": { + "defaultMessage": "Gateway status", + "description": "Title for the gateway status dashboard widget." + }, + "app.dashboard.widget.memory": { + "defaultMessage": "Memory", + "description": "Title for the memory utilization dashboard widget." + }, + "app.dashboard.widget.nodes": { + "defaultMessage": "Nodes", + "description": "Title for the nodes dashboard widget." + }, + "app.dashboard.widget.pods": { + "defaultMessage": "Pods", + "description": "Title for the pods utilization dashboard widget." + }, + "app.dashboard.widget.provisionTime": { + "defaultMessage": "Gateway provision time", + "description": "Title for the gateway provision time dashboard widget." + }, + "app.dashboard.widget.provisionedGateways": { + "defaultMessage": "Provisioned gateways", + "description": "Title for the provisioned gateways dashboard widget." + }, + "app.dashboard.widget.registeredUsers": { + "defaultMessage": "Registered users", + "description": "Title for the registered users dashboard widget." + }, + "app.dashboard.widget.summary": { + "defaultMessage": "Summary", + "description": "Title for the operational dashboard summary widget." + }, + "app.dashboard.widget.systemSummary": { + "defaultMessage": "System summary", + "description": "Title for the operational dashboard system summary widget." + }, + "app.dashboard.widget.usageSummary": { + "defaultMessage": "Usage summary", + "description": "Title for the operational dashboard usage summary widget." + }, "app.error.body": { "defaultMessage": "Refresh the page to try again.", "description": "Recovery guidance shown after an unexpected route failure." @@ -711,6 +1011,10 @@ "defaultMessage": "{count, plural, one {# gateway} other {# gateways}}", "description": "Pluralized gateway count shown inside each phase card." }, + "app.metrics.phase.pending": { + "defaultMessage": "Pending", + "description": "Label for the Pending gateway phase metric card." + }, "app.metrics.phase.provisioning": { "defaultMessage": "Provisioning", "description": "Label for the Provisioning gateway phase metric card." @@ -719,6 +1023,10 @@ "defaultMessage": "Running", "description": "Label for the Running gateway phase metric card." }, + "app.nav.dashboard": { + "defaultMessage": "Operational dashboard", + "description": "Page and navigation label for the HyperShell dashboard." + }, "app.nav.gateways": { "defaultMessage": "OpenShell Gateways", "description": "Page and resource collection label for OpenShell gateways." @@ -739,6 +1047,18 @@ "defaultMessage": "Notifications", "description": "Accessible label for transient application notifications." }, + "app.page.dashboard.accessDenied.body": { + "defaultMessage": "The operational dashboard is available only to HyperShell administrators.", + "description": "Recovery guidance shown when a signed-in user lacks the admin role for the dashboard." + }, + "app.page.dashboard.accessDenied.title": { + "defaultMessage": "Access denied", + "description": "Heading shown when a signed-in user lacks the admin role for the dashboard." + }, + "app.page.dashboard.description": { + "defaultMessage": "Operational metrics dashboard for HyperShell adoption and provisioned resources.", + "description": "Browser metadata description for the operational dashboard page." + }, "app.page.gateway.description": { "defaultMessage": "Connect to this gateway and review its configuration.", "description": "Metadata description for the gateway detail page." @@ -799,6 +1119,10 @@ "defaultMessage": "HyperShell", "description": "HyperShell product name." }, + "app.session.loading.ariaLabel": { + "defaultMessage": "Loading session", + "description": "Accessible label for the session loading spinner." + }, "app.skipToContent": { "defaultMessage": "Skip to content", "description": "Accessibility link that moves focus to the main page content." diff --git a/components/web-console/package.json b/components/web-console/package.json index d35afefb..ca2ddcf1 100644 --- a/components/web-console/package.json +++ b/components/web-console/package.json @@ -10,8 +10,8 @@ "check": "pnpm run format:check && pnpm run architecture:check && pnpm run lint && pnpm run typecheck && pnpm run test:run && pnpm run i18n:check && pnpm run build && pnpm run build:storybook", "dev": "react-router dev", "format:check": "prettier --check .", - "i18n:check": "formatjs extract 'app/**/*.{ts,tsx}' '../../packages/gateway-management-ui/src/**/*.{ts,tsx}' --out-file /tmp/hypershell-web-console-en.json && cmp locales/en.json /tmp/hypershell-web-console-en.json", - "i18n:extract": "formatjs extract 'app/**/*.{ts,tsx}' '../../packages/gateway-management-ui/src/**/*.{ts,tsx}' --out-file locales/en.json", + "i18n:check": "formatjs extract 'app/**/*.{ts,tsx}' '../../packages/gateway-management-ui/src/**/*.{ts,tsx}' '../../packages/operational-dashboard-ui/src/**/*.{ts,tsx}' --out-file /tmp/hypershell-web-console-en.json && cmp locales/en.json /tmp/hypershell-web-console-en.json", + "i18n:extract": "formatjs extract 'app/**/*.{ts,tsx}' '../../packages/gateway-management-ui/src/**/*.{ts,tsx}' '../../packages/operational-dashboard-ui/src/**/*.{ts,tsx}' --out-file locales/en.json", "lint": "eslint . --max-warnings=0", "storybook": "STORYBOOK=true storybook dev --port 6006 --no-open", "test": "vitest", @@ -24,6 +24,7 @@ "dependencies": { "@openshift-online/hypershell-domain-probes": "workspace:0.0.0", "@openshift-online/hypershell-gateway-management-ui": "workspace:0.0.0", + "@openshift-online/hypershell-operational-dashboard-ui": "workspace:0.0.0", "@openshift-online/hypershell-sdk": "workspace:0.0.0", "@opentelemetry/api": "1.9.1", "@opentelemetry/core": "2.10.0", @@ -31,9 +32,11 @@ "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0", "@opentelemetry/semantic-conventions": "1.43.0", + "@patternfly/react-charts": "8.6.1", "@patternfly/react-core": "6.6.0", "@patternfly/react-icons": "6.6.0", "@patternfly/react-table": "6.6.0", + "@patternfly/widgetized-dashboard": "1.0.0-prerelease.6", "@react-router/node": "8.3.0", "@tanstack/react-query": "5.101.4", "isbot": "5.2.1", @@ -42,6 +45,11 @@ "react-hook-form": "7.82.0", "react-intl": "10.1.18", "react-router": "8.3.0", + "victory-area": "37.3.6", + "victory-core": "37.3.6", + "victory-group": "37.3.6", + "victory-tooltip": "37.3.6", + "victory-voronoi-container": "37.3.6", "web-vitals": "5.3.0", "zod": "4.4.3" }, diff --git a/components/web-console/route-contract.json b/components/web-console/route-contract.json index e1a45429..3b232900 100644 --- a/components/web-console/route-contract.json +++ b/components/web-console/route-contract.json @@ -1,10 +1,12 @@ { + "dashboard": "dashboard", "gatewayDetail": "gateways/:gatewayId", "gatewayNew": "gateways/new", "login": "login", "metrics": "metrics", "directNavigationExamples": [ "/", + "/dashboard", "/login", "/gateways/new", "/gateways/gateway-1", diff --git a/components/web-console/shared/dashboard-roles.ts b/components/web-console/shared/dashboard-roles.ts new file mode 100644 index 00000000..dbe38a25 --- /dev/null +++ b/components/web-console/shared/dashboard-roles.ts @@ -0,0 +1,14 @@ +/** Keycloak realm role for HyperShell administrators. */ +export const HYPERSHELL_ADMIN_ROLE = "hypershell-admins"; + +/** Keycloak realm role for platform-wide administration. */ +export const PLATFORM_ADMIN_ROLE = "platform:admin"; + +const DASHBOARD_ADMIN_ROLES = new Set([ + HYPERSHELL_ADMIN_ROLE, + PLATFORM_ADMIN_ROLE, +]); + +export function hasDashboardAdminRole(roles: readonly string[]): boolean { + return roles.some((role) => DASHBOARD_ADMIN_ROLES.has(role)); +} diff --git a/components/web-console/shared/gateway-phases.ts b/components/web-console/shared/gateway-phases.ts new file mode 100644 index 00000000..600f4dd8 --- /dev/null +++ b/components/web-console/shared/gateway-phases.ts @@ -0,0 +1,25 @@ +// TitleCase canonical gateway phases. Mirrors components/api-server/pkg/gatewayhealth +// and packages/gateway-management-ui/src/gateways/gateway-data.ts. +export const gatewayCanonicalPhaseStrings = [ + "Pending", + "Provisioning", + "Running", + "Degraded", + "Failed", +] as const; + +export type GatewayCanonicalPhase = + (typeof gatewayCanonicalPhaseStrings)[number]; + +export function emptyGatewayPhaseCounts(): Record< + GatewayCanonicalPhase, + number +> { + return { + Pending: 0, + Provisioning: 0, + Running: 0, + Degraded: 0, + Failed: 0, + }; +} diff --git a/components/web-console/tsconfig.app.json b/components/web-console/tsconfig.app.json index e61ceede..15185412 100644 --- a/components/web-console/tsconfig.app.json +++ b/components/web-console/tsconfig.app.json @@ -23,6 +23,7 @@ "include": [ ".react-router/types/**/*", "app/**/*", + "shared/**/*", "react-router.config.ts", "vite.config.ts" ], diff --git a/components/web-console/tsconfig.test.json b/components/web-console/tsconfig.test.json index 379c9654..76a16610 100644 --- a/components/web-console/tsconfig.test.json +++ b/components/web-console/tsconfig.test.json @@ -6,6 +6,7 @@ }, "include": [ "app/**/*", + "shared/**/*", ".storybook/**/*", "e2e/**/*", "e2e-live/**/*", diff --git a/deploy/base/kustomization.yaml b/deploy/base/kustomization.yaml index bee94775..57d17981 100644 --- a/deploy/base/kustomization.yaml +++ b/deploy/base/kustomization.yaml @@ -6,5 +6,6 @@ resources: - controller.yaml - controller-rbac.yaml - web-console.yaml + - prometheus/ - certificates/ - networkpolicies.yaml diff --git a/deploy/base/prometheus/kube-state-metrics-servicemonitor.yaml b/deploy/base/prometheus/kube-state-metrics-servicemonitor.yaml new file mode 100644 index 00000000..5e099b79 --- /dev/null +++ b/deploy/base/prometheus/kube-state-metrics-servicemonitor.yaml @@ -0,0 +1,18 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: kube-state-metrics + namespace: hypershell-system + labels: + app: kube-state-metrics + hypershell.redhat.io/prometheus-scrape: "true" +spec: + jobLabel: app + selector: + matchLabels: + app: kube-state-metrics + endpoints: + - port: metrics + path: /metrics + scheme: http + interval: 30s diff --git a/deploy/base/prometheus/kube-state-metrics.yaml b/deploy/base/prometheus/kube-state-metrics.yaml new file mode 100644 index 00000000..b67b29fc --- /dev/null +++ b/deploy/base/prometheus/kube-state-metrics.yaml @@ -0,0 +1,115 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kube-state-metrics + namespace: hypershell-system + labels: + app: kube-state-metrics +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: hypershell-kube-state-metrics + labels: + app: kube-state-metrics +rules: + - apiGroups: [""] + resources: + - nodes + - pods + verbs: ["list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: hypershell-kube-state-metrics + labels: + app: kube-state-metrics +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: hypershell-kube-state-metrics +subjects: + - kind: ServiceAccount + name: kube-state-metrics + namespace: hypershell-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kube-state-metrics + namespace: hypershell-system + labels: + app: kube-state-metrics +spec: + replicas: 1 + selector: + matchLabels: + app: kube-state-metrics + template: + metadata: + labels: + app: kube-state-metrics + spec: + automountServiceAccountToken: true + serviceAccountName: kube-state-metrics + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + fsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: kube-state-metrics + image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.14.0@sha256:2336fb0ec5b0841ce5871d777627943cda0a7fd875917a8abdb3fe27d78ac6fc + args: + - --host=0.0.0.0 + - --port=8080 + - --telemetry-host=0.0.0.0 + - --telemetry-port=8081 + - --resources=pods,nodes + ports: + - name: metrics + containerPort: 8080 + - name: telemetry + containerPort: 8081 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + livenessProbe: + httpGet: + path: /livez + port: 8080 + initialDelaySeconds: 5 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + timeoutSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: kube-state-metrics + namespace: hypershell-system + labels: + app: kube-state-metrics +spec: + selector: + app: kube-state-metrics + ports: + - name: metrics + port: 8080 + targetPort: metrics diff --git a/deploy/base/prometheus/kustomization.yaml b/deploy/base/prometheus/kustomization.yaml index ba048a7f..10e9c728 100644 --- a/deploy/base/prometheus/kustomization.yaml +++ b/deploy/base/prometheus/kustomization.yaml @@ -4,3 +4,9 @@ resources: - rbac.yaml - prometheus.yaml - servicemonitor.yaml + - node-exporter.yaml + - node-exporter-servicemonitor.yaml + - kube-state-metrics.yaml + - kube-state-metrics-servicemonitor.yaml + - otel-collector.yaml + - otel-collector-servicemonitor.yaml diff --git a/deploy/base/prometheus/node-exporter-servicemonitor.yaml b/deploy/base/prometheus/node-exporter-servicemonitor.yaml new file mode 100644 index 00000000..d93d6c66 --- /dev/null +++ b/deploy/base/prometheus/node-exporter-servicemonitor.yaml @@ -0,0 +1,18 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: node-exporter + namespace: hypershell-system + labels: + app: node-exporter + hypershell.redhat.io/prometheus-scrape: "true" +spec: + jobLabel: app + selector: + matchLabels: + app: node-exporter + endpoints: + - port: metrics + path: /metrics + scheme: http + interval: 30s diff --git a/deploy/base/prometheus/node-exporter.yaml b/deploy/base/prometheus/node-exporter.yaml new file mode 100644 index 00000000..8e550090 --- /dev/null +++ b/deploy/base/prometheus/node-exporter.yaml @@ -0,0 +1,86 @@ +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: node-exporter + namespace: hypershell-system + labels: + app: node-exporter +spec: + selector: + matchLabels: + app: node-exporter + template: + metadata: + labels: + app: node-exporter + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + fsGroup: 65534 + seccompProfile: + type: RuntimeDefault + tolerations: + - operator: Exists + containers: + - name: node-exporter + image: quay.io/prometheus/node-exporter:v1.9.0@sha256:c99d7ee4d12a38661788f60d9eca493f08584e2e544bbd3b3fca64749f86b848 + args: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/host/root + - --web.listen-address=:9100 + ports: + - name: metrics + containerPort: 9100 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi + volumeMounts: + - name: proc + mountPath: /host/proc + readOnly: true + - name: sys + mountPath: /host/sys + readOnly: true + - name: root + mountPath: /host/root + readOnly: true + mountPropagation: HostToContainer + volumes: + - name: proc + hostPath: + path: /proc + - name: sys + hostPath: + path: /sys + - name: root + hostPath: + path: / +--- +apiVersion: v1 +kind: Service +metadata: + name: node-exporter + namespace: hypershell-system + labels: + app: node-exporter +spec: + clusterIP: None + selector: + app: node-exporter + ports: + - name: metrics + port: 9100 + targetPort: metrics diff --git a/deploy/base/prometheus/otel-collector-servicemonitor.yaml b/deploy/base/prometheus/otel-collector-servicemonitor.yaml new file mode 100644 index 00000000..61ee4e04 --- /dev/null +++ b/deploy/base/prometheus/otel-collector-servicemonitor.yaml @@ -0,0 +1,18 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: otel-collector + namespace: hypershell-system + labels: + app: otel-collector + hypershell.redhat.io/prometheus-scrape: "true" +spec: + jobLabel: app + selector: + matchLabels: + app: otel-collector + endpoints: + - port: prometheus + path: /metrics + scheme: http + interval: 30s diff --git a/deploy/base/prometheus/otel-collector.yaml b/deploy/base/prometheus/otel-collector.yaml new file mode 100644 index 00000000..f54c88ac --- /dev/null +++ b/deploy/base/prometheus/otel-collector.yaml @@ -0,0 +1,112 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: otel-collector-config + namespace: hypershell-system +data: + config.yaml: | + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + + processors: + batch: + + exporters: + prometheus: + endpoint: 0.0.0.0:8889 + + extensions: + health_check: + endpoint: 0.0.0.0:13133 + + service: + extensions: [health_check] + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: otel-collector + namespace: hypershell-system +spec: + replicas: 1 + selector: + matchLabels: + app: otel-collector + template: + metadata: + labels: + app: otel-collector + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: otel-collector + image: otel/opentelemetry-collector-contrib:0.128.0@sha256:1ab0baba0ee3695d823c46653d8a6e8894896e668ce8bd7ebe002e948d827bc7 + args: + - --config=/conf/config.yaml + ports: + - name: otlp-grpc + containerPort: 4317 + - name: prometheus + containerPort: 8889 + volumeMounts: + - name: config + mountPath: /conf + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL + readinessProbe: + httpGet: + path: / + port: 13133 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 13133 + initialDelaySeconds: 10 + periodSeconds: 20 + volumes: + - name: config + configMap: + name: otel-collector-config +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-collector + namespace: hypershell-system + labels: + app: otel-collector +spec: + selector: + app: otel-collector + ports: + - name: otlp-grpc + port: 4317 + targetPort: otlp-grpc + - name: prometheus + port: 8889 + targetPort: prometheus diff --git a/deploy/base/prometheus/prometheus.yaml b/deploy/base/prometheus/prometheus.yaml index b3bc124e..8117c970 100644 --- a/deploy/base/prometheus/prometheus.yaml +++ b/deploy/base/prometheus/prometheus.yaml @@ -1,7 +1,7 @@ apiVersion: monitoring.coreos.com/v1 kind: Prometheus metadata: - name: hypershell + name: prometheus namespace: hypershell-system spec: serviceAccountName: prometheus @@ -13,7 +13,7 @@ spec: type: RuntimeDefault serviceMonitorSelector: matchLabels: - app: hypershell-api-server + hypershell.redhat.io/prometheus-scrape: "true" serviceMonitorNamespaceSelector: matchLabels: kubernetes.io/metadata.name: hypershell-system diff --git a/deploy/base/prometheus/servicemonitor.yaml b/deploy/base/prometheus/servicemonitor.yaml index 79e02668..36f51583 100644 --- a/deploy/base/prometheus/servicemonitor.yaml +++ b/deploy/base/prometheus/servicemonitor.yaml @@ -5,6 +5,7 @@ metadata: namespace: hypershell-system labels: app: hypershell-api-server + hypershell.redhat.io/prometheus-scrape: "true" spec: selector: matchLabels: diff --git a/deploy/kind/kustomization.yaml b/deploy/kind/kustomization.yaml index 64f5d60d..28825053 100644 --- a/deploy/kind/kustomization.yaml +++ b/deploy/kind/kustomization.yaml @@ -95,6 +95,8 @@ patches: value: "hypershell-api-server.hypershell-system.svc.cluster.local:9000" - name: HYPERSHELL_API_SERVER_URL value: "http://hypershell-api-server.hypershell-system.svc.cluster.local:8000" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "http://otel-collector.hypershell-system.svc.cluster.local:4317" dnsConfig: options: - name: use-vc @@ -127,6 +129,16 @@ patches: secretKeyRef: name: hypershell-oidc-session key: session-secret + - name: PROMETHEUS_URL + value: "http://prometheus-operated.hypershell-system.svc.cluster.local:9090" + # --- Prometheus: emptyDir storage for Kind (no PVC required) --- + - target: + kind: Prometheus + name: prometheus + namespace: hypershell-system + patch: | + - op: remove + path: /spec/storage # --- API server: enable JWT + JWK cert URL + bypass paths --- # Appending --enable-jwt=true is index-independent: --enable-jwt is a pflag # BoolVar, and repeated single-value flags are last-value-wins, so the diff --git a/package.json b/package.json index f2c8d8f0..bd967534 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "pnpm": ">=11.15.1" }, "scripts": { - "build:web": "pnpm --filter @openshift-online/hypershell-sdk build && pnpm --filter @openshift-online/hypershell-domain-probes build && pnpm --filter @openshift-online/hypershell-gateway-management-ui build && pnpm --filter @openshift-online/hypershell-web-console build && pnpm --filter @openshift-online/hypershell-web-console-bff build", - "check:web": "pnpm --filter @openshift-online/hypershell-sdk check && pnpm --filter @openshift-online/hypershell-sdk build && pnpm --filter @openshift-online/hypershell-domain-probes check && pnpm --filter @openshift-online/hypershell-domain-probes build && pnpm --filter @openshift-online/hypershell-gateway-management-ui check && pnpm --filter @openshift-online/hypershell-web-console check && pnpm --filter @openshift-online/hypershell-web-console-bff check", + "build:web": "pnpm --filter @openshift-online/hypershell-sdk build && pnpm --filter @openshift-online/hypershell-domain-probes build && pnpm --filter @openshift-online/hypershell-gateway-management-ui build && pnpm --filter @openshift-online/hypershell-operational-dashboard-ui build && pnpm --filter @openshift-online/hypershell-web-console build && pnpm --filter @openshift-online/hypershell-web-console-bff build", + "check:web": "pnpm --filter @openshift-online/hypershell-sdk check && pnpm --filter @openshift-online/hypershell-sdk build && pnpm --filter @openshift-online/hypershell-domain-probes check && pnpm --filter @openshift-online/hypershell-domain-probes build && pnpm --filter @openshift-online/hypershell-gateway-management-ui check && pnpm --filter @openshift-online/hypershell-operational-dashboard-ui check && pnpm --filter @openshift-online/hypershell-web-console check && pnpm --filter @openshift-online/hypershell-web-console-bff check", "dev": "pnpm --filter @openshift-online/hypershell-web-console dev", "dev:bff": "pnpm --filter @openshift-online/hypershell-web-console-bff dev", "test:e2e": "pnpm --filter @openshift-online/hypershell-web-console test:e2e", diff --git a/packages/gateway-management-ui/src/gateways/gateway-data.test.ts b/packages/gateway-management-ui/src/gateways/gateway-data.test.ts index 3c2bde9a..ef5e9594 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-data.test.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-data.test.ts @@ -3,12 +3,14 @@ import { describe, expect, it } from "vitest"; import { normalizeGatewayPlacementClusterIds } from "../application/gateway-placement"; import type { GatewayRecord } from "../application/gateway-types"; import { + aggregateGatewayDisplayStatusCounts, gatewayConsoleReadyDeadlineMilliseconds, gatewayConsoleUnavailable, gatewayNeedsStatusPolling, gatewayPlacementBatchQueryKey, gatewayStatusPollMilliseconds, resolveConsoleWaitStart, + resolveGatewayDisplayStatus, toGatewayConnection, } from "./gateway-data"; @@ -287,6 +289,9 @@ describe("gateway presentation data", () => { }); it("presents transitional and failed lifecycle phases before health", () => { + expect(resolveGatewayDisplayStatus("Provisioning", "Ready")).toBe( + "Provisioning", + ); expect( toGatewayConnection( gateway({ phase: "Provisioning", status: "Ready" }), @@ -305,6 +310,30 @@ describe("gateway presentation data", () => { "Hub cluster", ).status, ).toBe("Degraded"); + expect( + toGatewayConnection( + gateway({ phase: "Running", status: "Healthy" }), + "Hub cluster", + ).status, + ).toBe("Healthy"); + }); + + it("aggregates gateway list rows into dashboard status buckets", () => { + expect( + aggregateGatewayDisplayStatusCounts([ + { phase: "Running", status: "Healthy" }, + { phase: "Running", status: "Healthy" }, + { phase: "Provisioning", status: "route pending" }, + { phase: "Degraded", status: "CrashLoopBackOff" }, + { phase: "Failed", status: "apply error" }, + { phase: "Running", status: "Degraded" }, + ]), + ).toEqual({ + degraded: 2, + failed: 1, + healthy: 2, + provisioning: 1, + }); }); it("keeps a returned cluster identifier for name resolution only", () => { diff --git a/packages/gateway-management-ui/src/gateways/gateway-data.ts b/packages/gateway-management-ui/src/gateways/gateway-data.ts index b7f66156..5536b431 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-data.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-data.ts @@ -4,6 +4,7 @@ import type { } from "../application/gateway-types"; import { normalizeGatewayPlacementClusterIds } from "../application/gateway-placement"; import type { GatewayConnection } from "./gateway-connections"; +import { gatewayStatusAppearance } from "./gateway-connections"; export const gatewayListQueryRoot = ["gateways", "list"] as const; export const gatewayPlacementQueryRoot = ["gateways", "placements"] as const; @@ -36,6 +37,19 @@ export const gatewayCanonicalPhases = { failed: "failed", } as const; +// TitleCase canonical phases for metrics and API labels. Derived from the same +// vocabulary as gatewayhealth.PhaseStrings() in lifecycle order. +export const gatewayCanonicalPhaseStrings = [ + "Pending", + "Provisioning", + "Running", + "Degraded", + "Failed", +] as const; + +export type GatewayCanonicalPhase = + (typeof gatewayCanonicalPhaseStrings)[number]; + // Recoverable (non-terminal) canonical phases keep the UI polling. The extra // transitional descriptors (reconciling/updating) are tolerated in case they // surface through the free-form health status, but the canonical phase set above @@ -54,6 +68,103 @@ const gatewayFailedLifecycleStates = new Set([ "error", ]); +export interface GatewayDisplayStatusCounts { + degraded: number; + failed: number; + healthy: number; + provisioning: number; +} + +export const gatewayDisplayStatusKeys = [ + "healthy", + "provisioning", + "degraded", + "failed", +] as const satisfies readonly (keyof GatewayDisplayStatusCounts)[]; + +type GatewayDisplayStatusKey = (typeof gatewayDisplayStatusKeys)[number]; + +type GatewayLifecycleRecord = Pick; + +/** Status label shown in the gateway list for a phase/status pair. */ +export function resolveGatewayDisplayStatus( + phase?: string, + status?: string, +): string { + const phaseValue = phase?.trim() ?? ""; + const normalizedPhase = phaseValue.toLocaleLowerCase(); + const healthStatus = status?.trim() ?? ""; + + if ( + phaseValue && + (gatewayPollingStates.has(normalizedPhase) || + gatewayFailedLifecycleStates.has(normalizedPhase)) + ) { + return phaseValue; + } + + return healthStatus || phaseValue || "Unknown"; +} + +function gatewayDisplayStatusBucket( + displayStatus: string, +): GatewayDisplayStatusKey { + const normalized = displayStatus.trim().toLocaleLowerCase(); + + if (normalized === "healthy") { + return "healthy"; + } + if ( + normalized === "provisioning" || + normalized === "pending" || + normalized === "reconciling" || + normalized === "updating" + ) { + return "provisioning"; + } + if (normalized === "degraded") { + return "degraded"; + } + if (normalized === "failed" || normalized === "error") { + return "failed"; + } + + switch (gatewayStatusAppearance(displayStatus)) { + case "success": + return "healthy"; + case "warning": + return "degraded"; + case "danger": + return "failed"; + case "progress": + case "pending": + return "provisioning"; + default: + return "provisioning"; + } +} + +/** Aggregates gateway list rows into dashboard status buckets. */ +export function aggregateGatewayDisplayStatusCounts( + gateways: readonly GatewayLifecycleRecord[], +): GatewayDisplayStatusCounts { + const counts: GatewayDisplayStatusCounts = { + degraded: 0, + failed: 0, + healthy: 0, + provisioning: 0, + }; + + for (const gateway of gateways) { + const bucket = gatewayDisplayStatusBucket( + resolveGatewayDisplayStatus(gateway.phase, gateway.status), + ); + counts[bucket] += 1; + } + + return counts; +} + type GatewayConsoleRecord = Pick< GatewayRecord, "phase" | "status" | "externalDns" | "consoleUrl" @@ -236,14 +347,7 @@ export function toGatewayConnection( ): GatewayConnection { const clusterId = gateway.clusterId.trim(); const phase = gateway.phase?.trim() ?? ""; - const healthStatus = gateway.status?.trim() ?? ""; - const normalizedPhase = phase.toLocaleLowerCase(); - const status = - phase && - (gatewayPollingStates.has(normalizedPhase) || - gatewayFailedLifecycleStates.has(normalizedPhase)) - ? phase - : healthStatus || phase; + const status = resolveGatewayDisplayStatus(phase, gateway.status); return { ...(typeof gateway.activeSandboxCount === "number" diff --git a/packages/gateway-management-ui/src/index.ts b/packages/gateway-management-ui/src/index.ts index 941350d7..bae3f5f3 100644 --- a/packages/gateway-management-ui/src/index.ts +++ b/packages/gateway-management-ui/src/index.ts @@ -58,13 +58,18 @@ export { type GatewayCreatePageProps, } from "./gateways/gateway-create"; export { + aggregateGatewayDisplayStatusCounts, + gatewayCanonicalPhaseStrings, + gatewayCanonicalPhases, gatewayListQueryKey, gatewayListQueryRoot, gatewayPlacementBatchQueryKey, gatewayPlacementDetailQueryKey, gatewayPlacementQueryKey, gatewayQueryKey, + resolveGatewayDisplayStatus, toGatewayConnection, + type GatewayDisplayStatusCounts, } from "./gateways/gateway-data"; export type { GatewayConnection } from "./gateways/gateway-connections"; export { @@ -93,6 +98,7 @@ export { fetchGatewayMetrics, gatewayMetricsQueryKey, gatewayPhases, + emptyGatewayPhaseCounts, type GatewayPhaseCounts, } from "./metrics/gateway-metrics-data"; export { GatewayMetricsDashboard } from "./metrics/gateway-metrics-dashboard"; diff --git a/packages/gateway-management-ui/src/metrics/gateway-metrics-dashboard.test.tsx b/packages/gateway-management-ui/src/metrics/gateway-metrics-dashboard.test.tsx new file mode 100644 index 00000000..36b3f66f --- /dev/null +++ b/packages/gateway-management-ui/src/metrics/gateway-metrics-dashboard.test.tsx @@ -0,0 +1,81 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GatewayMetricsDashboard } from "./gateway-metrics-dashboard"; +import * as gatewayMetricsData from "./gateway-metrics-data"; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function renderDashboard(queryClient = createTestQueryClient()) { + return render( + + + + + , + ); +} + +describe("GatewayMetricsDashboard", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows a loading spinner while metrics are fetching", () => { + vi.spyOn(gatewayMetricsData, "fetchGatewayMetrics").mockReturnValue( + new Promise(() => undefined), + ); + + const { container } = renderDashboard(); + + expect(screen.getByLabelText("Loading metrics")).toBeTruthy(); + expect(container.querySelector(".pf-v6-c-spinner")).toBeTruthy(); + }); + + it("shows recovery guidance when metrics fail to load", async () => { + vi.spyOn(gatewayMetricsData, "fetchGatewayMetrics").mockRejectedValue( + new Error("Metrics unavailable"), + ); + + renderDashboard(); + + expect(await screen.findByText("Metrics could not be loaded")).toBeTruthy(); + expect( + screen.getByText( + "Check that Prometheus is running and reachable, then refresh.", + ), + ).toBeTruthy(); + }); + + it("renders all five phase cards when metrics load", async () => { + vi.spyOn(gatewayMetricsData, "fetchGatewayMetrics").mockResolvedValue({ + Pending: 1, + Running: 5, + Provisioning: 2, + Degraded: 1, + Failed: 0, + }); + + renderDashboard(); + + expect(await screen.findByText("Gateway metrics")).toBeTruthy(); + expect(screen.getByText("Pending")).toBeTruthy(); + expect(screen.getByText("Running")).toBeTruthy(); + expect(screen.getByText("Provisioning")).toBeTruthy(); + expect(screen.getByText("Degraded")).toBeTruthy(); + expect(screen.getByText("Failed")).toBeTruthy(); + expect(screen.getByText("5")).toBeTruthy(); + expect(screen.getByText("2")).toBeTruthy(); + expect(screen.getAllByText("1")).toHaveLength(2); + expect(screen.getByText("0")).toBeTruthy(); + expect(screen.getByText("5 gateways")).toBeTruthy(); + expect(screen.getAllByText("1 gateway")).toHaveLength(2); + expect(screen.getByText("0 gateways")).toBeTruthy(); + }); +}); diff --git a/packages/gateway-management-ui/src/metrics/gateway-metrics-dashboard.tsx b/packages/gateway-management-ui/src/metrics/gateway-metrics-dashboard.tsx index fae2e5d9..1989a0d4 100644 --- a/packages/gateway-management-ui/src/metrics/gateway-metrics-dashboard.tsx +++ b/packages/gateway-management-ui/src/metrics/gateway-metrics-dashboard.tsx @@ -41,6 +41,11 @@ const messages = defineMessages({ defaultMessage: "Loading metrics", description: "Accessible label for the metrics loading spinner.", }, + phasePending: { + id: "app.metrics.phase.pending", + defaultMessage: "Pending", + description: "Label for the Pending gateway phase metric card.", + }, phaseRunning: { id: "app.metrics.phase.running", defaultMessage: "Running", @@ -69,6 +74,7 @@ const messages = defineMessages({ }); const phaseMessageKey = { + Pending: messages.phasePending, Running: messages.phaseRunning, Provisioning: messages.phaseProvisioning, Degraded: messages.phaseDegraded, @@ -76,6 +82,7 @@ const phaseMessageKey = { } as const satisfies Record; const phaseColor: Record = { + Pending: "var(--pf-t--global--color--status--info--default)", Running: "var(--pf-t--global--color--status--success--default)", Provisioning: "var(--pf-t--global--color--status--info--default)", Degraded: "var(--pf-t--global--color--status--warning--default)", diff --git a/packages/gateway-management-ui/src/metrics/gateway-metrics-data.test.ts b/packages/gateway-management-ui/src/metrics/gateway-metrics-data.test.ts new file mode 100644 index 00000000..6fa36e3d --- /dev/null +++ b/packages/gateway-management-ui/src/metrics/gateway-metrics-data.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { fetchGatewayMetrics } from "./gateway-metrics-data"; + +describe("fetchGatewayMetrics", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns phase counts from a successful response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + counts: { + Running: 5, + Provisioning: 2, + Degraded: 1, + Failed: 0, + }, + }), + { status: 200 }, + ), + ); + + await expect(fetchGatewayMetrics()).resolves.toEqual({ + Pending: 0, + Running: 5, + Provisioning: 2, + Degraded: 1, + Failed: 0, + }); + expect(fetch).toHaveBeenCalledWith("/api/hypershell/v1/metrics/gateways", { + credentials: "same-origin", + signal: undefined, + }); + }); + + it("defaults absent phases to zero", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ counts: { Running: 3 } }), { + status: 200, + }), + ); + + await expect(fetchGatewayMetrics()).resolves.toEqual({ + Pending: 0, + Running: 3, + Provisioning: 0, + Degraded: 0, + Failed: 0, + }); + }); + + it("throws when the response is not ok", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 502 }), + ); + + await expect(fetchGatewayMetrics()).rejects.toThrow( + "Failed to fetch gateway metrics: 502", + ); + }); + + it("forwards an abort signal to fetch", async () => { + const controller = new AbortController(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + counts: { + Pending: 0, + Running: 1, + Provisioning: 0, + Degraded: 0, + Failed: 0, + }, + }), + { status: 200 }, + ), + ); + + await fetchGatewayMetrics(controller.signal); + + expect(fetch).toHaveBeenCalledWith("/api/hypershell/v1/metrics/gateways", { + credentials: "same-origin", + signal: controller.signal, + }); + }); +}); diff --git a/packages/gateway-management-ui/src/metrics/gateway-metrics-data.ts b/packages/gateway-management-ui/src/metrics/gateway-metrics-data.ts index 64e81567..a39befd6 100644 --- a/packages/gateway-management-ui/src/metrics/gateway-metrics-data.ts +++ b/packages/gateway-management-ui/src/metrics/gateway-metrics-data.ts @@ -1,19 +1,24 @@ -export interface GatewayPhaseCounts { - Running: number; - Provisioning: number; - Degraded: number; - Failed: number; -} +import { + gatewayCanonicalPhaseStrings, + type GatewayCanonicalPhase, +} from "../gateways/gateway-data"; + +export type GatewayPhaseCounts = Record; -export const gatewayPhases = [ - "Running", - "Provisioning", - "Degraded", - "Failed", -] as const satisfies readonly (keyof GatewayPhaseCounts)[]; +export const gatewayPhases = gatewayCanonicalPhaseStrings; export const gatewayMetricsQueryKey = ["gateways", "metrics"] as const; +export function emptyGatewayPhaseCounts(): GatewayPhaseCounts { + return { + Pending: 0, + Provisioning: 0, + Running: 0, + Degraded: 0, + Failed: 0, + }; +} + export async function fetchGatewayMetrics( signal?: AbortSignal, ): Promise { @@ -27,10 +32,9 @@ export async function fetchGatewayMetrics( ); } const body = (await response.json()) as { counts: Record }; - return { - Running: body.counts.Running ?? 0, - Provisioning: body.counts.Provisioning ?? 0, - Degraded: body.counts.Degraded ?? 0, - Failed: body.counts.Failed ?? 0, - }; + const counts = emptyGatewayPhaseCounts(); + for (const phase of gatewayPhases) { + counts[phase] = body.counts[phase] ?? 0; + } + return counts; } diff --git a/packages/operational-dashboard-ui/.prettierignore b/packages/operational-dashboard-ui/.prettierignore new file mode 100644 index 00000000..b395b940 --- /dev/null +++ b/packages/operational-dashboard-ui/.prettierignore @@ -0,0 +1,3 @@ +coverage +node_modules +*.tsbuildinfo diff --git a/packages/operational-dashboard-ui/DATA_SOURCES.md b/packages/operational-dashboard-ui/DATA_SOURCES.md new file mode 100644 index 00000000..cf9d943c --- /dev/null +++ b/packages/operational-dashboard-ui/DATA_SOURCES.md @@ -0,0 +1,39 @@ +# Operational dashboard data sources + +This document tracks which operational dashboard widgets are backed by live +data and which remain placeholders. Widgets without a connected source still +appear on the dashboard; they render the localized "Metric unavailable" empty +state defined in `operational-dashboard-page.tsx`. + +Data is loaded through `useGetMetricsData` → `dashboard.getOperationalMetrics` +→ `components/web-console/app/adapters/api/dashboard-control-plane.ts`. + +## Connected metrics + +| Widget / metric ID | Source | Notes | +| ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provisioned-gateways` | HyperShell API `GET /api/hypershell/v1/gateways` (paginated) | Display-status breakdown (`healthy`, `provisioning`, `degraded`, `failed`) using the same phase/status presentation rules as the gateway list. Total count. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | +| `gateway-status` | Same as `provisioned-gateways` | Uses the `status` field on the provisioned-gateways metric. | +| `provisioned-sandboxes` | HyperShell API `GET /api/hypershell/v1/gateways` (paginated) | Sum of `active_sandbox_count` across all gateways. Advisory control-plane field; omitted from the response when unset on a gateway. | +| `registered-users` | HyperShell API `GET /api/hypershell/v1/users` (`page=1`, `size=1`) | Total registered users from the List `total` field. Requires dashboard-operator authorization (`platform:admin` or `hypershell-admins`). Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | +| `memory` | BFF `GET /api/metrics/cluster-memory` (Prometheus) | Hub-cluster node memory from Prometheus node-exporter via `sum(node_memory_MemTotal_bytes)` (capacity) and `sum(node_memory_MemAvailable_bytes)` (available). Adapter maps used/capacity bytes to whole GiB for the utilization donut. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | +| `cpu` | BFF `GET /api/metrics/cluster-cpu` (Prometheus) | Hub-cluster node CPU from the same node-exporter DaemonSet as memory. Capacity: `sum(count by (instance) (node_cpu_seconds_total{mode="idle"}))`. Used: `sum(rate(node_cpu_seconds_total{mode!="idle"}[5m]))`. Adapter maps fractional used/capacity cores to whole cores for the utilization donut. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | +| `pods` | BFF `GET /api/metrics/cluster-pods` (Prometheus) | Hub-cluster pod capacity from kube-state-metrics. Capacity: `sum(kube_node_status_allocatable{resource="pods"})`. Used: `count(kube_pod_info)` (all phases while pod objects exist). Phase breakdown: `sum(kube_pod_status_phase{phase=""})`. Adapter maps phase fields to `podPhases`; `pods` widget uses `PodCapacityChart` with gray Unused segment (`total - value`). Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | +| `nodes` | BFF `GET /api/metrics/cluster-nodes` (Prometheus) | Hub-cluster node inventory from kube-state-metrics. Total: `count(kube_node_info)`. Ready: `sum(kube_node_status_condition{condition="Ready",status="true"})`. Adapter maps `ready_nodes` → `status.healthy` and `not_ready_nodes` → `status.failed`. `nodes` widget uses `NodeStatusChart`. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | +| `provision-time` | BFF `GET /api/metrics/gateway-provision-duration` (Prometheus) | Average in the system summary; average, P50, and P95 in the `provision-time` widget from `gateway_provision_duration_seconds` histogram per `platform/gateway-provision-time.spec.md`. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | + +## Not connected (widgets remain, data unavailable) + +| Widget / metric ID | Reason | +| -------------------------------- | ------------------------------------------------------------------------ | +| Sparkline trends on metric cards | Historical series are not queried; only instantaneous values are loaded. | + +## Notes + +- **`provision-time`:** BFF `GET /api/metrics/gateway-provision-duration` queries Prometheus for average, P50, and P95 from `gateway_provision_duration_seconds` (control-plane OTLP histogram per CP-OBS-07). Requires control-plane metrics export through the in-cluster OpenTelemetry Collector (`deploy/base/prometheus/otel-collector.yaml`). + +## Adding a new source + +1. Expose a same-origin BFF route (or reuse an existing API route) for browser access. +2. Extend `createDashboardControlPlaneAdapter` to map the response into `OperationalMetric`. +3. Update this file and the dashboard page description in `messages.ts`. diff --git a/packages/operational-dashboard-ui/eslint.config.mjs b/packages/operational-dashboard-ui/eslint.config.mjs new file mode 100644 index 00000000..938110d8 --- /dev/null +++ b/packages/operational-dashboard-ui/eslint.config.mjs @@ -0,0 +1,62 @@ +import eslint from "@eslint/js"; +import formatjs from "eslint-plugin-formatjs"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import reactCompiler from "eslint-plugin-react-compiler"; +import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; +import query from "@tanstack/eslint-plugin-query"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["coverage/**", "node_modules/**"], + }, + eslint.configs.recommended, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + files: ["**/*.{cjs,js,mjs}"], + extends: [tseslint.configs.disableTypeChecked], + }, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + project: ["./tsconfig.app.json", "./tsconfig.test.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + formatjs, + "jsx-a11y": jsxA11y, + "react-compiler": reactCompiler, + "react-hooks": reactHooks, + "@tanstack/query": query, + }, + rules: { + ...formatjs.configs.recommended.rules, + ...jsxA11y.flatConfigs.recommended.rules, + ...reactCompiler.configs.recommended.rules, + ...reactHooks.configs.flat.recommended.rules, + ...query.configs["flat/recommended"].rules, + "@typescript-eslint/consistent-type-imports": "error", + "formatjs/enforce-default-message": "error", + "formatjs/enforce-id": "error", + "no-console": "error", + }, + }, + { + files: ["src/messages.ts"], + rules: { + "sort-keys": [ + "error", + "asc", + { caseSensitive: false, minKeys: 4, natural: true }, + ], + }, + }, +); diff --git a/packages/operational-dashboard-ui/package.json b/packages/operational-dashboard-ui/package.json new file mode 100644 index 00000000..f59f979e --- /dev/null +++ b/packages/operational-dashboard-ui/package.json @@ -0,0 +1,72 @@ +{ + "name": "@openshift-online/hypershell-operational-dashboard-ui", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./fixtures": "./src/fixtures/mock-operational-dashboard-metrics.ts" + }, + "scripts": { + "build": "tsc --project tsconfig.app.json --noEmit", + "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test:run", + "format:check": "prettier --check .", + "lint": "eslint . --max-warnings=0", + "test": "vitest", + "test:run": "vitest run --coverage", + "typecheck": "tsc --project tsconfig.app.json --noEmit && tsc --project tsconfig.test.json --noEmit" + }, + "peerDependencies": { + "@openshift-online/hypershell-domain-probes": "workspace:0.0.0", + "@patternfly/react-charts": "8.6.1", + "@patternfly/react-core": "6.6.0", + "@patternfly/react-icons": "6.6.0", + "@patternfly/widgetized-dashboard": "1.0.0-prerelease.6", + "@tanstack/react-query": "5.101.4", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-intl": "10.1.18", + "victory-area": "37.3.6", + "victory-core": "37.3.6", + "victory-group": "37.3.6", + "victory-tooltip": "37.3.6", + "victory-voronoi-container": "37.3.6" + }, + "devDependencies": { + "@eslint/js": "9.39.5", + "@openshift-online/hypershell-domain-probes": "workspace:0.0.0", + "@patternfly/react-charts": "8.6.1", + "@tanstack/eslint-plugin-query": "5.101.4", + "@tanstack/react-query": "5.101.4", + "@patternfly/react-core": "6.6.0", + "@patternfly/react-icons": "6.6.0", + "@patternfly/widgetized-dashboard": "1.0.0-prerelease.6", + "@testing-library/react": "16.3.2", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitest/coverage-v8": "4.1.10", + "eslint": "9.39.5", + "eslint-plugin-formatjs": "6.4.19", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-react-compiler": "19.1.0-rc.2", + "eslint-plugin-react-hooks": "7.1.1", + "globals": "16.5.0", + "jsdom": "27.4.0", + "prettier": "3.9.6", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-intl": "10.1.18", + "typescript": "6.0.3", + "typescript-eslint": "8.65.0", + "vite": "8.1.5", + "vitest": "4.1.10", + "victory-area": "37.3.6", + "victory-core": "37.3.6", + "victory-group": "37.3.6", + "victory-tooltip": "37.3.6", + "victory-voronoi-container": "37.3.6" + }, + "engines": { + "node": ">=24.18.1" + } +} diff --git a/packages/operational-dashboard-ui/src/application/dashboard-operations.ts b/packages/operational-dashboard-ui/src/application/dashboard-operations.ts new file mode 100644 index 00000000..6861c0b7 --- /dev/null +++ b/packages/operational-dashboard-ui/src/application/dashboard-operations.ts @@ -0,0 +1,101 @@ +import type { + DashboardControlPlane, + DashboardInvocationContext, + DashboardOperations, + DashboardWorkflowRuntime, +} from "./dashboard-types"; +import type { + DashboardProbe, + DashboardProbePublisher, + DashboardWorkflowAction, +} from "./dashboard-probes"; +import { noopDashboardProbePublisher } from "./dashboard-probes"; + +export interface DashboardOperationDependencies { + controlPlane: DashboardControlPlane; + probes?: DashboardProbePublisher; + runtime?: DashboardWorkflowRuntime; +} + +const defaultRuntime: DashboardWorkflowRuntime = { + createCorrelationId: () => crypto.randomUUID(), +}; + +const workflowAction: DashboardWorkflowAction = "get-operational-metrics"; + +function isCancelled(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError" + ); +} + +function workflowProbe( + action: DashboardWorkflowAction, + correlationId: string, + name: DashboardProbe["name"], + occurredAt: string, + outcome: DashboardProbe["fields"]["outcome"], +): DashboardProbe { + return Object.freeze({ + context: Object.freeze({ correlationId }), + fields: Object.freeze({ action, outcome }), + name, + occurredAt, + schemaVersion: 1, + }); +} + +export function createDashboardOperations({ + controlPlane, + probes = noopDashboardProbePublisher, + runtime = defaultRuntime, +}: DashboardOperationDependencies): DashboardOperations { + return { + getOperationalMetrics: async (signal) => { + const correlationId = runtime.createCorrelationId(); + const context: DashboardInvocationContext = { + correlationId, + ...(signal === undefined ? {} : { signal }), + }; + const occurredAt = new Date().toISOString(); + + probes.publish( + workflowProbe( + workflowAction, + correlationId, + "dashboard.workflow.started", + occurredAt, + "started", + ), + ); + + try { + const metrics = await controlPlane.getOperationalMetrics(context); + probes.publish( + workflowProbe( + workflowAction, + correlationId, + "dashboard.workflow.completed", + new Date().toISOString(), + "succeeded", + ), + ); + return metrics; + } catch (error) { + probes.publish( + workflowProbe( + workflowAction, + correlationId, + "dashboard.workflow.completed", + new Date().toISOString(), + isCancelled(error) ? "cancelled" : "failed", + ), + ); + throw error; + } + }, + }; +} diff --git a/packages/operational-dashboard-ui/src/application/dashboard-probes.ts b/packages/operational-dashboard-ui/src/application/dashboard-probes.ts new file mode 100644 index 00000000..5c01bab3 --- /dev/null +++ b/packages/operational-dashboard-ui/src/application/dashboard-probes.ts @@ -0,0 +1,35 @@ +import type { + DomainProbe, + DomainProbePublisher, +} from "@openshift-online/hypershell-domain-probes"; + +export type DashboardWorkflowAction = "get-operational-metrics"; +export type DashboardLayoutAction = "persist-layout-template"; +export type DashboardProbeAction = + DashboardWorkflowAction | DashboardLayoutAction; + +export type DashboardProbeOutcome = + "started" | "succeeded" | "failed" | "cancelled"; + +export type DashboardProbeName = + | "dashboard.workflow.started" + | "dashboard.workflow.completed" + | "dashboard.layout.template.invalid" + | "dashboard.layout.template.persistence-failed"; + +export type DashboardProbe = DomainProbe< + DashboardProbeName, + 1, + { + readonly action: DashboardProbeAction; + readonly outcome: DashboardProbeOutcome; + } +>; + +export type DashboardProbePublisher = DomainProbePublisher; + +export const noopDashboardProbePublisher: DashboardProbePublisher = { + publish() { + return undefined; + }, +}; diff --git a/packages/operational-dashboard-ui/src/application/dashboard-types.ts b/packages/operational-dashboard-ui/src/application/dashboard-types.ts new file mode 100644 index 00000000..085cdc97 --- /dev/null +++ b/packages/operational-dashboard-ui/src/application/dashboard-types.ts @@ -0,0 +1,83 @@ +export interface OperationalMetricTrendPoint { + label: string; + value: number; +} + +export interface OperationalMetricTrend { + points: readonly OperationalMetricTrendPoint[]; +} + +export interface OperationalMetricStatus { + degraded?: number; + failed?: number; + healthy?: number; + provisioning?: number; +} + +export interface OperationalMetricPodPhases { + failed: number; + pending: number; + running: number; + succeeded: number; + unknown: number; +} + +export interface OperationalMetricProvisionDuration { + mean: string; + p50: string; + p95: string; +} + +export interface OperationalMetric { + id: string; + podPhases?: OperationalMetricPodPhases; + provisionDuration?: OperationalMetricProvisionDuration; + status?: OperationalMetricStatus; + total?: string; + trend?: OperationalMetricTrend; + unit?: string; + value: string; +} + +export interface SignupTrendPoint { + label: string; + value: number; +} + +export interface OperationalDashboardMetrics { + lastSuccessfulRefresh: Date; + metrics: readonly OperationalMetric[]; +} + +export interface DashboardInvocationContext { + correlationId: string; + signal?: AbortSignal; +} + +/** Application-owned driven port for operational dashboard metrics. */ +export interface DashboardControlPlane { + getOperationalMetrics( + context: DashboardInvocationContext, + ): Promise; +} + +/** Driving entry port used by the operational dashboard presentation adapters. */ +export interface DashboardOperations { + getOperationalMetrics( + signal?: AbortSignal, + ): Promise; +} + +/** Application-owned port for nondeterministic workflow context. */ +export interface DashboardWorkflowRuntime { + createCorrelationId(): string; +} + +export type { + DashboardProbe, + DashboardProbeAction, + DashboardProbeName, + DashboardProbeOutcome, + DashboardProbePublisher, + DashboardWorkflowAction, +} from "./dashboard-probes"; diff --git a/packages/operational-dashboard-ui/src/dashboard-ui-provider.tsx b/packages/operational-dashboard-ui/src/dashboard-ui-provider.tsx new file mode 100644 index 00000000..859f1a75 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard-ui-provider.tsx @@ -0,0 +1,41 @@ +import { createContext, type PropsWithChildren, useContext } from "react"; + +import type { DashboardOperations } from "./application/dashboard-types"; +import type { DashboardProbePublisher } from "./application/dashboard-probes"; + +export interface DashboardUiNavigation { + collectionHref: string; + navigate: (href: string) => Promise | void; +} + +export interface DashboardUiServices { + dashboard: DashboardOperations; + navigation: DashboardUiNavigation; + probes?: DashboardProbePublisher; +} + +const DashboardUiContext = createContext( + undefined, +); + +export function DashboardUiProvider({ + children, + dashboard, + navigation, + probes, +}: PropsWithChildren) { + return ( + + {children} + + ); +} + +export function useDashboardUi(): DashboardUiServices { + const services = useContext(DashboardUiContext); + if (!services) { + throw new Error("Dashboard UI must be rendered within DashboardUiProvider"); + } + + return services; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-data.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-data.ts new file mode 100644 index 00000000..e015482a --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-data.ts @@ -0,0 +1,10 @@ +export const operationalDashboardMetricsQueryRoot = [ + "operational-dashboard", + "metrics", +] as const; + +export const operationalDashboardRefreshMilliseconds = 15 * 60 * 1000; + +export function operationalDashboardMetricsQueryKey() { + return [...operationalDashboardMetricsQueryRoot] as const; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.test.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.test.ts new file mode 100644 index 00000000..401a5579 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { ExtendedTemplateConfig } from "@patternfly/widgetized-dashboard"; + +import { defaultDashboardLayoutTemplate } from "./dashboard-layout-template"; +import { + getActiveWidgetTypes, + isValidSavedTemplate, + sanitizeDashboardTemplate, +} from "./dashboard-layout-persistence"; + +describe("dashboard layout persistence", () => { + it("collects active widget types across responsive variants", () => { + expect(getActiveWidgetTypes(defaultDashboardLayoutTemplate)).toEqual([ + "usage-summary", + "gateway-status", + "provision-time", + "provisioned-sandboxes", + "memory", + "system-summary", + "registered-users", + "cpu", + "pods", + "nodes", + ]); + }); + + it("accepts saved templates that define every responsive variant", () => { + expect( + isValidSavedTemplate( + defaultDashboardLayoutTemplate, + defaultDashboardLayoutTemplate, + ), + ).toBe(true); + }); + + it("rejects saved templates that omit a responsive variant", () => { + expect( + isValidSavedTemplate( + { xl: defaultDashboardLayoutTemplate.xl } as ExtendedTemplateConfig, + defaultDashboardLayoutTemplate, + ), + ).toBe(false); + }); + + it("deduplicates widget types within a variant on save", () => { + const cpuWidget = defaultDashboardLayoutTemplate.xl.find( + (item) => item.widgetType === "cpu", + ); + if (!cpuWidget) { + throw new Error("expected cpu widget in default layout"); + } + + const duplicateCpu = { + ...defaultDashboardLayoutTemplate, + xl: [ + ...defaultDashboardLayoutTemplate.xl, + { + ...cpuWidget, + i: "cpu#2", + }, + ], + }; + + const sanitized = sanitizeDashboardTemplate(duplicateCpu); + + expect( + sanitized.xl.filter((item) => item.widgetType === "cpu"), + ).toHaveLength(1); + }); +}); diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.ts new file mode 100644 index 00000000..9f46933b --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.ts @@ -0,0 +1,43 @@ +import type { + ExtendedTemplateConfig, + Variants, +} from "@patternfly/widgetized-dashboard"; + +export function getActiveWidgetTypes( + template: ExtendedTemplateConfig, +): string[] { + const types = new Set(); + + for (const variant of Object.keys(template) as Variants[]) { + for (const item of template[variant]) { + types.add(item.widgetType); + } + } + + return [...types]; +} + +export function isValidSavedTemplate( + savedTemplate: ExtendedTemplateConfig, + baseTemplate: ExtendedTemplateConfig, +): boolean { + return (Object.keys(baseTemplate) as Variants[]).every((variant) => + Array.isArray(savedTemplate[variant]), + ); +} + +export function sanitizeDashboardTemplate( + template: ExtendedTemplateConfig, +): ExtendedTemplateConfig { + return (Object.keys(template) as Variants[]).reduce((acc, variant) => { + const seen = new Set(); + acc[variant] = template[variant].filter((item) => { + if (seen.has(item.widgetType)) { + return false; + } + seen.add(item.widgetType); + return true; + }); + return acc; + }, {} as ExtendedTemplateConfig); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts new file mode 100644 index 00000000..ac117677 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts @@ -0,0 +1,292 @@ +import type { ExtendedTemplateConfig } from "@patternfly/widgetized-dashboard"; +import type { IntlShape } from "react-intl"; + +import { messages } from "../messages"; + +const METRIC_WIDGET_HEIGHT = 3; +const METRIC_ROW_GAP = 1; +const METRIC_ROW_STEP = METRIC_WIDGET_HEIGHT + METRIC_ROW_GAP; +/** One row taller than standard metric widgets; fits a compact status donut. */ +export const NODE_STATUS_WIDGET_HEIGHT = METRIC_WIDGET_HEIGHT + 1; +/** Pod capacity donut shares the same height as the nodes status widget. */ +export const POD_CAPACITY_WIDGET_HEIGHT = NODE_STATUS_WIDGET_HEIGHT; +/** Gateway status spans two metric rows plus the row gap between them. */ +export const GATEWAY_STATUS_WIDGET_HEIGHT = + METRIC_WIDGET_HEIGHT * 2 + METRIC_ROW_GAP; +const SUMMARY_COLUMN_HEIGHT = METRIC_WIDGET_HEIGHT + 2 * METRIC_ROW_STEP; +const BASE_SUMMARY_WIDGET_HEIGHT = (SUMMARY_COLUMN_HEIGHT - METRIC_ROW_GAP) / 2; +/** Equal height for usage and system summary widgets in the left column. */ +export const USAGE_SUMMARY_WIDGET_HEIGHT = BASE_SUMMARY_WIDGET_HEIGHT + 1; +/** One row taller than usage summary; fits exception status rows on pods and nodes. */ +export const SYSTEM_SUMMARY_WIDGET_HEIGHT = USAGE_SUMMARY_WIDGET_HEIGHT + 1; +/** Stats list and P95 note. */ +export const PROVISION_TIME_WIDGET_HEIGHT = METRIC_WIDGET_HEIGHT + 1; +const SYSTEM_SUMMARY_WIDGET_Y = USAGE_SUMMARY_WIDGET_HEIGHT + METRIC_ROW_GAP; + +const WIDGET_TITLE_MESSAGES = { + "usage-summary": messages.usageSummaryWidget, + "system-summary": messages.systemSummaryWidget, + "registered-users": messages.registeredUsers, + "gateway-status": messages.gatewayStatusWidget, + memory: messages.widgetMemory, + nodes: messages.nodes, + "provision-time": messages.provisionTimeWidget, + "provisioned-sandboxes": messages.widgetSandboxes, + cpu: messages.widgetCpu, + pods: messages.widgetPods, +} as const; + +type DashboardWidgetType = keyof typeof WIDGET_TITLE_MESSAGES; + +const fourColumnLayout = [ + { + h: USAGE_SUMMARY_WIDGET_HEIGHT, + i: "usage-summary#1", + title: "Usage summary", + w: 1, + widgetType: "usage-summary", + x: 0, + y: 0, + }, + { + h: GATEWAY_STATUS_WIDGET_HEIGHT, + i: "gateway-status#1", + title: "Gateway status", + w: 1, + widgetType: "gateway-status", + x: 1, + y: 0, + }, + { + h: PROVISION_TIME_WIDGET_HEIGHT, + i: "provision-time#1", + title: "Provision time", + w: 1, + widgetType: "provision-time", + x: 1, + y: GATEWAY_STATUS_WIDGET_HEIGHT, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "provisioned-sandboxes#1", + title: "Sandboxes", + w: 1, + widgetType: "provisioned-sandboxes", + x: 2, + y: 0, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "memory#1", + title: "Memory", + w: 1, + widgetType: "memory", + x: 3, + y: 0, + }, + { + h: SYSTEM_SUMMARY_WIDGET_HEIGHT, + i: "system-summary#1", + title: "System summary", + w: 1, + widgetType: "system-summary", + x: 0, + y: SYSTEM_SUMMARY_WIDGET_Y, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "registered-users#1", + title: "Registered users", + w: 1, + widgetType: "registered-users", + x: 2, + y: METRIC_ROW_STEP, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "cpu#1", + title: "CPU", + w: 1, + widgetType: "cpu", + x: 3, + y: METRIC_ROW_STEP, + }, + { + h: POD_CAPACITY_WIDGET_HEIGHT, + i: "pods#1", + title: "Pods", + w: 1, + widgetType: "pods", + x: 3, + y: METRIC_ROW_STEP * 2, + }, + { + h: NODE_STATUS_WIDGET_HEIGHT, + i: "nodes#1", + title: "Nodes", + w: 1, + widgetType: "nodes", + x: 2, + y: METRIC_ROW_STEP * 2, + }, +] as const; + +export const defaultDashboardLayoutTemplate: ExtendedTemplateConfig = { + xl: [...fourColumnLayout], + lg: [...fourColumnLayout], + md: [...fourColumnLayout], + sm: [ + { + h: USAGE_SUMMARY_WIDGET_HEIGHT, + i: "usage-summary#1", + title: "Usage summary", + w: 1, + widgetType: "usage-summary", + x: 0, + y: 0, + }, + { + h: SYSTEM_SUMMARY_WIDGET_HEIGHT, + i: "system-summary#1", + title: "System summary", + w: 1, + widgetType: "system-summary", + x: 0, + y: SYSTEM_SUMMARY_WIDGET_Y, + }, + { + h: GATEWAY_STATUS_WIDGET_HEIGHT, + i: "gateway-status#1", + title: "Gateway status", + w: 1, + widgetType: "gateway-status", + x: 0, + y: + USAGE_SUMMARY_WIDGET_HEIGHT + + METRIC_ROW_GAP + + SYSTEM_SUMMARY_WIDGET_HEIGHT, + }, + { + h: PROVISION_TIME_WIDGET_HEIGHT, + i: "provision-time#1", + title: "Provision time", + w: 1, + widgetType: "provision-time", + x: 0, + y: + USAGE_SUMMARY_WIDGET_HEIGHT + + METRIC_ROW_GAP + + SYSTEM_SUMMARY_WIDGET_HEIGHT + + GATEWAY_STATUS_WIDGET_HEIGHT, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "provisioned-sandboxes#1", + title: "Sandboxes", + w: 1, + widgetType: "provisioned-sandboxes", + x: 0, + y: + USAGE_SUMMARY_WIDGET_HEIGHT + + METRIC_ROW_GAP + + SYSTEM_SUMMARY_WIDGET_HEIGHT + + GATEWAY_STATUS_WIDGET_HEIGHT + + PROVISION_TIME_WIDGET_HEIGHT, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "memory#1", + title: "Memory", + w: 1, + widgetType: "memory", + x: 0, + y: + USAGE_SUMMARY_WIDGET_HEIGHT + + METRIC_ROW_GAP + + SYSTEM_SUMMARY_WIDGET_HEIGHT + + GATEWAY_STATUS_WIDGET_HEIGHT + + PROVISION_TIME_WIDGET_HEIGHT + + METRIC_ROW_STEP, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "registered-users#1", + title: "Registered users", + w: 1, + widgetType: "registered-users", + x: 0, + y: + USAGE_SUMMARY_WIDGET_HEIGHT + + METRIC_ROW_GAP + + SYSTEM_SUMMARY_WIDGET_HEIGHT + + GATEWAY_STATUS_WIDGET_HEIGHT + + PROVISION_TIME_WIDGET_HEIGHT + + METRIC_ROW_STEP * 2, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "cpu#1", + title: "CPU", + w: 1, + widgetType: "cpu", + x: 0, + y: + USAGE_SUMMARY_WIDGET_HEIGHT + + METRIC_ROW_GAP + + SYSTEM_SUMMARY_WIDGET_HEIGHT + + GATEWAY_STATUS_WIDGET_HEIGHT + + PROVISION_TIME_WIDGET_HEIGHT + + METRIC_ROW_STEP * 3, + }, + { + h: POD_CAPACITY_WIDGET_HEIGHT, + i: "pods#1", + title: "Pods", + w: 1, + widgetType: "pods", + x: 0, + y: + USAGE_SUMMARY_WIDGET_HEIGHT + + METRIC_ROW_GAP + + SYSTEM_SUMMARY_WIDGET_HEIGHT + + GATEWAY_STATUS_WIDGET_HEIGHT + + PROVISION_TIME_WIDGET_HEIGHT + + METRIC_ROW_STEP * 4, + }, + { + h: NODE_STATUS_WIDGET_HEIGHT, + i: "nodes#1", + title: "Nodes", + w: 1, + widgetType: "nodes", + x: 0, + y: + USAGE_SUMMARY_WIDGET_HEIGHT + + METRIC_ROW_GAP + + SYSTEM_SUMMARY_WIDGET_HEIGHT + + GATEWAY_STATUS_WIDGET_HEIGHT + + PROVISION_TIME_WIDGET_HEIGHT + + METRIC_ROW_STEP * 5, + }, + ], +}; + +export function localizeDashboardLayoutTemplate( + template: ExtendedTemplateConfig, + intl: IntlShape, +): ExtendedTemplateConfig { + return (Object.keys(template) as (keyof ExtendedTemplateConfig)[]).reduce( + (localized, variant) => { + localized[variant] = template[variant].map((item) => { + const widgetType = item.widgetType as DashboardWidgetType; + + return { + ...item, + title: intl.formatMessage(WIDGET_TITLE_MESSAGES[widgetType]), + }; + }); + return localized; + }, + {} as ExtendedTemplateConfig, + ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/gateway-exception-status-counts.ts b/packages/operational-dashboard-ui/src/dashboard/gateway-exception-status-counts.ts new file mode 100644 index 00000000..b1a8de06 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/gateway-exception-status-counts.ts @@ -0,0 +1,10 @@ +import type { OperationalMetricStatus } from "../application/dashboard-types"; + +export function getGatewayExceptionStatusCounts( + status: OperationalMetricStatus, +): Readonly<{ degraded: number; failed: number }> { + return { + degraded: status.degraded ?? 0, + failed: status.failed ?? 0, + }; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/gateway-status-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/gateway-status-chart.tsx new file mode 100644 index 00000000..40dfe0ec --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/gateway-status-chart.tsx @@ -0,0 +1,50 @@ +import { useMemo } from "react"; +import { useIntl } from "react-intl"; + +import type { OperationalMetric } from "../application/dashboard-types"; +import { messages } from "../messages"; +import { buildGatewayStatusData } from "./gateway-status-data"; +import { formatOperationalMetricDisplayValue } from "./operational-metric-display"; +import { isStatusDonutMetric } from "./status-donut-metric"; +import { StatusDonutChart } from "./status-donut-chart"; +import type { StatusDonutDatum } from "./status-donut-data"; + +export { isStatusDonutMetric as isGatewayStatusMetric } from "./status-donut-metric"; + +export function GatewayStatusChart({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + + const { colorScale, data, legendData } = useMemo(() => { + if (!isStatusDonutMetric(metric)) { + return { colorScale: [], data: [], legendData: [] }; + } + + return buildGatewayStatusData(intl, metric.status); + }, [intl, metric]); + + if (!isStatusDonutMetric(metric)) { + return null; + } + + return ( + + datum.x + ? intl.formatMessage(messages.statusDonutDataLabel, { + count: datum.y, + status: datum.x, + }) + : null + } + legendData={legendData} + subTitle={intl.formatMessage(messages.gateways)} + title={formatOperationalMetricDisplayValue(metric.value, intl)} + /> + ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/gateway-status-data.test.ts b/packages/operational-dashboard-ui/src/dashboard/gateway-status-data.test.ts new file mode 100644 index 00000000..57294df0 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/gateway-status-data.test.ts @@ -0,0 +1,55 @@ +import { createIntl, createIntlCache } from "react-intl"; +import { describe, expect, it } from "vitest"; + +import { messages } from "../messages"; +import { + buildGatewayStatusData, + GATEWAY_STATUS_COLORS, +} from "./gateway-status-data"; + +const intlMessages = Object.fromEntries( + Object.values(messages).map((message) => [ + message.id, + message.defaultMessage, + ]), +); +const intl = createIntl( + { locale: "en", messages: intlMessages }, + createIntlCache(), +); + +describe("buildGatewayStatusData", () => { + it("omits zero-count buckets from donut data", () => { + const result = buildGatewayStatusData(intl, { + degraded: 1, + failed: 0, + healthy: 5, + provisioning: 2, + }); + + expect(result.data).toEqual([ + { x: "Healthy", y: 5 }, + { x: "Provisioning", y: 2 }, + { x: "Degraded", y: 1 }, + ]); + expect(result.colorScale).toEqual([ + GATEWAY_STATUS_COLORS.healthy, + GATEWAY_STATUS_COLORS.provisioning, + GATEWAY_STATUS_COLORS.degraded, + ]); + expect(result.legendData).toHaveLength(3); + }); + + it("returns empty series when every bucket is zero", () => { + const result = buildGatewayStatusData(intl, { + degraded: 0, + failed: 0, + healthy: 0, + provisioning: 0, + }); + + expect(result.data).toEqual([]); + expect(result.colorScale).toEqual([]); + expect(result.legendData).toEqual([]); + }); +}); diff --git a/packages/operational-dashboard-ui/src/dashboard/gateway-status-data.ts b/packages/operational-dashboard-ui/src/dashboard/gateway-status-data.ts new file mode 100644 index 00000000..d6e11497 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/gateway-status-data.ts @@ -0,0 +1,60 @@ +import type { IntlShape } from "react-intl"; + +import type { OperationalMetricStatus } from "../application/dashboard-types"; +import { messages } from "../messages"; +import { STATUS_DONUT_COLORS } from "./status-donut-colors"; +import { + buildStatusDonutData, + type StatusDonutDatum, + type StatusDonutLegendDatum, + type StatusDonutSeries, +} from "./status-donut-data"; + +export const GATEWAY_STATUS_COLORS = STATUS_DONUT_COLORS; + +export const GATEWAY_STATUS_ORDER = [ + "healthy", + "provisioning", + "degraded", + "failed", +] as const satisfies readonly (keyof OperationalMetricStatus)[]; + +export type GatewayStatusKey = (typeof GATEWAY_STATUS_ORDER)[number]; + +export type GatewayStatusDatum = StatusDonutDatum; +export type GatewayStatusLegendDatum = StatusDonutLegendDatum; + +function gatewayStatusLabel(intl: IntlShape, status: GatewayStatusKey): string { + switch (status) { + case "healthy": + return intl.formatMessage(messages.gatewayStatusHealthy); + case "provisioning": + return intl.formatMessage(messages.gatewayStatusProvisioning); + case "degraded": + return intl.formatMessage(messages.gatewayStatusDegraded); + case "failed": + return intl.formatMessage(messages.gatewayStatusFailed); + } +} + +export function buildGatewayStatusData( + intl: IntlShape, + status: OperationalMetricStatus, +): StatusDonutSeries { + return buildStatusDonutData( + GATEWAY_STATUS_ORDER.map((key) => { + const count = status[key] ?? 0; + const label = gatewayStatusLabel(intl, key); + + return { + color: GATEWAY_STATUS_COLORS[key], + count, + label, + legendName: intl.formatMessage(messages.statusDonutLegend, { + count, + status: label, + }), + }; + }), + ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.test.ts b/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.test.ts new file mode 100644 index 00000000..7beda676 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import type { OperationalMetric } from "../application/dashboard-types"; +import { getMetricTrendChange } from "./metric-trend-change"; + +function metricWithTrend(values: number[]): OperationalMetric { + return { + id: "pods", + trend: { + points: values.map((value, index) => ({ + label: `Day ${String(index + 1)}`, + value, + })), + }, + value: String(values.at(-1) ?? 0), + }; +} + +describe("getMetricTrendChange", () => { + it("detects an increase above the default threshold", () => { + expect(getMetricTrendChange(metricWithTrend([100, 110]))).toEqual({ + direction: "increase", + percent: 10, + }); + }); + + it("detects a decrease above the default threshold", () => { + expect(getMetricTrendChange(metricWithTrend([100, 90]))).toEqual({ + direction: "decrease", + percent: 10, + }); + }); + + it("returns undefined when the change is within the threshold", () => { + expect(getMetricTrendChange(metricWithTrend([100, 104]))).toBeUndefined(); + }); + + it("returns undefined when the starting trend value is zero", () => { + expect(getMetricTrendChange(metricWithTrend([0, 50]))).toBeUndefined(); + }); +}); diff --git a/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.ts b/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.ts new file mode 100644 index 00000000..0c71e724 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.ts @@ -0,0 +1,46 @@ +import type { OperationalMetric } from "../application/dashboard-types"; + +export const TREND_CHANGE_THRESHOLD_PERCENT = 5; + +export type MetricTrendDirection = "increase" | "decrease"; + +export interface MetricTrendChange { + direction: MetricTrendDirection; + percent: number; +} + +export function getMetricTrendChange( + metric: OperationalMetric, + thresholdPercent = TREND_CHANGE_THRESHOLD_PERCENT, +): MetricTrendChange | undefined { + const trendPoints = metric.trend?.points; + const firstPoint = trendPoints?.[0]; + const lastPoint = trendPoints?.at(-1); + if (!firstPoint || !lastPoint) { + return undefined; + } + + const startValue = firstPoint.value; + const currentValue = lastPoint.value; + if (startValue === 0) { + return undefined; + } + + const percentChange = ((currentValue - startValue) / startValue) * 100; + + if (percentChange >= thresholdPercent) { + return { + direction: "increase", + percent: Math.round(percentChange), + }; + } + + if (percentChange <= -thresholdPercent) { + return { + direction: "decrease", + percent: Math.round(Math.abs(percentChange)), + }; + } + + return undefined; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/node-status-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/node-status-chart.tsx new file mode 100644 index 00000000..0b8a37e3 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/node-status-chart.tsx @@ -0,0 +1,48 @@ +import { useMemo } from "react"; +import { useIntl } from "react-intl"; + +import type { OperationalMetric } from "../application/dashboard-types"; +import { messages } from "../messages"; +import { buildNodeStatusData } from "./node-status-data"; +import { formatOperationalMetricDisplayValue } from "./operational-metric-display"; +import { isStatusDonutMetric } from "./status-donut-metric"; +import { StatusDonutChart } from "./status-donut-chart"; +import type { StatusDonutDatum } from "./status-donut-data"; + +export function NodeStatusChart({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + + const { colorScale, data, legendData } = useMemo(() => { + if (!isStatusDonutMetric(metric)) { + return { colorScale: [], data: [], legendData: [] }; + } + + return buildNodeStatusData(intl, metric.status); + }, [intl, metric]); + + if (!isStatusDonutMetric(metric)) { + return null; + } + + return ( + + datum.x + ? intl.formatMessage(messages.statusDonutDataLabel, { + count: datum.y, + status: datum.x, + }) + : null + } + legendData={legendData} + size="compact" + title={formatOperationalMetricDisplayValue(metric.value, intl)} + /> + ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/node-status-data.test.ts b/packages/operational-dashboard-ui/src/dashboard/node-status-data.test.ts new file mode 100644 index 00000000..c0bbe069 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/node-status-data.test.ts @@ -0,0 +1,62 @@ +import { createIntl, createIntlCache } from "react-intl"; +import { describe, expect, it } from "vitest"; + +import { messages } from "../messages"; +import { buildNodeStatusData, NODE_STATUS_ORDER } from "./node-status-data"; +import { STATUS_DONUT_COLORS } from "./status-donut-colors"; + +const intlMessages = Object.fromEntries( + Object.values(messages).map((message) => [ + message.id, + message.defaultMessage, + ]), +); +const intl = createIntl( + { locale: "en", messages: intlMessages }, + createIntlCache(), +); + +describe("buildNodeStatusData", () => { + it("maps healthy and failed buckets to ready and not-ready labels", () => { + const result = buildNodeStatusData(intl, { + failed: 1, + healthy: 7, + }); + + expect(result.data).toEqual([ + { x: "Ready", y: 7 }, + { x: "Not ready", y: 1 }, + ]); + expect(result.colorScale).toEqual([ + STATUS_DONUT_COLORS.healthy, + STATUS_DONUT_COLORS.failed, + ]); + expect(result.legendData).toHaveLength(2); + }); + + it("omits zero-count buckets from donut data", () => { + const result = buildNodeStatusData(intl, { + failed: 0, + healthy: 8, + }); + + expect(result.data).toEqual([{ x: "Ready", y: 8 }]); + expect(result.colorScale).toEqual([STATUS_DONUT_COLORS.healthy]); + expect(result.legendData).toHaveLength(1); + }); + + it("returns empty series when every bucket is zero", () => { + const result = buildNodeStatusData(intl, { + failed: 0, + healthy: 0, + }); + + expect(result.data).toEqual([]); + expect(result.colorScale).toEqual([]); + expect(result.legendData).toEqual([]); + }); + + it("uses the ready bucket before the not-ready bucket", () => { + expect(NODE_STATUS_ORDER).toEqual(["healthy", "failed"]); + }); +}); diff --git a/packages/operational-dashboard-ui/src/dashboard/node-status-data.ts b/packages/operational-dashboard-ui/src/dashboard/node-status-data.ts new file mode 100644 index 00000000..63df4ae5 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/node-status-data.ts @@ -0,0 +1,52 @@ +import type { IntlShape } from "react-intl"; + +import type { OperationalMetricStatus } from "../application/dashboard-types"; +import { messages } from "../messages"; +import { STATUS_DONUT_COLORS } from "./status-donut-colors"; +import { + buildStatusDonutData, + type StatusDonutDatum, + type StatusDonutLegendDatum, + type StatusDonutSeries, +} from "./status-donut-data"; + +export const NODE_STATUS_ORDER = [ + "healthy", + "failed", +] as const satisfies readonly (keyof OperationalMetricStatus)[]; + +type NodeStatusKey = (typeof NODE_STATUS_ORDER)[number]; + +export type NodeStatusDatum = StatusDonutDatum; +export type NodeStatusLegendDatum = StatusDonutLegendDatum; + +function nodeStatusLabel(intl: IntlShape, status: NodeStatusKey): string { + switch (status) { + case "healthy": + return intl.formatMessage(messages.nodeStatusReady); + case "failed": + return intl.formatMessage(messages.nodeStatusNotReady); + } +} + +export function buildNodeStatusData( + intl: IntlShape, + status: OperationalMetricStatus, +): StatusDonutSeries { + return buildStatusDonutData( + NODE_STATUS_ORDER.map((key) => { + const count = status[key] ?? 0; + const label = nodeStatusLabel(intl, key); + + return { + color: STATUS_DONUT_COLORS[key], + count, + label, + legendName: intl.formatMessage(messages.statusDonutLegend, { + count, + status: label, + }), + }; + }), + ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/operational-metric-display.test.ts b/packages/operational-dashboard-ui/src/dashboard/operational-metric-display.test.ts new file mode 100644 index 00000000..144ad1fb --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/operational-metric-display.test.ts @@ -0,0 +1,48 @@ +import { createIntl, createIntlCache } from "react-intl"; +import { describe, expect, it } from "vitest"; + +import { messages } from "../messages"; +import { + formatOperationalMetricDisplayValue, + isDisplayableOperationalMetricValue, +} from "./operational-metric-display"; + +const intl = createIntl( + { + locale: "en", + messages: Object.fromEntries( + Object.values(messages).map((message) => [ + message.id, + message.defaultMessage, + ]), + ), + }, + createIntlCache(), +); + +describe("isDisplayableOperationalMetricValue", () => { + it("accepts finite decimal strings", () => { + expect(isDisplayableOperationalMetricValue("0")).toBe(true); + expect(isDisplayableOperationalMetricValue("42")).toBe(true); + expect(isDisplayableOperationalMetricValue("5.25")).toBe(true); + }); + + it("rejects non-finite literals and coercions", () => { + expect(isDisplayableOperationalMetricValue("NaN")).toBe(false); + expect(isDisplayableOperationalMetricValue("Infinity")).toBe(false); + expect(isDisplayableOperationalMetricValue("-Infinity")).toBe(false); + expect(isDisplayableOperationalMetricValue("not-a-number")).toBe(false); + }); +}); + +describe("formatOperationalMetricDisplayValue", () => { + it("returns the localized fallback for non-displayable values", () => { + expect(formatOperationalMetricDisplayValue("NaN", intl)).toBe( + "Metric could not be determined", + ); + }); + + it("returns the original value when displayable", () => { + expect(formatOperationalMetricDisplayValue("12", intl)).toBe("12"); + }); +}); diff --git a/packages/operational-dashboard-ui/src/dashboard/operational-metric-display.ts b/packages/operational-dashboard-ui/src/dashboard/operational-metric-display.ts new file mode 100644 index 00000000..bab21742 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/operational-metric-display.ts @@ -0,0 +1,24 @@ +import type { IntlShape } from "react-intl"; + +import { messages } from "../messages"; + +const NON_DISPLAYABLE_LITERALS = new Set(["NaN", "Infinity", "-Infinity"]); + +export function isDisplayableOperationalMetricValue(value: string): boolean { + if (NON_DISPLAYABLE_LITERALS.has(value)) { + return false; + } + + return Number.isFinite(Number(value)); +} + +export function formatOperationalMetricDisplayValue( + value: string, + intl: IntlShape, +): string { + if (!isDisplayableOperationalMetricValue(value)) { + return intl.formatMessage(messages.metricCouldNotBeDetermined); + } + + return value; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/pod-capacity-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/pod-capacity-chart.tsx new file mode 100644 index 00000000..f83fe367 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/pod-capacity-chart.tsx @@ -0,0 +1,65 @@ +import { useMemo } from "react"; +import { useIntl } from "react-intl"; + +import type { OperationalMetric } from "../application/dashboard-types"; +import { messages } from "../messages"; +import { buildPodCapacityData } from "./pod-capacity-data"; +import { isPodCapacityMetric } from "./pod-capacity-metric"; +import { + formatOperationalMetricDisplayValue, + isDisplayableOperationalMetricValue, +} from "./operational-metric-display"; +import { StatusDonutChart } from "./status-donut-chart"; +import type { StatusDonutDatum } from "./status-donut-data"; + +export function PodCapacityChart({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + const displayValue = formatOperationalMetricDisplayValue(metric.value, intl); + + const { colorScale, data, legendData } = useMemo(() => { + if (!isPodCapacityMetric(metric)) { + return { colorScale: [], data: [], legendData: [] }; + } + + return buildPodCapacityData( + intl, + Number(metric.value), + Number(metric.total), + metric.podPhases, + ); + }, [intl, metric]); + + if (!isPodCapacityMetric(metric)) { + return null; + } + + return ( + + datum.x + ? intl.formatMessage(messages.statusDonutDataLabel, { + count: datum.y, + status: datum.x, + }) + : null + } + legendData={legendData} + size="compact" + subTitle={ + isDisplayableOperationalMetricValue(metric.total) + ? intl.formatMessage(messages.utilizationSubtitle, { + total: metric.total, + unit: metric.unit, + }) + : undefined + } + title={displayValue} + /> + ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/pod-capacity-data.test.ts b/packages/operational-dashboard-ui/src/dashboard/pod-capacity-data.test.ts new file mode 100644 index 00000000..695c4d96 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/pod-capacity-data.test.ts @@ -0,0 +1,73 @@ +import { createIntl, createIntlCache } from "react-intl"; +import { describe, expect, it } from "vitest"; + +import { messages } from "../messages"; +import { + buildPodCapacityData, + POD_CAPACITY_PHASE_ORDER, +} from "./pod-capacity-data"; +import { STATUS_DONUT_COLORS } from "./status-donut-colors"; + +const intlMessages = Object.fromEntries( + Object.values(messages).map((message) => [ + message.id, + message.defaultMessage, + ]), +); +const intl = createIntl( + { locale: "en", messages: intlMessages }, + createIntlCache(), +); + +describe("buildPodCapacityData", () => { + it("includes phase segments and unused capacity", () => { + const result = buildPodCapacityData(intl, 548, 2000, { + failed: 16, + pending: 12, + running: 500, + succeeded: 20, + unknown: 0, + }); + + expect(result.data).toEqual([ + { x: "Running", y: 500 }, + { x: "Pending", y: 12 }, + { x: "Failed", y: 16 }, + { x: "Succeeded", y: 20 }, + { x: "Unused", y: 1452 }, + ]); + expect(result.colorScale).toEqual([ + STATUS_DONUT_COLORS.healthy, + STATUS_DONUT_COLORS.provisioning, + STATUS_DONUT_COLORS.failed, + STATUS_DONUT_COLORS.degraded, + STATUS_DONUT_COLORS.unused, + ]); + }); + + it("omits zero-count phase buckets but keeps unused when positive", () => { + const result = buildPodCapacityData(intl, 8, 10, { + failed: 0, + pending: 0, + running: 8, + succeeded: 0, + unknown: 0, + }); + + expect(result.data).toEqual([ + { x: "Running", y: 8 }, + { x: "Unused", y: 2 }, + ]); + }); + + it("orders phases before unused capacity", () => { + expect(POD_CAPACITY_PHASE_ORDER).toEqual([ + "running", + "pending", + "failed", + "succeeded", + "unknown", + "unused", + ]); + }); +}); diff --git a/packages/operational-dashboard-ui/src/dashboard/pod-capacity-data.ts b/packages/operational-dashboard-ui/src/dashboard/pod-capacity-data.ts new file mode 100644 index 00000000..037a9ce3 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/pod-capacity-data.ts @@ -0,0 +1,109 @@ +import type { IntlShape } from "react-intl"; + +import type { OperationalMetricPodPhases } from "../application/dashboard-types"; +import { messages } from "../messages"; +import { STATUS_DONUT_COLORS } from "./status-donut-colors"; +import { + buildStatusDonutData, + type StatusDonutDatum, + type StatusDonutLegendDatum, + type StatusDonutSeries, +} from "./status-donut-data"; + +export const POD_CAPACITY_PHASE_ORDER = [ + "running", + "pending", + "failed", + "succeeded", + "unknown", + "unused", +] as const; + +type PodCapacityPhaseKey = (typeof POD_CAPACITY_PHASE_ORDER)[number]; + +export type PodCapacityDatum = StatusDonutDatum; +export type PodCapacityLegendDatum = StatusDonutLegendDatum; + +function podCapacityPhaseLabel( + intl: IntlShape, + phase: Exclude, +): string { + switch (phase) { + case "running": + return intl.formatMessage(messages.podStatusRunning); + case "pending": + return intl.formatMessage(messages.podStatusPending); + case "failed": + return intl.formatMessage(messages.podStatusFailed); + case "succeeded": + return intl.formatMessage(messages.podStatusSucceeded); + case "unknown": + return intl.formatMessage(messages.podStatusUnknown); + } +} + +function podCapacityPhaseColor( + phase: Exclude, +): string { + switch (phase) { + case "running": + return STATUS_DONUT_COLORS.healthy; + case "pending": + return STATUS_DONUT_COLORS.provisioning; + case "failed": + return STATUS_DONUT_COLORS.failed; + case "succeeded": + return STATUS_DONUT_COLORS.degraded; + case "unknown": + return "#8b8d8f"; + } +} + +export function buildPodCapacityData( + intl: IntlShape, + usedPods: number, + capacityPods: number, + podPhases: OperationalMetricPodPhases, +): StatusDonutSeries { + const unusedPods = Math.max(capacityPods - usedPods, 0); + const phaseCounts: Record, number> = { + failed: podPhases.failed, + pending: podPhases.pending, + running: podPhases.running, + succeeded: podPhases.succeeded, + unknown: podPhases.unknown, + }; + + const phaseEntries = POD_CAPACITY_PHASE_ORDER.filter( + (phase): phase is Exclude => + phase !== "unused", + ).map((phase) => { + const count = phaseCounts[phase]; + const label = podCapacityPhaseLabel(intl, phase); + + return { + color: podCapacityPhaseColor(phase), + count, + label, + legendName: intl.formatMessage(messages.statusDonutLegend, { + count, + status: label, + }), + }; + }); + + const unusedLabel = intl.formatMessage(messages.podStatusUnused); + + return buildStatusDonutData([ + ...phaseEntries, + { + color: STATUS_DONUT_COLORS.unused, + count: unusedPods, + label: unusedLabel, + legendName: intl.formatMessage(messages.statusDonutLegend, { + count: unusedPods, + status: unusedLabel, + }), + }, + ]); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/pod-capacity-metric.ts b/packages/operational-dashboard-ui/src/dashboard/pod-capacity-metric.ts new file mode 100644 index 00000000..01b1a54d --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/pod-capacity-metric.ts @@ -0,0 +1,19 @@ +import type { OperationalMetric } from "../application/dashboard-types"; + +export interface PodCapacityMetric { + id: string; + podPhases: NonNullable; + total: string; + unit: string; + value: string; +} + +export function isPodCapacityMetric( + metric: OperationalMetric, +): metric is PodCapacityMetric { + return ( + typeof metric.unit === "string" && + typeof metric.total === "string" && + metric.podPhases !== undefined + ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/provision-time-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/provision-time-chart.tsx new file mode 100644 index 00000000..57ae2a85 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/provision-time-chart.tsx @@ -0,0 +1,81 @@ +import { + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Stack, + StackItem, +} from "@patternfly/react-core"; +import { useMemo } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import type { OperationalMetric } from "../application/dashboard-types"; +import { messages } from "../messages"; +import { + formatProvisionDurationValue, + parseProvisionDurationStats, +} from "./provision-time-data"; + +const STAT_ROWS = [ + { key: "mean", label: messages.provisionTimeAverage }, + { key: "p50", label: messages.provisionTimeMedian }, + { key: "p95", label: messages.provisionTimeP95Label }, +] as const; + +export function ProvisionTimeChart({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + const stats = useMemo(() => parseProvisionDurationStats(metric), [metric]); + + if (!stats) { + return null; + } + + const statValues = { + mean: stats.meanMinutes, + p50: stats.p50Minutes, + p95: stats.p95Minutes, + } as const; + + return ( + + + + {STAT_ROWS.map((row) => ( + + + + + + {formatProvisionDurationValue( + intl, + statValues[row.key], + metric.unit, + )} + + + ))} + + + + + + + + + ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/provision-time-data.test.ts b/packages/operational-dashboard-ui/src/dashboard/provision-time-data.test.ts new file mode 100644 index 00000000..74532727 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/provision-time-data.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import type { OperationalMetric } from "../application/dashboard-types"; +import { parseProvisionDurationStats } from "./provision-time-data"; + +const provisionTimeMetric: OperationalMetric = { + id: "provision-time", + provisionDuration: { + mean: "5.25", + p50: "4.80", + p95: "12.10", + }, + unit: "minutes", + value: "5.25", +}; + +describe("parseProvisionDurationStats", () => { + it("parses mean, P50, and P95 from provisionDuration", () => { + expect(parseProvisionDurationStats(provisionTimeMetric)).toEqual({ + meanMinutes: 5.25, + p50Minutes: 4.8, + p95Minutes: 12.1, + }); + }); + + it("returns undefined when any percentile is missing", () => { + expect( + parseProvisionDurationStats({ + ...provisionTimeMetric, + provisionDuration: { + mean: "5.25", + p50: "4.80", + p95: "NaN", + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/operational-dashboard-ui/src/dashboard/provision-time-data.ts b/packages/operational-dashboard-ui/src/dashboard/provision-time-data.ts new file mode 100644 index 00000000..0b39dd5c --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/provision-time-data.ts @@ -0,0 +1,56 @@ +import type { IntlShape } from "react-intl"; + +import type { OperationalMetric } from "../application/dashboard-types"; +import { messages } from "../messages"; +import { isDisplayableOperationalMetricValue } from "./operational-metric-display"; + +export interface ProvisionDurationStats { + meanMinutes: number; + p50Minutes: number; + p95Minutes: number; +} + +function parseMinutes(value: string | undefined): number | undefined { + if (value === undefined || !isDisplayableOperationalMetricValue(value)) { + return undefined; + } + + return Number(value); +} + +export function parseProvisionDurationStats( + metric: OperationalMetric, +): ProvisionDurationStats | undefined { + const meanMinutes = parseMinutes( + metric.provisionDuration?.mean ?? metric.value, + ); + const p50Minutes = parseMinutes(metric.provisionDuration?.p50); + const p95Minutes = parseMinutes(metric.provisionDuration?.p95); + + if ( + meanMinutes === undefined || + p50Minutes === undefined || + p95Minutes === undefined + ) { + return undefined; + } + + return { meanMinutes, p50Minutes, p95Minutes }; +} + +export function formatProvisionDurationValue( + intl: IntlShape, + minutes: number, + unit: string | undefined, +): string { + const value = minutes.toFixed(2); + + if (unit) { + return intl.formatMessage(messages.utilizationLabel, { + unit, + value, + }); + } + + return value; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/status-donut-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/status-donut-chart.tsx new file mode 100644 index 00000000..da11248e --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/status-donut-chart.tsx @@ -0,0 +1,140 @@ +import { ChartDonut } from "@patternfly/react-charts/victory"; +import { useEffect, useRef, useState } from "react"; + +import type { + StatusDonutDatum, + StatusDonutLegendDatum, +} from "./status-donut-data"; +import "../pages/dashboard-widget.css"; + +/** Rendered pixel size; must match the donut wrapper and ChartDonut height. */ +export const STATUS_DONUT_CHART_HEIGHT = 165; + +/** Compact size for standard metric widgets without trend sparklines. */ +export const COMPACT_STATUS_DONUT_CHART_HEIGHT = 130; + +/** Extra right padding keeps the legend inside the clipped widget body. */ +export const STATUS_DONUT_CHART_PADDING = { + bottom: 25, + left: 20, + right: 145, + top: 20, +} as const; + +export const COMPACT_STATUS_DONUT_CHART_PADDING = { + bottom: 12, + left: 12, + right: 100, + top: 4, +} as const; + +/** Extra bottom padding reserves space for the capacity subtitle without enlarging the donut. */ +export const COMPACT_STATUS_DONUT_WITH_SUBTITLE_CHART_PADDING = { + bottom: 28, + left: 12, + right: 115, + top: 4, +} as const; + +/** Same donut ring as compact; extra height fits the subtitle below the chart. */ +export const COMPACT_STATUS_DONUT_WITH_SUBTITLE_CHART_HEIGHT = + COMPACT_STATUS_DONUT_CHART_HEIGHT - + COMPACT_STATUS_DONUT_CHART_PADDING.top - + COMPACT_STATUS_DONUT_CHART_PADDING.bottom + + COMPACT_STATUS_DONUT_WITH_SUBTITLE_CHART_PADDING.top + + COMPACT_STATUS_DONUT_WITH_SUBTITLE_CHART_PADDING.bottom; + +export type StatusDonutChartSize = "compact" | "default"; + +export interface StatusDonutChartProps { + ariaDesc: string; + ariaTitle: string; + colorScale: readonly string[]; + data: readonly StatusDonutDatum[]; + dataLabel: (datum: StatusDonutDatum) => string | null; + legendData: readonly StatusDonutLegendDatum[]; + size?: StatusDonutChartSize; + subTitle?: string; + title: string; +} + +export function StatusDonutChart({ + ariaDesc, + ariaTitle, + colorScale, + data, + dataLabel, + legendData, + size = "default", + subTitle, + title, +}: Readonly) { + const containerRef = useRef(null); + const [width, setWidth] = useState(275); + const compactWithSubtitle = size === "compact" && subTitle !== undefined; + const chartHeight = + size === "compact" + ? compactWithSubtitle + ? COMPACT_STATUS_DONUT_WITH_SUBTITLE_CHART_HEIGHT + : COMPACT_STATUS_DONUT_CHART_HEIGHT + : STATUS_DONUT_CHART_HEIGHT; + const chartPadding = + size === "compact" + ? compactWithSubtitle + ? COMPACT_STATUS_DONUT_WITH_SUBTITLE_CHART_PADDING + : COMPACT_STATUS_DONUT_CHART_PADDING + : STATUS_DONUT_CHART_PADDING; + + useEffect(() => { + const node = containerRef.current; + if (!node) { + return; + } + + const observer = new ResizeObserver((entries) => { + const nextWidth = entries[0]?.contentRect.width; + if (nextWidth && nextWidth > 0) { + setWidth(nextWidth); + } + }); + observer.observe(node); + + return () => { + observer.disconnect(); + }; + }, []); + + if (data.length === 0) { + return null; + } + + return ( +
+ dataLabel(datum)} + legendData={[...legendData]} + legendOrientation="vertical" + legendPosition="right" + padding={chartPadding} + subTitle={subTitle} + subTitlePosition={subTitle ? "bottom" : undefined} + title={title} + width={width} + /> +
+ ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/status-donut-colors.ts b/packages/operational-dashboard-ui/src/dashboard/status-donut-colors.ts new file mode 100644 index 00000000..20eb45da --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/status-donut-colors.ts @@ -0,0 +1,11 @@ +/** + * Status bucket colors aligned with PatternFly Alert and Label status semantics. + */ +export const STATUS_DONUT_COLORS = { + degraded: "#ffcc17", + failed: "#b1380b", + healthy: "#63993d", + provisioning: "#0066cc", + /** Matches PatternFly ChartDonutUtilization unused segment fill. */ + unused: "#d2d2d2", +} as const; diff --git a/packages/operational-dashboard-ui/src/dashboard/status-donut-data.test.ts b/packages/operational-dashboard-ui/src/dashboard/status-donut-data.test.ts new file mode 100644 index 00000000..d16395b5 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/status-donut-data.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { buildStatusDonutData } from "./status-donut-data"; + +describe("buildStatusDonutData", () => { + it("omits zero-count entries", () => { + const result = buildStatusDonutData([ + { + color: "#63993d", + count: 5, + label: "Ready", + legendName: "Ready: 5", + }, + { + color: "#b1380b", + count: 0, + label: "Not ready", + legendName: "Not ready: 0", + }, + ]); + + expect(result).toEqual({ + colorScale: ["#63993d"], + data: [{ x: "Ready", y: 5 }], + legendData: [{ name: "Ready: 5" }], + }); + }); + + it("returns empty series when every entry is zero", () => { + const result = buildStatusDonutData([ + { + color: "#63993d", + count: 0, + label: "Ready", + legendName: "Ready: 0", + }, + ]); + + expect(result).toEqual({ + colorScale: [], + data: [], + legendData: [], + }); + }); +}); diff --git a/packages/operational-dashboard-ui/src/dashboard/status-donut-data.ts b/packages/operational-dashboard-ui/src/dashboard/status-donut-data.ts new file mode 100644 index 00000000..517cae62 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/status-donut-data.ts @@ -0,0 +1,33 @@ +export interface StatusDonutDatum { + x: string; + y: number; +} + +export interface StatusDonutLegendDatum { + name: string; +} + +export interface StatusDonutSeries { + colorScale: string[]; + data: StatusDonutDatum[]; + legendData: StatusDonutLegendDatum[]; +} + +interface StatusDonutEntry { + color: string; + count: number; + label: string; + legendName: string; +} + +export function buildStatusDonutData( + entries: readonly StatusDonutEntry[], +): StatusDonutSeries { + const visibleEntries = entries.filter((entry) => entry.count > 0); + + return { + colorScale: visibleEntries.map((entry) => entry.color), + data: visibleEntries.map((entry) => ({ x: entry.label, y: entry.count })), + legendData: visibleEntries.map((entry) => ({ name: entry.legendName })), + }; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/status-donut-metric.ts b/packages/operational-dashboard-ui/src/dashboard/status-donut-metric.ts new file mode 100644 index 00000000..31b55328 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/status-donut-metric.ts @@ -0,0 +1,13 @@ +import type { OperationalMetric } from "../application/dashboard-types"; + +export interface StatusDonutMetric { + id: string; + status: NonNullable; + value: string; +} + +export function isStatusDonutMetric( + metric: OperationalMetric, +): metric is StatusDonutMetric { + return metric.status !== undefined; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx new file mode 100644 index 00000000..09395fde --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx @@ -0,0 +1,96 @@ +import { + ChartArea, + ChartGroup, + ChartThemeColor, + ChartVoronoiContainer, +} from "../patternfly/victory-charts"; +import { useEffect, useRef, useState } from "react"; +import { useIntl } from "react-intl"; + +import type { OperationalMetricTrend } from "../application/dashboard-types"; +import { messages } from "../messages"; +import "../pages/dashboard-widget.css"; + +interface SparklineDatum { + name: string; + x: string; + y: number; +} + +const SPARKLINE_PLOT_HEIGHT = 36; + +export function TrendSparklineChart({ + trend, + title, +}: Readonly<{ + trend: OperationalMetricTrend; + title: string; +}>) { + const intl = useIntl(); + const containerRef = useRef(null); + const [width, setWidth] = useState(220); + + useEffect(() => { + const node = containerRef.current; + if (!node) { + return; + } + + const observer = new ResizeObserver((entries) => { + const nextWidth = entries[0]?.contentRect.width; + if (nextWidth && nextWidth > 0) { + setWidth(nextWidth); + } + }); + observer.observe(node); + + return () => { + observer.disconnect(); + }; + }, []); + + if (trend.points.length < 2) { + return null; + } + + const chartData: SparklineDatum[] = trend.points.map((point) => ({ + name: title, + x: point.label, + y: point.value, + })); + + const formatTooltip = (datum: SparklineDatum) => + intl.formatMessage(messages.trendTooltip, { + date: datum.x, + metric: title, + value: datum.y, + }); + + const trendDayCount = trend.points.length.toString(); + + return ( +
+
+ formatTooltip(datum as SparklineDatum)} + /> + } + height={SPARKLINE_PLOT_HEIGHT} + padding={{ bottom: 1, left: 2, right: 2, top: 1 }} + themeColor={ChartThemeColor.blue} + width={width} + > + + +
+ + {intl.formatMessage(messages.trendLastDays, { days: trendDayCount })} + +
+ ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/utilization-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/utilization-chart.tsx new file mode 100644 index 00000000..6d84c9dd --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/utilization-chart.tsx @@ -0,0 +1,151 @@ +import { ChartDonutUtilization } from "@patternfly/react-charts/victory"; +import { Flex, FlexItem, Title } from "@patternfly/react-core"; +import { useIntl } from "react-intl"; + +import type { + OperationalMetric, + OperationalMetricTrend, +} from "../application/dashboard-types"; +import { messages } from "../messages"; +import { + formatOperationalMetricDisplayValue, + isDisplayableOperationalMetricValue, +} from "./operational-metric-display"; +import "../pages/dashboard-widget.css"; + +interface UsageData { + x: string; + y: number; +} + +interface UtilizationMetric { + id: string; + total: string; + trend?: OperationalMetricTrend; + unit: string; + value: string; +} + +export const UTILIZATION_WARNING_THRESHOLD_PERCENT = 60; +export const UTILIZATION_DANGER_THRESHOLD_PERCENT = 90; + +export const UTILIZATION_THRESHOLDS = [ + { value: UTILIZATION_WARNING_THRESHOLD_PERCENT }, + { value: UTILIZATION_DANGER_THRESHOLD_PERCENT }, +] as const; + +export type UtilizationStatusLevel = "danger" | "ok" | "warning"; + +export function getUtilizationPercentage(value: string, total: string): number { + return Math.round((Number(value) / Number(total)) * 100); +} + +export function getUtilizationStatusLevel( + percentage: number, +): UtilizationStatusLevel { + if (percentage >= UTILIZATION_DANGER_THRESHOLD_PERCENT) { + return "danger"; + } + if (percentage >= UTILIZATION_WARNING_THRESHOLD_PERCENT) { + return "warning"; + } + return "ok"; +} + +/** Rendered pixel size; must match the donut wrapper and ChartDonutUtilization height/width. */ +const UTILIZATION_CHART_SIZE = 130; + +/** Extra left padding keeps hover tooltips inside the clipped widget body. */ +const UTILIZATION_CHART_PADDING = { + bottom: 10, + left: 32, + right: 10, + top: 10, +} as const; + +export function isUtilizationMetric( + metric: OperationalMetric, +): metric is UtilizationMetric { + return typeof metric.unit === "string" && typeof metric.total === "string"; +} + +export function UtilizationChart({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + + if (!isUtilizationMetric(metric)) { + return null; + } + + const { unit, total, value } = metric; + + if ( + !isDisplayableOperationalMetricValue(value) || + !isDisplayableOperationalMetricValue(total) + ) { + return ( + + {formatOperationalMetricDisplayValue(value, intl)} + + ); + } + + const percentage = getUtilizationPercentage(value, total); + const capacityLabel = intl.formatMessage(messages.utilizationCapacity, { + unit, + }); + + const data: UsageData = { x: capacityLabel, y: percentage }; + const valueLabel = intl.formatMessage(messages.utilizationLabel, { + unit, + value, + }); + const capacitySubtitle = intl.formatMessage(messages.utilizationSubtitle, { + total, + unit, + }); + + return ( + + + + datum.x + ? intl.formatMessage(messages.utilizationDataLabel, { + capacity: datum.x, + percentage: datum.y, + }) + : null + } + padding={UTILIZATION_CHART_PADDING} + thresholds={[...UTILIZATION_THRESHOLDS]} + width={UTILIZATION_CHART_SIZE} + /> + + + + {valueLabel} + + {capacitySubtitle} + + + ); +} diff --git a/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts b/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts new file mode 100644 index 00000000..330742c1 --- /dev/null +++ b/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts @@ -0,0 +1,73 @@ +import type { OperationalDashboardMetrics } from "../application/dashboard-types"; + +/** + * Storybook and local-dev fixture shaped like `createDashboardControlPlaneAdapter` + * output: instantaneous values only (no trend series in production v1). + */ +export const mockOperationalDashboardMetrics: OperationalDashboardMetrics = + Object.freeze({ + metrics: Object.freeze([ + Object.freeze({ + id: "provisioned-gateways", + status: Object.freeze({ + degraded: 6, + failed: 2, + healthy: 80, + provisioning: 9, + }), + value: "97", + }), + Object.freeze({ + id: "provisioned-sandboxes", + value: "214", + }), + Object.freeze({ + id: "registered-users", + value: "450", + }), + Object.freeze({ + id: "memory", + total: "237", + unit: "GiB", + value: "220", + }), + Object.freeze({ + id: "cpu", + total: "60", + unit: "cores", + value: "48", + }), + Object.freeze({ + id: "pods", + podPhases: Object.freeze({ + failed: 16, + pending: 12, + running: 500, + succeeded: 20, + unknown: 0, + }), + total: "2000", + unit: "pods", + value: "548", + }), + Object.freeze({ + id: "nodes", + status: Object.freeze({ + failed: 1, + healthy: 7, + }), + value: "8", + }), + Object.freeze({ + id: "provision-time", + provisionDuration: Object.freeze({ + mean: "5.25", + p50: "4.80", + p95: "12.10", + }), + unit: "minutes", + value: "5.25", + }), + ]), + lastSuccessfulRefresh: new Date("2026-08-25T10:55:00.000Z"), + }); diff --git a/packages/operational-dashboard-ui/src/index.ts b/packages/operational-dashboard-ui/src/index.ts new file mode 100644 index 00000000..83cecec8 --- /dev/null +++ b/packages/operational-dashboard-ui/src/index.ts @@ -0,0 +1,40 @@ +export { + DashboardUiProvider, + useDashboardUi, + type DashboardUiNavigation, +} from "./dashboard-ui-provider"; +export type { + DashboardControlPlane, + DashboardInvocationContext, + DashboardOperations, + DashboardProbe, + DashboardProbeAction, + DashboardProbeName, + DashboardProbeOutcome, + DashboardProbePublisher, + DashboardWorkflowAction, + DashboardWorkflowRuntime, + OperationalDashboardMetrics, + OperationalMetric, + OperationalMetricPodPhases, + OperationalMetricProvisionDuration, + OperationalMetricStatus, + OperationalMetricTrend, + OperationalMetricTrendPoint, + SignupTrendPoint, +} from "./application/dashboard-types"; +export { noopDashboardProbePublisher } from "./application/dashboard-probes"; +export { + createDashboardOperations, + type DashboardOperationDependencies, +} from "./application/dashboard-operations"; +export { + operationalDashboardMetricsQueryKey, + operationalDashboardRefreshMilliseconds, +} from "./dashboard/dashboard-data"; +export { ResourceRefreshButton } from "./shared/resource-refresh-button"; +export { + OperationalDashboardPage, + type OperationalDashboardPageProps, +} from "./pages/operational-dashboard-page"; +export { messages as dashboardMessages } from "./messages"; diff --git a/packages/operational-dashboard-ui/src/messages.ts b/packages/operational-dashboard-ui/src/messages.ts new file mode 100644 index 00000000..9e182c08 --- /dev/null +++ b/packages/operational-dashboard-ui/src/messages.ts @@ -0,0 +1,402 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + addWidgets: { + id: "app.dashboard.addWidgets", + defaultMessage: "Add widgets", + description: "Label for the button that opens the widget drawer.", + }, + cpus: { + id: "app.dashboard.summary.cpus", + defaultMessage: "CPUs", + description: "Summary label for provisioned CPU capacity.", + }, + description: { + id: "app.dashboard.description", + defaultMessage: + "Live view of gateway fleet health, hub cluster capacity, and platform usage. Metrics refresh every 15 minutes; use Refresh to update now.", + description: "Supporting text on the operational dashboard page.", + }, + gateways: { + id: "app.dashboard.summary.gateways", + defaultMessage: "Gateways", + description: "Summary label for provisioned gateways.", + }, + gatewayStatusAriaDesc: { + id: "app.dashboard.gatewayStatus.ariaDesc", + defaultMessage: "Gateway count by status", + description: "Accessible description for the gateway status donut chart.", + }, + gatewayStatusChartTitle: { + id: "app.dashboard.gatewayStatus.chartTitle", + defaultMessage: "Gateway status chart", + description: "Accessible title for the gateway status donut chart.", + }, + gatewayStatusDataLabel: { + id: "app.dashboard.gatewayStatus.dataLabel", + defaultMessage: "{status}: {count}", + description: + "Data label for a gateway status donut chart segment. Superseded by statusDonutDataLabel.", + }, + gatewayStatusDegraded: { + id: "app.dashboard.gatewayStatus.degraded", + defaultMessage: "Degraded", + description: "Legend label for degraded gateways.", + }, + gatewayStatusFailed: { + id: "app.dashboard.gatewayStatus.failed", + defaultMessage: "Failed", + description: "Legend label for failed gateways.", + }, + gatewayStatusHealthy: { + id: "app.dashboard.gatewayStatus.healthy", + defaultMessage: "Healthy", + description: "Legend label for healthy gateways.", + }, + gatewayStatusLegend: { + id: "app.dashboard.gatewayStatus.legend", + defaultMessage: "{status}: {count}", + description: "Legend entry for a gateway status donut chart segment.", + }, + gatewayStatusProvisioning: { + id: "app.dashboard.gatewayStatus.provisioning", + defaultMessage: "Provisioning", + description: "Legend label for provisioning gateways.", + }, + gatewayStatusWidget: { + id: "app.dashboard.widget.gatewayStatus", + defaultMessage: "Gateway status", + description: "Title for the gateway status dashboard widget.", + }, + loadErrorBody: { + id: "app.dashboard.loadError.body", + defaultMessage: + "An unexpected error occurred while loading dashboard metrics.", + description: + "Recovery guidance when operational dashboard metrics cannot be loaded.", + }, + loadErrorTitle: { + id: "app.dashboard.loadError.title", + defaultMessage: "Operational dashboard metrics are unavailable", + description: + "Title shown when operational dashboard metrics cannot be loaded.", + }, + loading: { + id: "app.dashboard.loading", + defaultMessage: "Loading operational dashboard metrics", + description: + "Accessible status shown while operational dashboard metrics load.", + }, + memory: { + id: "app.dashboard.summary.memory", + defaultMessage: "Memory", + description: "Summary label for memory utilization.", + }, + metricCouldNotBeDetermined: { + id: "app.dashboard.metricCouldNotBeDetermined", + defaultMessage: "Metric could not be determined", + description: + "Fallback when a dashboard metric value is non-finite or cannot be shown as a number.", + }, + metricUnavailableBody: { + id: "app.dashboard.metricUnavailable.body", + defaultMessage: "This information is not currently available.", + description: + "Recovery guidance when an individual dashboard metric is missing.", + }, + metricUnavailableTitle: { + id: "app.dashboard.metricUnavailable.title", + defaultMessage: "Metric unavailable", + description: + "Heading shown when an individual dashboard metric is missing.", + }, + metricValue: { + id: "app.dashboard.metric.value", + defaultMessage: "{value} {label}", + description: "Formatted count for a dashboard metric card heading.", + }, + nodes: { + id: "app.dashboard.widget.nodes", + defaultMessage: "Nodes", + description: "Title for the nodes dashboard widget.", + }, + nodeStatusAriaDesc: { + id: "app.dashboard.nodeStatus.ariaDesc", + defaultMessage: "Node count by readiness", + description: "Accessible description for the node status donut chart.", + }, + nodeStatusChartTitle: { + id: "app.dashboard.nodeStatus.chartTitle", + defaultMessage: "Node status chart", + description: "Accessible title for the node status donut chart.", + }, + nodeStatusNotReady: { + id: "app.dashboard.nodeStatus.notReady", + defaultMessage: "Not ready", + description: "Legend label for not-ready nodes.", + }, + nodeStatusReady: { + id: "app.dashboard.nodeStatus.ready", + defaultMessage: "Ready", + description: "Legend label for ready nodes.", + }, + pods: { + id: "app.dashboard.summary.pods", + defaultMessage: "Pods", + description: "Summary label for pod utilization.", + }, + podStatusAriaDesc: { + id: "app.dashboard.podStatus.ariaDesc", + defaultMessage: "Pod capacity by phase and unused slots", + description: "Accessible description for the pod capacity donut chart.", + }, + podStatusChartTitle: { + id: "app.dashboard.podStatus.chartTitle", + defaultMessage: "Pod capacity chart", + description: "Accessible title for the pod capacity donut chart.", + }, + podStatusFailed: { + id: "app.dashboard.podStatus.failed", + defaultMessage: "Failed", + description: "Legend label for failed pods.", + }, + podStatusPending: { + id: "app.dashboard.podStatus.pending", + defaultMessage: "Pending", + description: "Legend label for pending pods.", + }, + podStatusRunning: { + id: "app.dashboard.podStatus.running", + defaultMessage: "Running", + description: "Legend label for running pods.", + }, + podStatusSucceeded: { + id: "app.dashboard.podStatus.succeeded", + defaultMessage: "Succeeded", + description: "Legend label for succeeded pods.", + }, + podStatusUnknown: { + id: "app.dashboard.podStatus.unknown", + defaultMessage: "Unknown", + description: "Legend label for unknown-phase pods.", + }, + podStatusUnused: { + id: "app.dashboard.podStatus.unused", + defaultMessage: "Unused", + description: "Legend label for unused pod capacity slots.", + }, + provisionedGateways: { + id: "app.dashboard.widget.provisionedGateways", + defaultMessage: "Provisioned gateways", + description: "Title for the provisioned gateways dashboard widget.", + }, + provisionTime: { + id: "app.dashboard.summary.provisionTime", + defaultMessage: "Provision time (average)", + description: "Summary label for average gateway provision time.", + }, + provisionTimeAverage: { + id: "app.dashboard.provisionTime.average", + defaultMessage: "Average", + description: "Label for average gateway provision duration.", + }, + provisionTimeMedian: { + id: "app.dashboard.provisionTime.median", + defaultMessage: "Median (P50)", + description: "Label for median gateway provision duration.", + }, + provisionTimeP50: { + id: "app.dashboard.summary.provisionTimeP50", + defaultMessage: "Provision time (P50)", + description: "Deprecated summary label retained for locale extraction.", + }, + provisionTimeP95: { + id: "app.dashboard.summary.provisionTimeP95", + defaultMessage: "Provision time (P95)", + description: "Deprecated summary label retained for locale extraction.", + }, + provisionTimeP95Label: { + id: "app.dashboard.provisionTime.p95", + defaultMessage: "P95", + description: "Label for the 95th percentile gateway provision duration.", + }, + provisionTimeP95Note: { + id: "app.dashboard.provisionTime.p95Note", + defaultMessage: + "95% of gateways were provisioned in under {duration} {unit}.", + description: + "Context note explaining the 95th percentile gateway provision duration.", + }, + provisionTimeStatsAriaLabel: { + id: "app.dashboard.provisionTime.statsAriaLabel", + defaultMessage: "Gateway provision duration statistics", + description: + "Accessible label for the provision time statistics description list.", + }, + provisionTimeWidget: { + id: "app.dashboard.widget.provisionTime", + defaultMessage: "Gateway provision time", + description: "Title for the gateway provision time dashboard widget.", + }, + refresh: { + id: "app.dashboard.refresh", + defaultMessage: "Refresh dashboard metrics", + description: + "Accessible label for refreshing operational dashboard metrics.", + }, + refreshErrorBody: { + id: "app.dashboard.refreshError.body", + defaultMessage: + "Showing the last successful metrics. Try refreshing again.", + description: + "Recovery guidance when operational dashboard metrics cannot be refreshed.", + }, + refreshErrorTitle: { + id: "app.dashboard.refreshError.title", + defaultMessage: "Could not refresh dashboard metrics", + description: + "Title shown when operational dashboard metrics cannot be refreshed.", + }, + registeredUsers: { + id: "app.dashboard.widget.registeredUsers", + defaultMessage: "Registered users", + description: "Title for the registered users dashboard widget.", + }, + registeredUsersSummary: { + id: "app.dashboard.summary.registeredUsers", + defaultMessage: "Registered users", + description: "Summary label for registered users.", + }, + resetToDefault: { + id: "app.dashboard.resetToDefault", + defaultMessage: "Reset to default", + description: + "Label for restoring the operational dashboard default layout.", + }, + statusDonutDataLabel: { + id: "app.dashboard.statusDonut.dataLabel", + defaultMessage: "{status}: {count}", + description: "Data label for a status donut chart segment.", + }, + statusDonutLegend: { + id: "app.dashboard.statusDonut.legend", + defaultMessage: "{status}: {count}", + description: "Legend entry for a status donut chart segment.", + }, + summary: { + id: "app.dashboard.widget.summary", + defaultMessage: "Summary", + description: "Title for the operational dashboard summary widget.", + }, + summarySystem: { + id: "app.dashboard.summary.system", + defaultMessage: "System", + description: + "Heading for system utilization metrics in the summary widget.", + }, + summarySystemAriaLabel: { + id: "app.dashboard.summary.systemAriaLabel", + defaultMessage: "System metrics", + description: + "Accessible label for the system metrics list in the summary widget.", + }, + summaryTrendDecrease: { + id: "app.dashboard.summary.trendDecrease", + defaultMessage: "{percent}% decrease", + description: + "Tooltip for a usage summary metric that decreased since the start of its trend.", + }, + summaryTrendIncrease: { + id: "app.dashboard.summary.trendIncrease", + defaultMessage: "{percent}% increase", + description: + "Tooltip for a usage summary metric that increased since the start of its trend.", + }, + summaryUsage: { + id: "app.dashboard.summary.usage", + defaultMessage: "Usage", + description: "Heading for adoption metrics in the summary widget.", + }, + summaryUsageAriaLabel: { + id: "app.dashboard.summary.usageAriaLabel", + defaultMessage: "Usage metrics", + description: + "Accessible label for the usage metrics list in the summary widget.", + }, + systemSummaryWidget: { + id: "app.dashboard.widget.systemSummary", + defaultMessage: "System summary", + description: "Title for the operational dashboard system summary widget.", + }, + title: { + id: "app.dashboard.title", + defaultMessage: "HyperShell operational dashboard", + description: "Main heading on the operational dashboard page.", + }, + trendLastDays: { + id: "app.dashboard.trend.lastDays", + defaultMessage: "Last {days} days", + description: "Caption below a trend sparkline showing the lookback window.", + }, + trendTooltip: { + id: "app.dashboard.trend.tooltip", + defaultMessage: "{date}: {value} {metric}", + description: "Tooltip for a dashboard metric trend sparkline point.", + }, + usageSummaryWidget: { + id: "app.dashboard.widget.usageSummary", + defaultMessage: "Usage summary", + description: "Title for the operational dashboard usage summary widget.", + }, + utilizationCapacity: { + id: "app.dashboard.utilization.capacity", + defaultMessage: "{unit} capacity", + description: "Capacity label for a utilization donut chart.", + }, + utilizationChartTitle: { + id: "app.dashboard.utilization.chartTitle", + defaultMessage: "{unit} utilization chart", + description: "Accessible title for a utilization donut chart.", + }, + utilizationDataLabel: { + id: "app.dashboard.utilization.dataLabel", + defaultMessage: "{capacity}: {percentage}%", + description: "Data label for a utilization donut chart segment.", + }, + utilizationLabel: { + id: "app.dashboard.utilization.label", + defaultMessage: "{value} {unit}", + description: "Primary value label for a utilization donut chart.", + }, + utilizationSubtitle: { + id: "app.dashboard.utilization.subtitle", + defaultMessage: "of {total} {unit}", + description: "Subtitle for a utilization donut chart.", + }, + utilizationSummaryTooltip: { + id: "app.dashboard.utilization.summaryTooltip", + defaultMessage: "{percent}% capacity{separator}{value} of {total} {unit}", + description: "Tooltip for utilization status in the system summary widget.", + }, + widgetCpu: { + id: "app.dashboard.widget.cpu", + defaultMessage: "CPU", + description: "Title for the CPU utilization dashboard widget.", + }, + widgetMemory: { + id: "app.dashboard.widget.memory", + defaultMessage: "Memory", + description: "Title for the memory utilization dashboard widget.", + }, + widgetPods: { + id: "app.dashboard.widget.pods", + defaultMessage: "Pods", + description: "Title for the pods utilization dashboard widget.", + }, + widgetSandboxes: { + id: "app.dashboard.summary.sandboxes", + defaultMessage: "Sandboxes", + description: + "Label for provisioned sandboxes on the operational dashboard.", + }, +}); diff --git a/packages/operational-dashboard-ui/src/pages/dashboard-widget.css b/packages/operational-dashboard-ui/src/pages/dashboard-widget.css new file mode 100644 index 00000000..d833f812 --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/dashboard-widget.css @@ -0,0 +1,135 @@ +.hypershell-dashboard-metric-card { + --pf-v6-c-content--MarginBottom: 0; +} + +.hypershell-dashboard-metric-card .pf-v6-c-stack { + --pf-v6-c-stack--m-gutter--Gap: var(--pf-t--global--spacer--sm); +} + +.hypershell-dashboard-sparkline-chart { + width: 100%; +} + +.hypershell-dashboard-sparkline-chart__plot { + height: 36px; +} + +.hypershell-dashboard-sparkline-chart__caption { + display: block; + line-height: 1.2; + margin-block-start: var(--pf-t--global--spacer--xs); + text-align: center; +} + +.hypershell-dashboard-utilization-chart { + padding-inline-start: var(--pf-t--global--spacer--sm); +} + +.hypershell-dashboard-utilization-chart__label { + min-width: 0; +} + +.hypershell-dashboard-utilization-chart__donut { + flex-shrink: 0; +} + +.hypershell-dashboard-status-donut-card { + --pf-v6-c-content--MarginBottom: 0; +} + +.hypershell-dashboard-status-donut-card .pf-v6-c-stack { + --pf-v6-c-stack--m-gutter--Gap: var(--pf-t--global--spacer--sm); +} + +.hypershell-dashboard-status-donut-card--compact { + --pf-v6-c-card__body--PaddingBlockEnd: var(--pf-t--global--spacer--sm); + --pf-v6-c-card__body--PaddingBlockStart: var(--pf-t--global--spacer--sm); +} + +.hypershell-dashboard-status-donut-chart--compact { + margin-block-start: calc(-1 * var(--pf-t--global--spacer--xs)); +} + +.hypershell-dashboard-status-donut-chart { + min-width: 0; + --hypershell-gateway-status-degraded-color: var( + --pf-t--global--icon--color--status--warning--default + ); + --hypershell-gateway-status-failed-color: var( + --pf-t--global--icon--color--status--danger--default + ); + --hypershell-gateway-status-provisioning-color: var( + --pf-t--global--icon--color--status--info--default + ); + /* Chart green-100 reads better on large donut slices than success icon green. */ + --hypershell-gateway-status-healthy-color: #63993d; + /* + * ChartDonut title, subtitle, and legend use chart label tokens that fall back + * to dark gray. Re-map them to global text tokens so dark mode stays readable. + */ + --pf-v6-chart-donut--label--subtitle--Fill: var( + --pf-t--global--text--color--subtle + ); + --pf-v6-chart-donut--label--title--Fill: var( + --pf-t--global--text--color--regular + ); + --pf-v6-chart-global--label--Fill: var(--pf-t--global--text--color--regular); +} + +.hypershell-dashboard-summary-trend { + --pf-v6-c-button--PaddingBlock: 0; + --pf-v6-c-button--PaddingInline: 0; + min-height: auto; +} + +.hypershell-dashboard-summary-trend--increase { + color: var(--pf-t--global--icon--color--status--success--default); +} + +.hypershell-dashboard-summary-trend--decrease { + color: var(--pf-t--global--icon--color--status--danger--default); +} + +.hypershell-dashboard-summary-gateway-value { + --pf-v6-c-stack--m-gutter--Gap: var(--pf-t--global--spacer--xs); +} + +.hypershell-dashboard-summary-gateway-status { + flex-wrap: nowrap; +} + +.hypershell-dashboard-summary-gateway-status__divider { + align-self: stretch; + height: auto; +} + +.hypershell-dashboard-summary-gateway-status__icon { + --pf-v6-c-button--PaddingBlock: 0; + --pf-v6-c-button--PaddingInline: 0; + display: inline-flex; + min-height: auto; + vertical-align: middle; +} + +.hypershell-dashboard-provision-time-card { + --pf-v6-c-content--MarginBottom: 0; +} + +.hypershell-dashboard-provision-time { + --pf-v6-c-stack--m-gutter--Gap: var(--pf-t--global--spacer--sm); +} + +/* + * Widgetized dashboard summary cards apply display:grid to description lists. + * Restore the default horizontal description-list layout here. + */ +.hypershell-dashboard-provision-time .pf-v6-c-description-list, +.hypershell-dashboard-provision-time .pf-v6-c-description-list > .pf-v6-c-card { + display: block; +} + +.hypershell-dashboard-provision-time__note { + color: var(--pf-t--global--text--color--subtle); + font-size: var(--pf-t--global--font--size--sm); + margin-block-end: 0; +} diff --git a/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx b/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx new file mode 100644 index 00000000..9af49509 --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx @@ -0,0 +1,669 @@ +import { + Button, + Card, + CardBody, + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Divider, + Flex, + FlexItem, + Icon, + Stack, + StackItem, + Title, + Tooltip, +} from "@patternfly/react-core"; +import { + CheckCircleIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + TrendDownIcon, + TrendUpIcon, +} from "@patternfly/react-icons"; +import type { PropsWithChildren } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import type { OperationalMetric } from "../application/dashboard-types"; +import { + getMetricTrendChange, + type MetricTrendChange, +} from "../dashboard/metric-trend-change"; +import { + formatOperationalMetricDisplayValue, + isDisplayableOperationalMetricValue, +} from "../dashboard/operational-metric-display"; +import { TrendSparklineChart } from "../dashboard/trend-sparkline-chart"; +import { getGatewayExceptionStatusCounts } from "../dashboard/gateway-exception-status-counts"; +import { GatewayStatusChart } from "../dashboard/gateway-status-chart"; +import { NodeStatusChart } from "../dashboard/node-status-chart"; +import { PodCapacityChart } from "../dashboard/pod-capacity-chart"; +import { ProvisionTimeChart } from "../dashboard/provision-time-chart"; +import { isPodCapacityMetric } from "../dashboard/pod-capacity-metric"; +import { + getUtilizationPercentage, + getUtilizationStatusLevel, + isUtilizationMetric, + UtilizationChart, +} from "../dashboard/utilization-chart"; +import { messages } from "../messages"; + +function WidgetContent({ + bodyClassName, + children, +}: Readonly>) { + return ( + + {children} + + ); +} + +export function MetricCard({ + metric, + showTrend = true, + subtitle, + title, +}: Readonly<{ + metric: OperationalMetric; + showTrend?: boolean; + subtitle: string; + title: string; +}>) { + const intl = useIntl(); + const displayValue = formatOperationalMetricDisplayValue(metric.value, intl); + const metricHeading = isDisplayableOperationalMetricValue(metric.value) + ? intl.formatMessage(messages.metricValue, { + label: title, + value: displayValue, + }) + : displayValue; + + return ( + + + + + + + + {metricHeading} + + {subtitle ? {subtitle} : null} + + + + {showTrend && metric.trend ? ( + + + + ) : null} + + + + ); +} + +export function GatewayStatusCard({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + const trendTitle = intl.formatMessage(messages.provisionedGateways); + + return ( + + + + + + + {metric.trend ? ( + + + + ) : null} + + + + ); +} + +export function NodeStatusCard({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + return ( + + + + + + ); +} + +export function PodCapacityCard({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + return ( + + + + + + ); +} + +export function ProvisionTimeCard({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + return ( + + + + + + ); +} + +export function UtilizationCard({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + return ( + + + + {isUtilizationMetric(metric) ? ( + + + + ) : null} + + + + ); +} + +function SummaryTrendIndicator({ + trendChange, +}: Readonly<{ trendChange: MetricTrendChange }>) { + const intl = useIntl(); + const isIncrease = trendChange.direction === "increase"; + const tooltipContent = intl.formatMessage( + isIncrease ? messages.summaryTrendIncrease : messages.summaryTrendDecrease, + { percent: trendChange.percent }, + ); + + return ( + + + + ); +} + +function UtilizationStatusIcon({ + percentage, + total, + unit, + value, +}: Readonly<{ + percentage: number; + total: string; + unit: string; + value: string; +}>) { + const intl = useIntl(); + const statusLevel = getUtilizationStatusLevel(percentage); + const tooltipContent = intl.formatMessage( + messages.utilizationSummaryTooltip, + { + percent: percentage, + separator: " | ", + total, + unit, + value, + }, + ); + + const statusIcon = (() => { + switch (statusLevel) { + case "ok": + return ( + + + + ); + case "warning": + return ( + + + + ); + case "danger": + return ( + + + + ); + } + })(); + + return ( + + + + ); +} + +function SummaryProvisionDurationValue({ + metric, + valueKey, +}: Readonly<{ + metric: OperationalMetric | undefined; + valueKey: "mean" | "p50" | "p95"; +}>) { + const intl = useIntl(); + + if (!metric) { + return null; + } + + const durationValue = + valueKey === "mean" + ? (metric.provisionDuration?.mean ?? metric.value) + : metric.provisionDuration?.[valueKey]; + + if ( + durationValue === undefined || + !isDisplayableOperationalMetricValue(durationValue) + ) { + return null; + } + + return ( + <> + {metric.unit + ? intl.formatMessage(messages.utilizationLabel, { + unit: metric.unit, + value: durationValue, + }) + : durationValue} + + ); +} + +function SummaryUtilizationValue({ + metric, +}: Readonly<{ metric: OperationalMetric | undefined }>) { + const intl = useIntl(); + + if (!metric) { + return null; + } + + if ( + !isDisplayableOperationalMetricValue(metric.value) || + (metric.total !== undefined && + !isDisplayableOperationalMetricValue(metric.total)) + ) { + return <>{formatOperationalMetricDisplayValue(metric.value, intl)}; + } + + if (!isUtilizationMetric(metric)) { + return ( + <> + {metric.unit + ? intl.formatMessage(messages.utilizationLabel, { + unit: metric.unit, + value: metric.value, + }) + : metric.value} + + ); + } + + const percentage = getUtilizationPercentage(metric.value, metric.total); + + return ( + + + {intl.formatMessage(messages.utilizationLabel, { + unit: metric.unit, + value: metric.value, + })} + + + + + + ); +} + +function SummaryMetricValue({ + metric, +}: Readonly<{ metric: OperationalMetric | undefined }>) { + const intl = useIntl(); + const trendChange = metric ? getMetricTrendChange(metric) : undefined; + const displayValue = metric + ? formatOperationalMetricDisplayValue(metric.value, intl) + : undefined; + + return ( + + {displayValue} + {trendChange ? ( + + + + ) : null} + + ); +} + +function SummaryGatewayStatusCount({ + count, + statusLabel, + variant, +}: Readonly<{ + count: number; + statusLabel: string; + variant: "danger" | "warning"; +}>) { + const intl = useIntl(); + const accessibleLabel = intl.formatMessage(messages.gatewayStatusLegend, { + count, + status: statusLabel, + }); + const statusIcon = + variant === "danger" ? ( + + + + ) : ( + + + + ); + + return ( + + + + + + + + {count} + + + ); +} + +function SummaryGatewayStatusCounts({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + + if (metric.status === undefined) { + return null; + } + + const { failed: failedCount, degraded: degradedCount } = + getGatewayExceptionStatusCounts(metric.status); + + if (failedCount === 0 && degradedCount === 0) { + return null; + } + + const failedLabel = intl.formatMessage(messages.gatewayStatusFailed); + const degradedLabel = intl.formatMessage(messages.gatewayStatusDegraded); + + return ( + + {failedCount > 0 ? ( + + + + ) : null} + {failedCount > 0 && degradedCount > 0 ? ( + + + + ) : null} + {degradedCount > 0 ? ( + + + + ) : null} + + ); +} + +function SummaryGatewayValue({ + metric, +}: Readonly<{ metric: OperationalMetric | undefined }>) { + return ( + + + + + {metric ? ( + + + + ) : null} + + ); +} + +function SummaryPodFailedCount({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + + if (!isPodCapacityMetric(metric)) { + return null; + } + + const failedCount = metric.podPhases.failed; + + if (failedCount === 0) { + return null; + } + + return ( + + ); +} + +function SummaryPodsValue({ + metric, +}: Readonly<{ metric: OperationalMetric | undefined }>) { + return ( + + + + + {metric ? ( + + + + ) : null} + + ); +} + +const USAGE_SUMMARY_METRIC_IDS = [ + "registered-users", + "provisioned-gateways", + "provisioned-sandboxes", +] as const; + +const USAGE_SUMMARY_LABELS = { + "registered-users": messages.registeredUsersSummary, + "provisioned-gateways": messages.gateways, + "provisioned-sandboxes": messages.widgetSandboxes, +} as const; + +export function UsageSummaryCard({ + metrics, +}: Readonly<{ metrics: readonly OperationalMetric[] }>) { + const intl = useIntl(); + + return ( + + + {USAGE_SUMMARY_METRIC_IDS.map((metricId) => ( + + + + + + {metricId === "provisioned-gateways" ? ( + metric.id === metricId)} + /> + ) : ( + metric.id === metricId)} + /> + )} + + + ))} + + + ); +} + +export function SystemSummaryCard({ + metrics, +}: Readonly<{ metrics: readonly OperationalMetric[] }>) { + const intl = useIntl(); + + return ( + + + + + + + + metric.id === "memory")} + /> + + + + + + + + metric.id === "cpu")} + /> + + + + + + + + metric.id === "pods")} + /> + + + + + + + + metric.id === "nodes")} + /> + + + + + + + + metric.id === "provision-time")} + valueKey="mean" + /> + + + + + ); +} diff --git a/packages/operational-dashboard-ui/src/pages/get-metrics-data.ts b/packages/operational-dashboard-ui/src/pages/get-metrics-data.ts new file mode 100644 index 00000000..42c5ffc7 --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/get-metrics-data.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + operationalDashboardMetricsQueryKey, + operationalDashboardRefreshMilliseconds, +} from "../dashboard/dashboard-data"; +import { useDashboardUi } from "../dashboard-ui-provider"; + +export interface UseGetMetricsDataOptions { + enabled?: boolean; +} + +export function useGetMetricsData({ + enabled = true, +}: UseGetMetricsDataOptions = {}) { + const { dashboard } = useDashboardUi(); + + return useQuery({ + enabled, + queryFn: async ({ signal }) => { + return dashboard.getOperationalMetrics(signal); + }, + queryKey: operationalDashboardMetricsQueryKey(), + refetchInterval: operationalDashboardRefreshMilliseconds, + staleTime: operationalDashboardRefreshMilliseconds, + }); +} diff --git a/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx b/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx new file mode 100644 index 00000000..96bcd060 --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx @@ -0,0 +1,601 @@ +import { + Alert, + Bullseye, + Button, + Content, + EmptyState, + EmptyStateBody, + EmptyStateVariant, + PageSection, + Spinner, + Flex, + FlexItem, + Title, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from "@patternfly/react-core"; +import { + ClusterIcon, + CubesIcon, + HourglassHalfIcon, + UsersIcon, + MicrochipIcon, + MemoryIcon, + ServerIcon, +} from "@patternfly/react-icons"; +import { + AddWidgetsButton, + GridLayout, + WidgetDrawer, + type ExtendedTemplateConfig, + type Variants, + type WidgetMapping, +} from "@patternfly/widgetized-dashboard"; +import "@patternfly/widgetized-dashboard/dist/esm/styles.css"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { FormattedMessage, useIntl, type IntlShape } from "react-intl"; + +import type { OperationalDashboardMetrics } from "../application/dashboard-types"; +import type { DashboardProbe } from "../application/dashboard-probes"; +import { noopDashboardProbePublisher } from "../application/dashboard-probes"; +import { + defaultDashboardLayoutTemplate, + GATEWAY_STATUS_WIDGET_HEIGHT, + NODE_STATUS_WIDGET_HEIGHT, + localizeDashboardLayoutTemplate, + PROVISION_TIME_WIDGET_HEIGHT, + SYSTEM_SUMMARY_WIDGET_HEIGHT, + USAGE_SUMMARY_WIDGET_HEIGHT, +} from "../dashboard/dashboard-layout-template"; +import { + getActiveWidgetTypes, + isValidSavedTemplate, + sanitizeDashboardTemplate, +} from "../dashboard/dashboard-layout-persistence"; +import { UtilizationChart } from "../dashboard/utilization-chart"; +import { useDashboardUi } from "../dashboard-ui-provider"; +import { messages } from "../messages"; +import { ResourceRefreshButton } from "../shared/resource-refresh-button"; +import "./dashboard-widget.css"; +import { + GatewayStatusCard, + MetricCard, + NodeStatusCard, + PodCapacityCard, + ProvisionTimeCard, + SystemSummaryCard, + UsageSummaryCard, +} from "./dashboard-widget"; +import { useGetMetricsData } from "./get-metrics-data"; + +const baseTemplate = defaultDashboardLayoutTemplate; + +const LAYOUT_STORAGE_KEY = "hypershell.operational-dashboard.layout.v21"; +const CUSTOM_COLUMNS: Record = { + xl: 4, + lg: 4, + md: 4, + sm: 1, +}; + +function getAddedWidgetTypes( + currentTemplate: ExtendedTemplateConfig, + nextTemplate: ExtendedTemplateConfig, +): string[] { + const currentTypes = new Set(getActiveWidgetTypes(currentTemplate)); + + return getActiveWidgetTypes(nextTemplate).filter( + (type) => !currentTypes.has(type), + ); +} + +function readSavedTemplate( + localizedBaseTemplate: ExtendedTemplateConfig, + intl: IntlShape, +): { invalid: boolean; template: ExtendedTemplateConfig } { + if (typeof window === "undefined") { + return { invalid: false, template: localizedBaseTemplate }; + } + + try { + const rawTemplate = window.localStorage.getItem(LAYOUT_STORAGE_KEY); + if (!rawTemplate) { + return { invalid: false, template: localizedBaseTemplate }; + } + + const parsed = JSON.parse(rawTemplate) as ExtendedTemplateConfig; + if (!isValidSavedTemplate(parsed, localizedBaseTemplate)) { + return { invalid: true, template: localizedBaseTemplate }; + } + + return { + invalid: false, + template: localizeDashboardLayoutTemplate(parsed, intl), + }; + } catch { + return { invalid: true, template: localizedBaseTemplate }; + } +} + +function layoutProbe( + correlationId: string, + name: DashboardProbe["name"], + outcome: DashboardProbe["fields"]["outcome"], +): DashboardProbe { + return Object.freeze({ + context: Object.freeze({ correlationId }), + fields: Object.freeze({ + action: "persist-layout-template", + outcome, + }), + name, + occurredAt: new Date().toISOString(), + schemaVersion: 1, + }); +} + +const METRIC_WIDGET_DEFAULTS = { h: 3, maxH: 5, minH: 2, w: 1 }; + +function createWidgetMapping( + metrics: OperationalDashboardMetrics, + intl: IntlShape, +): WidgetMapping { + const metricById = new Map( + metrics.metrics.map((metric) => [metric.id, metric]), + ); + + const renderMetric = ( + metricId: string, + subtitle: string, + titleMessage: (typeof messages)[keyof typeof messages], + metricType: + | "metric" + | "gateway-status" + | "node-status" + | "pod-capacity" + | "provision-time" + | "utilization", + ) => { + const metric = metricById.get(metricId); + const title = intl.formatMessage(titleMessage); + + if (!metric) { + return ( + + + + <FormattedMessage {...messages.metricUnavailableTitle} /> + + + + + + + ); + } + + if (metricType === "metric") { + return ; + } + + if (metricType === "gateway-status") { + return ; + } + + if (metricType === "node-status") { + return ; + } + + if (metricType === "pod-capacity") { + return ; + } + + if (metricType === "provision-time") { + return ; + } + + return ; + }; + + return { + "usage-summary": { + defaults: { + h: USAGE_SUMMARY_WIDGET_HEIGHT, + maxH: USAGE_SUMMARY_WIDGET_HEIGHT + 2, + minH: METRIC_WIDGET_DEFAULTS.minH, + w: 1, + }, + config: { + icon: , + title: intl.formatMessage(messages.usageSummaryWidget), + }, + renderWidget: () => , + }, + "system-summary": { + defaults: { + h: SYSTEM_SUMMARY_WIDGET_HEIGHT, + maxH: SYSTEM_SUMMARY_WIDGET_HEIGHT + 2, + minH: METRIC_WIDGET_DEFAULTS.minH, + w: 1, + }, + config: { + icon: , + title: intl.formatMessage(messages.systemSummaryWidget), + }, + renderWidget: () => , + }, + "registered-users": { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.registeredUsers), + }, + renderWidget: () => + renderMetric( + "registered-users", + "", + messages.registeredUsers, + "metric", + ), + }, + "gateway-status": { + defaults: { + h: GATEWAY_STATUS_WIDGET_HEIGHT, + maxH: GATEWAY_STATUS_WIDGET_HEIGHT + 2, + minH: METRIC_WIDGET_DEFAULTS.minH, + w: 1, + }, + config: { + icon: , + title: intl.formatMessage(messages.gatewayStatusWidget), + }, + renderWidget: () => + renderMetric( + "provisioned-gateways", + "", + messages.gatewayStatusWidget, + "gateway-status", + ), + }, + "provision-time": { + defaults: { + h: PROVISION_TIME_WIDGET_HEIGHT, + maxH: PROVISION_TIME_WIDGET_HEIGHT + 2, + minH: METRIC_WIDGET_DEFAULTS.minH, + w: 1, + }, + config: { + icon: , + title: intl.formatMessage(messages.provisionTimeWidget), + }, + renderWidget: () => + renderMetric( + "provision-time", + "", + messages.provisionTimeWidget, + "provision-time", + ), + }, + "provisioned-sandboxes": { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.widgetSandboxes), + }, + renderWidget: () => + renderMetric( + "provisioned-sandboxes", + "", + messages.widgetSandboxes, + "metric", + ), + }, + cpu: { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.widgetCpu), + }, + renderWidget: () => + renderMetric("cpu", "", messages.widgetCpu, "utilization"), + }, + memory: { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.widgetMemory), + }, + renderWidget: () => + renderMetric("memory", "", messages.widgetMemory, "utilization"), + }, + pods: { + defaults: { + h: NODE_STATUS_WIDGET_HEIGHT, + maxH: NODE_STATUS_WIDGET_HEIGHT + 2, + minH: METRIC_WIDGET_DEFAULTS.minH, + w: 1, + }, + config: { + icon: , + title: intl.formatMessage(messages.widgetPods), + }, + renderWidget: () => + renderMetric("pods", "", messages.widgetPods, "pod-capacity"), + }, + nodes: { + defaults: { + h: NODE_STATUS_WIDGET_HEIGHT, + maxH: NODE_STATUS_WIDGET_HEIGHT + 2, + minH: METRIC_WIDGET_DEFAULTS.minH, + w: 1, + }, + config: { + icon: , + title: intl.formatMessage(messages.nodes), + }, + renderWidget: () => + renderMetric("nodes", "", messages.nodes, "node-status"), + }, + }; +} + +export interface OperationalDashboardPageProps { + metrics?: OperationalDashboardMetrics; + title?: string; +} + +export function OperationalDashboardPage({ + metrics, + title, +}: Readonly) { + const intl = useIntl(); + const { probes = noopDashboardProbePublisher } = useDashboardUi(); + const pageTitle = title ?? intl.formatMessage(messages.title); + const metricsQuery = useGetMetricsData({ + enabled: metrics === undefined, + }); + const dashboardMetrics = metrics ?? metricsQuery.data; + const showInitialLoadError = + metrics === undefined && metricsQuery.isError && !metricsQuery.data; + const showRefreshError = + metrics === undefined && + metricsQuery.isError && + Boolean(metricsQuery.data) && + !metricsQuery.isFetching; + const localizedBaseTemplate = useMemo( + () => localizeDashboardLayoutTemplate(baseTemplate, intl), + [intl], + ); + const [drawerOpen, setDrawerOpen] = useState(false); + const [gridLayoutKey, setGridLayoutKey] = useState(0); + const [droppingWidgetType, setDroppingWidgetType] = useState< + string | undefined + >(); + + const savedTemplateResult = useMemo( + () => readSavedTemplate(localizedBaseTemplate, intl), + [intl, localizedBaseTemplate], + ); + + const invalidTemplateProbePublishedRef = useRef(false); + + useEffect(() => { + if ( + !savedTemplateResult.invalid || + invalidTemplateProbePublishedRef.current + ) { + return; + } + + invalidTemplateProbePublishedRef.current = true; + probes.publish( + layoutProbe( + crypto.randomUUID(), + "dashboard.layout.template.invalid", + "failed", + ), + ); + }, [probes, savedTemplateResult.invalid]); + + const [dashboardTemplate, setDashboardTemplate] = + useState(savedTemplateResult.template); + const displayTemplate = useMemo( + () => localizeDashboardLayoutTemplate(dashboardTemplate, intl), + [dashboardTemplate, intl], + ); + const activeWidgetTypes = useMemo( + () => getActiveWidgetTypes(displayTemplate), + [displayTemplate], + ); + const widgetMapping = useMemo( + () => + dashboardMetrics + ? createWidgetMapping(dashboardMetrics, intl) + : undefined, + [dashboardMetrics, intl], + ); + const hasWidgetsToAdd = useMemo(() => { + if (!widgetMapping) { + return false; + } + + return Object.keys(widgetMapping).some( + (type) => !activeWidgetTypes.includes(type), + ); + }, [widgetMapping, activeWidgetTypes]); + + const handleTemplateChange = (nextTemplate: ExtendedTemplateConfig) => { + const addedTypes = getAddedWidgetTypes(dashboardTemplate, nextTemplate); + + if (addedTypes.length > 0 && droppingWidgetType === undefined) { + setGridLayoutKey((currentKey) => currentKey + 1); + return; + } + + if ( + droppingWidgetType !== undefined && + activeWidgetTypes.includes(droppingWidgetType) + ) { + setGridLayoutKey((currentKey) => currentKey + 1); + return; + } + + const sanitized = sanitizeDashboardTemplate(nextTemplate); + const correlationId = crypto.randomUUID(); + + setDashboardTemplate(sanitized); + + const sanitizedTypes = getActiveWidgetTypes(sanitized); + if ( + widgetMapping && + Object.keys(widgetMapping).every((type) => sanitizedTypes.includes(type)) + ) { + setDrawerOpen(false); + } + + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + LAYOUT_STORAGE_KEY, + JSON.stringify(sanitized), + ); + } catch { + probes.publish( + layoutProbe( + correlationId, + "dashboard.layout.template.persistence-failed", + "failed", + ), + ); + } + }; + + const handleResetToDefault = () => { + const defaultTemplate = localizeDashboardLayoutTemplate(baseTemplate, intl); + const correlationId = crypto.randomUUID(); + + setDashboardTemplate(defaultTemplate); + setDrawerOpen(false); + setGridLayoutKey((currentKey) => currentKey + 1); + + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + LAYOUT_STORAGE_KEY, + JSON.stringify(defaultTemplate), + ); + } catch { + probes.publish( + layoutProbe( + correlationId, + "dashboard.layout.template.persistence-failed", + "failed", + ), + ); + } + }; + + return ( + + + + + {pageTitle} +

+ +

+
+
+ {metrics === undefined ? ( + + { + void metricsQuery.refetch(); + }} + /> + + ) : null} +
+ {metricsQuery.isPending && metrics === undefined ? ( + + + + ) : null} + {showInitialLoadError ? ( + + + + ) : null} + {showRefreshError ? ( + + + + ) : null} + {widgetMapping ? ( + <> + + + + + + + {hasWidgetsToAdd ? ( + + { + setDrawerOpen(!drawerOpen); + }} + > + {intl.formatMessage(messages.addWidgets)} + + + ) : null} + + + + { + setDroppingWidgetType(undefined); + }} + onWidgetDragStart={setDroppingWidgetType} + widgetMapping={widgetMapping} + > + + + + ) : null} +
+ ); +} diff --git a/packages/operational-dashboard-ui/src/patternfly/victory-charts.ts b/packages/operational-dashboard-ui/src/patternfly/victory-charts.ts new file mode 100644 index 00000000..9f65a38f --- /dev/null +++ b/packages/operational-dashboard-ui/src/patternfly/victory-charts.ts @@ -0,0 +1,6 @@ +export { + ChartArea, + ChartGroup, + ChartThemeColor, + ChartVoronoiContainer, +} from "@patternfly/react-charts/victory"; diff --git a/packages/operational-dashboard-ui/src/shared/resource-refresh-button.tsx b/packages/operational-dashboard-ui/src/shared/resource-refresh-button.tsx new file mode 100644 index 00000000..01917c1b --- /dev/null +++ b/packages/operational-dashboard-ui/src/shared/resource-refresh-button.tsx @@ -0,0 +1,29 @@ +import { Button } from "@patternfly/react-core"; +import { SyncAltIcon } from "@patternfly/react-icons"; + +interface ResourceRefreshButtonProps { + ariaLabel: string; + isRefreshing?: boolean; + onRefresh: () => unknown; +} + +export function ResourceRefreshButton({ + ariaLabel, + isRefreshing = false, + onRefresh, +}: ResourceRefreshButtonProps) { + return ( +