From 403475e6fead9ebed012e02aea8b67bfbb7b8c1d Mon Sep 17 00:00:00 2001 From: Kim Doberstein Date: Tue, 8 Sep 2026 10:56:29 -0500 Subject: [PATCH] feat(operational-dashboard): add live metrics dashboard with hub cluster data Deliver the operational dashboard with widgets for gateways, registered users, and hub cluster resources (memory, CPU, pods, nodes, provision time). Restrict dashboard access to admin roles, wire live data through the BFF, and align JWT role extraction between API server and BFF. Co-authored-by: Cursor --- .dockerignore | 2 + .forbidden-terms-whitelist.json | 2 +- .github/component-paths.json | 15 + .github/workflows/lint.yml | 24 +- CLAUDE.md | 2 + README.md | 2 +- .../api-server/openapi/openapi.users.yaml | 124 +++ components/api-server/openapi/openapi.yaml | 8 + .../pkg/api/openapi/.openapi-generator/FILES | 4 + .../api-server/pkg/api/openapi/README.md | 4 + .../pkg/api/openapi/api/openapi.yaml | 158 ++++ .../api-server/pkg/api/openapi/api_default.go | 287 +++++++ .../pkg/api/openapi/docs/DefaultAPI.md | 142 ++++ .../api-server/pkg/api/openapi/docs/User.md | 233 +++++ .../pkg/api/openapi/docs/UserList.md | 264 ++++++ .../api-server/pkg/api/openapi/model_user.go | 409 +++++++++ .../pkg/api/openapi/model_user_list.go | 413 +++++++++ .../api-server/pkg/rbac/authorization.go | 74 +- .../api-server/pkg/rbac/authorization_test.go | 120 ++- .../api-server/pkg/rbac/grpc_interceptor.go | 41 - components/api-server/pkg/rbac/jwt_roles.go | 117 +++ .../api-server/pkg/rbac/jwt_roles_test.go | 106 +++ .../api-server/pkg/rbac/user_provisioning.go | 57 +- .../api-server/plugins/users/handler.go | 87 ++ .../plugins/users/integration_test.go | 139 +++ components/api-server/plugins/users/plugin.go | 18 + .../api-server/plugins/users/presenter.go | 20 + .../api-server/plugins/users/testmain_test.go | 25 + components/sdk-typescript/src/base.ts | 2 +- components/sdk-typescript/src/client.ts | 5 +- components/sdk-typescript/src/gateway.ts | 2 +- components/sdk-typescript/src/gateway_api.ts | 2 +- .../sdk-typescript/src/gateway_network.ts | 2 +- .../sdk-typescript/src/gateway_network_api.ts | 2 +- .../sdk-typescript/src/gateway_release.ts | 2 +- .../sdk-typescript/src/gateway_release_api.ts | 2 +- components/sdk-typescript/src/index.ts | 8 +- .../sdk-typescript/src/managed_cluster.ts | 2 +- .../sdk-typescript/src/managed_cluster_api.ts | 2 +- .../sdk-typescript/src/managed_database.ts | 2 +- .../src/managed_database_api.ts | 2 +- .../src/open_shell_gateway_service_account.ts | 2 +- .../open_shell_gateway_service_account_api.ts | 2 +- components/sdk-typescript/src/role.ts | 2 +- components/sdk-typescript/src/role_api.ts | 2 +- components/sdk-typescript/src/role_binding.ts | 2 +- .../sdk-typescript/src/role_binding_api.ts | 2 +- components/sdk-typescript/src/user.ts | 60 ++ components/sdk-typescript/src/user_api.ts | 38 + components/web-console/Dockerfile | 4 +- .../api/dashboard-control-plane.test.ts | 795 ++++++++++++++++++ .../adapters/api/dashboard-control-plane.ts | 336 ++++++++ .../adapters/mock/dashboard-control-plane.ts | 20 + .../adapters/session/session-adapter.test.ts | 19 +- .../app/adapters/session/session-adapter.ts | 23 +- .../app/composition/dashboard-composition.ts | 10 + .../operational-dashboard.stories.tsx | 178 ++++ .../require-dashboard-admin.test.tsx | 104 +++ .../dashboard/require-dashboard-admin.tsx | 57 ++ .../app/features/shell/application-shell.tsx | 41 +- components/web-console/app/i18n/messages.ts | 30 + .../app/lib/dashboard-host.test.ts | 16 + .../web-console/app/lib/dashboard-host.ts | 5 + .../web-console/app/lib/session-roles.test.ts | 23 + .../web-console/app/lib/session-roles.ts | 5 + components/web-console/app/routes.ts | 1 + .../web-console/app/routes/dashboard.tsx | 17 + components/web-console/app/routes/home.tsx | 10 + components/web-console/app/routes/metrics.tsx | 7 +- components/web-console/bff/package.json | 2 +- components/web-console/bff/src/app.ts | 202 ++++- .../web-console/bff/src/auth-roles.test.ts | 55 ++ components/web-console/bff/src/auth.ts | 64 +- components/web-console/bff/src/config.ts | 11 + .../web-console/bff/src/dashboard-roles.ts | 5 + .../bff/src/metrics-cluster-cpu.ts | 94 +++ .../bff/src/metrics-cluster-memory.ts | 99 +++ .../bff/src/metrics-cluster-nodes.ts | 91 ++ .../bff/src/metrics-cluster-pods.ts | 160 ++++ .../src/metrics-gateway-provision-duration.ts | 113 +++ .../web-console/bff/src/metrics-gateways.ts | 65 ++ components/web-console/bff/src/roles.test.ts | 23 + components/web-console/bff/src/roles.ts | 5 + .../web-console/bff/test/app-tracing.test.ts | 2 + components/web-console/bff/test/app.test.ts | 4 + components/web-console/bff/test/auth.test.ts | 125 ++- .../web-console/bff/test/config.test.ts | 30 + .../test/metrics-cluster-cpu-route.test.ts | 326 +++++++ .../bff/test/metrics-cluster-cpu.test.ts | 165 ++++ .../test/metrics-cluster-memory-route.test.ts | 326 +++++++ .../bff/test/metrics-cluster-memory.test.ts | 141 ++++ .../test/metrics-cluster-nodes-route.test.ts | 326 +++++++ .../bff/test/metrics-cluster-nodes.test.ts | 135 +++ .../test/metrics-cluster-pods-route.test.ts | 381 +++++++++ .../bff/test/metrics-cluster-pods.test.ts | 214 +++++ ...metrics-gateway-provision-duration.test.ts | 114 +++ .../bff/test/metrics-gateways.test.ts | 92 ++ components/web-console/bff/tsconfig.json | 4 +- components/web-console/bff/tsconfig.test.json | 9 +- .../web-console/domain-probes/package.json | 8 +- components/web-console/locales/en.json | 324 +++++++ components/web-console/package.json | 12 +- components/web-console/route-contract.json | 2 + .../web-console/shared/dashboard-roles.ts | 14 + .../web-console/shared/gateway-phases.ts | 25 + components/web-console/tsconfig.app.json | 1 + components/web-console/tsconfig.test.json | 1 + deploy/base/kustomization.yaml | 1 + .../kube-state-metrics-servicemonitor.yaml | 18 + .../base/prometheus/kube-state-metrics.yaml | 115 +++ deploy/base/prometheus/kustomization.yaml | 6 + .../node-exporter-servicemonitor.yaml | 18 + deploy/base/prometheus/node-exporter.yaml | 86 ++ .../otel-collector-servicemonitor.yaml | 18 + deploy/base/prometheus/otel-collector.yaml | 112 +++ deploy/base/prometheus/prometheus.yaml | 4 +- deploy/base/prometheus/servicemonitor.yaml | 1 + deploy/kind/kustomization.yaml | 12 + package.json | 4 +- .../src/gateways/gateway-data.test.ts | 29 + .../src/gateways/gateway-data.ts | 120 ++- packages/gateway-management-ui/src/index.ts | 6 + .../gateway-metrics-dashboard.test.tsx | 81 ++ .../src/metrics/gateway-metrics-dashboard.tsx | 7 + .../src/metrics/gateway-metrics-data.test.ts | 88 ++ .../src/metrics/gateway-metrics-data.ts | 40 +- .../operational-dashboard-ui/.prettierignore | 3 + .../operational-dashboard-ui/DATA_SOURCES.md | 39 + .../eslint.config.mjs | 62 ++ .../operational-dashboard-ui/package.json | 72 ++ .../src/application/dashboard-operations.ts | 101 +++ .../src/application/dashboard-probes.ts | 35 + .../src/application/dashboard-types.ts | 83 ++ .../src/dashboard-ui-provider.tsx | 41 + .../src/dashboard/dashboard-data.ts | 10 + .../dashboard-layout-persistence.test.ts | 70 ++ .../dashboard/dashboard-layout-persistence.ts | 43 + .../dashboard/dashboard-layout-template.ts | 292 +++++++ .../gateway-exception-status-counts.ts | 10 + .../src/dashboard/gateway-status-chart.tsx | 50 ++ .../src/dashboard/gateway-status-data.test.ts | 55 ++ .../src/dashboard/gateway-status-data.ts | 60 ++ .../src/dashboard/metric-trend-change.test.ts | 41 + .../src/dashboard/metric-trend-change.ts | 46 + .../src/dashboard/node-status-chart.tsx | 48 ++ .../src/dashboard/node-status-data.test.ts | 62 ++ .../src/dashboard/node-status-data.ts | 52 ++ .../operational-metric-display.test.ts | 48 ++ .../dashboard/operational-metric-display.ts | 24 + .../src/dashboard/pod-capacity-chart.tsx | 65 ++ .../src/dashboard/pod-capacity-data.test.ts | 73 ++ .../src/dashboard/pod-capacity-data.ts | 109 +++ .../src/dashboard/pod-capacity-metric.ts | 19 + .../src/dashboard/provision-time-chart.tsx | 81 ++ .../src/dashboard/provision-time-data.test.ts | 38 + .../src/dashboard/provision-time-data.ts | 56 ++ .../src/dashboard/status-donut-chart.tsx | 140 +++ .../src/dashboard/status-donut-colors.ts | 11 + .../src/dashboard/status-donut-data.test.ts | 45 + .../src/dashboard/status-donut-data.ts | 33 + .../src/dashboard/status-donut-metric.ts | 13 + .../src/dashboard/trend-sparkline-chart.tsx | 96 +++ .../src/dashboard/utilization-chart.tsx | 151 ++++ .../mock-operational-dashboard-metrics.ts | 73 ++ .../operational-dashboard-ui/src/index.ts | 40 + .../operational-dashboard-ui/src/messages.ts | 402 +++++++++ .../src/pages/dashboard-widget.css | 135 +++ .../src/pages/dashboard-widget.tsx | 669 +++++++++++++++ .../src/pages/get-metrics-data.ts | 27 + .../src/pages/operational-dashboard-page.tsx | 601 +++++++++++++ .../src/patternfly/victory-charts.ts | 6 + .../src/shared/resource-refresh-button.tsx | 29 + .../operational-dashboard-ui/src/types.d.ts | 4 + .../tsconfig.app.json | 21 + .../operational-dashboard-ui/tsconfig.json | 4 + .../tsconfig.test.json | 9 + .../operational-dashboard-ui/vitest.config.ts | 29 + .../operational-dashboard-ui/vitest.setup.ts | 20 + pnpm-lock.yaml | 776 +++++++++++++++++ pnpm-workspace.yaml | 1 + scripts/kind/lib.sh | 17 +- scripts/kind/swap-component.sh | 15 +- scripts/kind/up.sh | 28 +- skills/RECONCILE.md | 397 ++++++++- specs/index.spec.md | 8 + specs/platform/cluster-cpu.spec.md | 227 +++++ specs/platform/cluster-memory.spec.md | 221 +++++ specs/platform/cluster-nodes.spec.md | 236 ++++++ specs/platform/cluster-pods.spec.md | 260 ++++++ .../control-plane-observability.spec.md | 2 + .../gateway-metrics-dashboard.spec.md | 75 +- .../platform/gateway-phase-vocabulary.spec.md | 33 +- specs/platform/gateway-provision-time.spec.md | 283 +++++++ specs/platform/registered-users.spec.md | 203 +++++ .../web-console/operational-dashboard.spec.md | 483 +++++++++++ 195 files changed, 16616 insertions(+), 274 deletions(-) create mode 100644 components/api-server/openapi/openapi.users.yaml create mode 100644 components/api-server/pkg/api/openapi/docs/User.md create mode 100644 components/api-server/pkg/api/openapi/docs/UserList.md create mode 100644 components/api-server/pkg/api/openapi/model_user.go create mode 100644 components/api-server/pkg/api/openapi/model_user_list.go create mode 100644 components/api-server/pkg/rbac/jwt_roles.go create mode 100644 components/api-server/pkg/rbac/jwt_roles_test.go create mode 100644 components/api-server/plugins/users/handler.go create mode 100644 components/api-server/plugins/users/integration_test.go create mode 100644 components/api-server/plugins/users/presenter.go create mode 100644 components/api-server/plugins/users/testmain_test.go create mode 100644 components/sdk-typescript/src/user.ts create mode 100644 components/sdk-typescript/src/user_api.ts create mode 100644 components/web-console/app/adapters/api/dashboard-control-plane.test.ts create mode 100644 components/web-console/app/adapters/api/dashboard-control-plane.ts create mode 100644 components/web-console/app/adapters/mock/dashboard-control-plane.ts create mode 100644 components/web-console/app/composition/dashboard-composition.ts create mode 100644 components/web-console/app/features/dashboard/operational-dashboard.stories.tsx create mode 100644 components/web-console/app/features/dashboard/require-dashboard-admin.test.tsx create mode 100644 components/web-console/app/features/dashboard/require-dashboard-admin.tsx create mode 100644 components/web-console/app/lib/dashboard-host.test.ts create mode 100644 components/web-console/app/lib/dashboard-host.ts create mode 100644 components/web-console/app/lib/session-roles.test.ts create mode 100644 components/web-console/app/lib/session-roles.ts create mode 100644 components/web-console/app/routes/dashboard.tsx create mode 100644 components/web-console/bff/src/auth-roles.test.ts create mode 100644 components/web-console/bff/src/dashboard-roles.ts create mode 100644 components/web-console/bff/src/metrics-cluster-cpu.ts create mode 100644 components/web-console/bff/src/metrics-cluster-memory.ts create mode 100644 components/web-console/bff/src/metrics-cluster-nodes.ts create mode 100644 components/web-console/bff/src/metrics-cluster-pods.ts create mode 100644 components/web-console/bff/src/metrics-gateway-provision-duration.ts create mode 100644 components/web-console/bff/src/metrics-gateways.ts create mode 100644 components/web-console/bff/src/roles.test.ts create mode 100644 components/web-console/bff/src/roles.ts create mode 100644 components/web-console/bff/test/metrics-cluster-cpu-route.test.ts create mode 100644 components/web-console/bff/test/metrics-cluster-cpu.test.ts create mode 100644 components/web-console/bff/test/metrics-cluster-memory-route.test.ts create mode 100644 components/web-console/bff/test/metrics-cluster-memory.test.ts create mode 100644 components/web-console/bff/test/metrics-cluster-nodes-route.test.ts create mode 100644 components/web-console/bff/test/metrics-cluster-nodes.test.ts create mode 100644 components/web-console/bff/test/metrics-cluster-pods-route.test.ts create mode 100644 components/web-console/bff/test/metrics-cluster-pods.test.ts create mode 100644 components/web-console/bff/test/metrics-gateway-provision-duration.test.ts create mode 100644 components/web-console/bff/test/metrics-gateways.test.ts create mode 100644 components/web-console/shared/dashboard-roles.ts create mode 100644 components/web-console/shared/gateway-phases.ts create mode 100644 deploy/base/prometheus/kube-state-metrics-servicemonitor.yaml create mode 100644 deploy/base/prometheus/kube-state-metrics.yaml create mode 100644 deploy/base/prometheus/node-exporter-servicemonitor.yaml create mode 100644 deploy/base/prometheus/node-exporter.yaml create mode 100644 deploy/base/prometheus/otel-collector-servicemonitor.yaml create mode 100644 deploy/base/prometheus/otel-collector.yaml create mode 100644 packages/gateway-management-ui/src/metrics/gateway-metrics-dashboard.test.tsx create mode 100644 packages/gateway-management-ui/src/metrics/gateway-metrics-data.test.ts create mode 100644 packages/operational-dashboard-ui/.prettierignore create mode 100644 packages/operational-dashboard-ui/DATA_SOURCES.md create mode 100644 packages/operational-dashboard-ui/eslint.config.mjs create mode 100644 packages/operational-dashboard-ui/package.json create mode 100644 packages/operational-dashboard-ui/src/application/dashboard-operations.ts create mode 100644 packages/operational-dashboard-ui/src/application/dashboard-probes.ts create mode 100644 packages/operational-dashboard-ui/src/application/dashboard-types.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard-ui-provider.tsx create mode 100644 packages/operational-dashboard-ui/src/dashboard/dashboard-data.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.test.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/gateway-exception-status-counts.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/gateway-status-chart.tsx create mode 100644 packages/operational-dashboard-ui/src/dashboard/gateway-status-data.test.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/gateway-status-data.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/metric-trend-change.test.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/metric-trend-change.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/node-status-chart.tsx create mode 100644 packages/operational-dashboard-ui/src/dashboard/node-status-data.test.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/node-status-data.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/operational-metric-display.test.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/operational-metric-display.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/pod-capacity-chart.tsx create mode 100644 packages/operational-dashboard-ui/src/dashboard/pod-capacity-data.test.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/pod-capacity-data.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/pod-capacity-metric.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/provision-time-chart.tsx create mode 100644 packages/operational-dashboard-ui/src/dashboard/provision-time-data.test.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/provision-time-data.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/status-donut-chart.tsx create mode 100644 packages/operational-dashboard-ui/src/dashboard/status-donut-colors.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/status-donut-data.test.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/status-donut-data.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/status-donut-metric.ts create mode 100644 packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx create mode 100644 packages/operational-dashboard-ui/src/dashboard/utilization-chart.tsx create mode 100644 packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts create mode 100644 packages/operational-dashboard-ui/src/index.ts create mode 100644 packages/operational-dashboard-ui/src/messages.ts create mode 100644 packages/operational-dashboard-ui/src/pages/dashboard-widget.css create mode 100644 packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx create mode 100644 packages/operational-dashboard-ui/src/pages/get-metrics-data.ts create mode 100644 packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx create mode 100644 packages/operational-dashboard-ui/src/patternfly/victory-charts.ts create mode 100644 packages/operational-dashboard-ui/src/shared/resource-refresh-button.tsx create mode 100644 packages/operational-dashboard-ui/src/types.d.ts create mode 100644 packages/operational-dashboard-ui/tsconfig.app.json create mode 100644 packages/operational-dashboard-ui/tsconfig.json create mode 100644 packages/operational-dashboard-ui/tsconfig.test.json create mode 100644 packages/operational-dashboard-ui/vitest.config.ts create mode 100644 packages/operational-dashboard-ui/vitest.setup.ts create mode 100644 specs/platform/cluster-cpu.spec.md create mode 100644 specs/platform/cluster-memory.spec.md create mode 100644 specs/platform/cluster-nodes.spec.md create mode 100644 specs/platform/cluster-pods.spec.md create mode 100644 specs/platform/gateway-provision-time.spec.md create mode 100644 specs/platform/registered-users.spec.md create mode 100644 specs/web-console/operational-dashboard.spec.md 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 ( +