From 0978a96881458538e3c1ee7e935c8c7ee0ca682d Mon Sep 17 00:00:00 2001 From: Kim Doberstein Date: Tue, 1 Sep 2026 17:10:50 -0500 Subject: [PATCH 1/2] feat(operational-dashboard): add operational dashboard with widgets Introduce the operational-dashboard-ui package and wire it into the web console with gateway status, utilization, and summary widgets, layout persistence, mock metrics support, and dashboard host routing. Co-authored-by: Cursor --- .dockerignore | 2 + .github/component-paths.json | 15 + .github/workflows/lint.yml | 24 +- CLAUDE.md | 2 + components/web-console/Dockerfile | 2 + .../adapters/mock/dashboard-control-plane.ts | 20 + .../app/composition/dashboard-composition.ts | 7 + .../operational-dashboard.stories.tsx | 179 ++++ .../app/features/shell/application-shell.tsx | 42 +- components/web-console/app/i18n/messages.ts | 17 + .../app/lib/dashboard-host.test.ts | 16 + .../web-console/app/lib/dashboard-host.ts | 5 + components/web-console/app/routes.ts | 1 + .../web-console/app/routes/dashboard.tsx | 12 + components/web-console/app/routes/home.tsx | 5 + components/web-console/bff/src/app.ts | 1 + components/web-console/bff/test/auth.test.ts | 7 +- .../web-console/domain-probes/package.json | 8 +- components/web-console/locales/en.json | 228 ++++++ components/web-console/package.json | 12 +- components/web-console/route-contract.json | 2 + package.json | 4 +- .../eslint.config.mjs | 62 ++ .../operational-dashboard-ui/package.json | 65 ++ .../src/application/dashboard-operations.ts | 101 +++ .../src/application/dashboard-probes.ts | 35 + .../src/application/dashboard-types.ts | 67 ++ .../src/dashboard-ui-provider.tsx | 45 ++ .../src/dashboard/dashboard-data.ts | 10 + .../dashboard/dashboard-layout-template.ts | 232 ++++++ .../gateway-exception-status-counts.ts | 10 + .../src/dashboard/gateway-status-chart.tsx | 212 +++++ .../src/dashboard/metric-trend-change.ts | 46 ++ .../src/dashboard/trend-sparkline-chart.tsx | 96 +++ .../src/dashboard/utilization-chart.tsx | 135 ++++ .../mock-operational-dashboard-metrics.ts | 97 +++ .../operational-dashboard-ui/src/index.ts | 38 + .../operational-dashboard-ui/src/messages.ts | 295 +++++++ .../src/pages/dashboard-widget.css | 104 +++ .../src/pages/dashboard-widget.tsx | 525 ++++++++++++ .../src/pages/get-metrics-data.ts | 27 + .../src/pages/operational-dashboard-page.tsx | 572 +++++++++++++ .../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 + pnpm-lock.yaml | 761 ++++++++++++++++++ pnpm-workspace.yaml | 1 + scripts/kind/swap-component.sh | 13 +- 50 files changed, 4201 insertions(+), 23 deletions(-) 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/lib/dashboard-host.test.ts create mode 100644 components/web-console/app/lib/dashboard-host.ts create mode 100644 components/web-console/app/routes/dashboard.tsx 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-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/metric-trend-change.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 diff --git a/.dockerignore b/.dockerignore index f1019c599..0a3e8c565 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/.github/component-paths.json b/.github/component-paths.json index 8107c8757..b5c6487b1 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 6f8b14ea4..a39bf6bef 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 0aad39097..e69022208 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/components/web-console/Dockerfile b/components/web-console/Dockerfile index 61d8f4bbb..3ffe9c12a 100644 --- a/components/web-console/Dockerfile +++ b/components/web-console/Dockerfile @@ -13,6 +13,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 @@ -21,6 +22,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/logo.png images/brand/logo.png 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 000000000..c1dcea8d7 --- /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/composition/dashboard-composition.ts b/components/web-console/app/composition/dashboard-composition.ts new file mode 100644 index 000000000..896e0bb3d --- /dev/null +++ b/components/web-console/app/composition/dashboard-composition.ts @@ -0,0 +1,7 @@ +import { createDashboardOperations } from "@openshift-online/hypershell-operational-dashboard-ui"; + +import { createMockDashboardControlPlane } from "../adapters/mock/dashboard-control-plane"; + +export const dashboardOperations = createDashboardOperations({ + controlPlane: createMockDashboardControlPlane(), +}); 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 000000000..fae609f09 --- /dev/null +++ b/components/web-console/app/features/dashboard/operational-dashboard.stories.tsx @@ -0,0 +1,179 @@ +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/shell/application-shell.tsx b/components/web-console/app/features/shell/application-shell.tsx index 242a80ba0..562a1d6c2 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,25 @@ export function ApplicationShell() { } return ( - - - - - + + + + + ); } diff --git a/components/web-console/app/i18n/messages.ts b/components/web-console/app/i18n/messages.ts index 9d8d1c2ab..a3c88a911 100644 --- a/components/web-console/app/i18n/messages.ts +++ b/components/web-console/app/i18n/messages.ts @@ -11,6 +11,23 @@ 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.", + }, + 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 000000000..94d1ce1a0 --- /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 000000000..99a9d0a40 --- /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/routes.ts b/components/web-console/app/routes.ts index c20aa10f3..87d024cb1 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"), ]), diff --git a/components/web-console/app/routes/dashboard.tsx b/components/web-console/app/routes/dashboard.tsx new file mode 100644 index 000000000..ba2a79b13 --- /dev/null +++ b/components/web-console/app/routes/dashboard.tsx @@ -0,0 +1,12 @@ +import { OperationalDashboardPage } from "@openshift-online/hypershell-operational-dashboard-ui"; + +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 bd3423801..76d4f7eae 100644 --- a/components/web-console/app/routes/home.tsx +++ b/components/web-console/app/routes/home.tsx @@ -1,10 +1,12 @@ 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 { 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 +18,9 @@ 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/bff/src/app.ts b/components/web-console/bff/src/app.ts index 243113c38..9754a4bc0 100644 --- a/components/web-console/bff/src/app.ts +++ b/components/web-console/bff/src/app.ts @@ -47,6 +47,7 @@ declare module "fastify" { function isApplicationRoute(pathname: string): boolean { return ( pathname === "/" || + pathname === "/dashboard" || pathname === "/login" || pathname === "/gateways/new" || /^\/gateways\/[^/]+\/?$/u.test(pathname) diff --git a/components/web-console/bff/test/auth.test.ts b/components/web-console/bff/test/auth.test.ts index 43212dce8..2ff926c84 100644 --- a/components/web-console/bff/test/auth.test.ts +++ b/components/web-console/bff/test/auth.test.ts @@ -783,7 +783,12 @@ 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", + "/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"); diff --git a/components/web-console/domain-probes/package.json b/components/web-console/domain-probes/package.json index 71d9e864a..aa36348ed 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 377c6449c..1a85ec72b 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -27,6 +27,222 @@ "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": "Monitor fleet utilization, gateway health, and operational capacity.", + "description": "Supporting text on the operational dashboard page." + }, + "app.dashboard.sampleData.bannerBody": { + "defaultMessage": "Metrics on this page are sample data until live operational metrics are connected.", + "description": "Body for the banner shown when the operational dashboard uses mock metrics." + }, + "app.dashboard.sampleData.bannerTitle": { + "defaultMessage": "Sample data", + "description": "Title for the banner shown when the operational dashboard uses mock metrics." + }, + "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." + }, + "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.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.gatewayStatus.running": { + "defaultMessage": "Running", + "description": "Legend label for running 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.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.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.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", + "description": "Summary label for provision time." + }, + "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.summary.users": { + "defaultMessage": "Users", + "description": "Summary label for active users." + }, + "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.activeUsers": { + "defaultMessage": "Active users", + "description": "Title for the active users dashboard 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.provisionedGateways": { + "defaultMessage": "Provisioned gateways", + "description": "Title for the provisioned gateways 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." @@ -647,6 +863,10 @@ "defaultMessage": "Log out", "description": "Sign-out action in the masthead identity menu." }, + "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." @@ -663,6 +883,10 @@ "defaultMessage": "Notifications", "description": "Accessible label for transient application notifications." }, + "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." @@ -719,6 +943,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 d35afefbe..ca2ddcf16 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 3ac15da3e..6073cb21d 100644 --- a/components/web-console/route-contract.json +++ b/components/web-console/route-contract.json @@ -1,9 +1,11 @@ { + "dashboard": "dashboard", "gatewayDetail": "gateways/:gatewayId", "gatewayNew": "gateways/new", "login": "login", "directNavigationExamples": [ "/", + "/dashboard", "/login", "/gateways/new", "/gateways/gateway-1" diff --git a/package.json b/package.json index f2c8d8f04..bd967534d 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/operational-dashboard-ui/eslint.config.mjs b/packages/operational-dashboard-ui/eslint.config.mjs new file mode 100644 index 000000000..ebc46e071 --- /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"], + 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 000000000..9034c90d8 --- /dev/null +++ b/packages/operational-dashboard-ui/package.json @@ -0,0 +1,65 @@ +{ + "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", + "format:check": "prettier --check .", + "lint": "eslint . --max-warnings=0", + "typecheck": "tsc --project tsconfig.app.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", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "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", + "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", + "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 000000000..6861c0b7b --- /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 000000000..5c01bab34 --- /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 000000000..e5e7cd8c1 --- /dev/null +++ b/packages/operational-dashboard-ui/src/application/dashboard-types.ts @@ -0,0 +1,67 @@ +export interface OperationalMetricTrendPoint { + label: string; + value: number; +} + +export interface OperationalMetricTrend { + points: readonly OperationalMetricTrendPoint[]; +} + +export interface OperationalMetricStatus { + degraded?: number; + failed?: number; + provisioning?: number; + running?: number; +} + +export interface OperationalMetric { + id: string; + 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 000000000..67ea60b0d --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard-ui-provider.tsx @@ -0,0 +1,45 @@ +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; + usesSampleData?: boolean; +} + +const DashboardUiContext = createContext( + undefined, +); + +export function DashboardUiProvider({ + children, + dashboard, + navigation, + probes, + usesSampleData = false, +}: 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 000000000..e015482aa --- /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-template.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts new file mode 100644 index 000000000..85bf7be39 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts @@ -0,0 +1,232 @@ +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; +/** 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; +export const SYSTEM_SUMMARY_WIDGET_HEIGHT = USAGE_SUMMARY_WIDGET_HEIGHT; +const SYSTEM_SUMMARY_WIDGET_Y = USAGE_SUMMARY_WIDGET_HEIGHT + METRIC_ROW_GAP; + +const WIDGET_TITLE_MESSAGES = { + "usage-summary": messages.usageSummaryWidget, + "system-summary": messages.systemSummaryWidget, + "active-users": messages.activeUsers, + "gateway-status": messages.gatewayStatusWidget, + memory: messages.widgetMemory, + "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: 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: "active-users#1", + title: "Active users", + w: 1, + widgetType: "active-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: METRIC_WIDGET_HEIGHT, + i: "pods#1", + title: "Pods", + w: 1, + widgetType: "pods", + x: 3, + 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: 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, + }, + { + 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 + + METRIC_ROW_STEP, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "active-users#1", + title: "Active users", + w: 1, + widgetType: "active-users", + x: 0, + y: + USAGE_SUMMARY_WIDGET_HEIGHT + + METRIC_ROW_GAP + + SYSTEM_SUMMARY_WIDGET_HEIGHT + + GATEWAY_STATUS_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 + + METRIC_ROW_STEP * 3, + }, + { + h: METRIC_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 + + METRIC_ROW_STEP * 4, + }, + ], +}; + +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 000000000..b1a8de06a --- /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 000000000..e2305da14 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/gateway-status-chart.tsx @@ -0,0 +1,212 @@ +import { ChartDonut } from "@patternfly/react-charts/victory"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useIntl, type IntlShape } from "react-intl"; + +import type { + OperationalMetric, + OperationalMetricStatus, +} from "../application/dashboard-types"; +import { messages } from "../messages"; +import "../pages/dashboard-widget.css"; + +const GATEWAY_STATUS_COLOR_VARS = { + degraded: "--hypershell-gateway-status-degraded-color", + failed: "--hypershell-gateway-status-failed-color", + provisioning: "--hypershell-gateway-status-provisioning-color", + running: "--hypershell-gateway-status-running-color", +} as const; + +const GATEWAY_STATUS_ORDER = [ + "running", + "provisioning", + "degraded", + "failed", +] as const satisfies readonly (keyof OperationalMetricStatus)[]; + +type GatewayStatusKey = (typeof GATEWAY_STATUS_ORDER)[number]; + +function readGatewayStatusColors( + element: HTMLElement | null, +): Record | undefined { + if (!element) { + return undefined; + } + + const style = getComputedStyle(element); + const colors = GATEWAY_STATUS_ORDER.reduce< + Partial> + >((acc, key) => { + const color = style.getPropertyValue(GATEWAY_STATUS_COLOR_VARS[key]).trim(); + if (!color) { + return acc; + } + acc[key] = color; + return acc; + }, {}); + + return GATEWAY_STATUS_ORDER.every((key) => colors[key] !== undefined) + ? (colors as Record) + : undefined; +} + +interface GatewayStatusDatum { + x: string; + y: number; +} + +interface GatewayStatusLegendDatum { + name: string; +} + +interface GatewayStatusMetric { + id: string; + status: OperationalMetricStatus; + value: string; +} + +/** Rendered pixel size; must match the donut wrapper and ChartDonut height. */ +const GATEWAY_STATUS_CHART_HEIGHT = 165; + +/** Extra right padding keeps the legend inside the clipped widget body. */ +const GATEWAY_STATUS_CHART_PADDING = { + bottom: 25, + left: 20, + right: 145, + top: 20, +} as const; + +export function isGatewayStatusMetric( + metric: OperationalMetric, +): metric is GatewayStatusMetric { + return metric.status !== undefined; +} + +function gatewayStatusLabel(intl: IntlShape, status: GatewayStatusKey): string { + switch (status) { + case "running": + return intl.formatMessage(messages.gatewayStatusRunning); + case "provisioning": + return intl.formatMessage(messages.gatewayStatusProvisioning); + case "degraded": + return intl.formatMessage(messages.gatewayStatusDegraded); + case "failed": + return intl.formatMessage(messages.gatewayStatusFailed); + } +} + +function buildGatewayStatusData( + intl: IntlShape, + status: OperationalMetricStatus, + colors: Record, +): { + colorScale: string[]; + data: GatewayStatusDatum[]; + legendData: GatewayStatusLegendDatum[]; +} { + const entries = GATEWAY_STATUS_ORDER.flatMap((key) => { + const count = status[key]; + if (count === undefined || count <= 0) { + return []; + } + + const label = gatewayStatusLabel(intl, key); + + return [ + { + color: colors[key], + datum: { x: label, y: count }, + legend: { + name: intl.formatMessage(messages.gatewayStatusLegend, { + count, + status: label, + }), + }, + }, + ]; + }); + + return { + colorScale: entries.map((entry) => entry.color), + data: entries.map((entry) => entry.datum), + legendData: entries.map((entry) => entry.legend), + }; +} + +export function GatewayStatusChart({ + metric, +}: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + const containerRef = useRef(null); + const [width, setWidth] = useState(275); + const [statusColors, setStatusColors] = useState< + Record | undefined + >(); + + 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); + } + setStatusColors(readGatewayStatusColors(node)); + }); + observer.observe(node); + setStatusColors(readGatewayStatusColors(node)); + + return () => { + observer.disconnect(); + }; + }, []); + + const { colorScale, data, legendData } = useMemo(() => { + if (!isGatewayStatusMetric(metric) || statusColors === undefined) { + return { colorScale: [], data: [], legendData: [] }; + } + + return buildGatewayStatusData(intl, metric.status, statusColors); + }, [intl, metric, statusColors]); + + if (!isGatewayStatusMetric(metric) || data.length === 0) { + return null; + } + + const subTitle = intl.formatMessage(messages.gateways); + + return ( +
+ + datum.x + ? intl.formatMessage(messages.gatewayStatusDataLabel, { + count: datum.y, + status: datum.x, + }) + : null + } + legendData={legendData} + legendOrientation="vertical" + legendPosition="right" + padding={GATEWAY_STATUS_CHART_PADDING} + subTitle={subTitle} + subTitlePosition="bottom" + title={metric.value} + width={width} + /> +
+ ); +} 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 000000000..0c71e724e --- /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/trend-sparkline-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx new file mode 100644 index 000000000..09395fde1 --- /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 000000000..a82f88e62 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/utilization-chart.tsx @@ -0,0 +1,135 @@ +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 "../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; + 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 000000000..c2ffca968 --- /dev/null +++ b/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts @@ -0,0 +1,97 @@ +import type { OperationalDashboardMetrics } from "../application/dashboard-types"; + +export const mockOperationalDashboardMetrics: OperationalDashboardMetrics = + Object.freeze({ + metrics: Object.freeze([ + Object.freeze({ + id: "active-users", + trend: Object.freeze({ + points: Object.freeze([ + Object.freeze({ label: "Day 1", value: 521 }), + Object.freeze({ label: "Day 2", value: 544 }), + Object.freeze({ label: "Day 3", value: 553 }), + Object.freeze({ label: "Day 4", value: 578 }), + Object.freeze({ label: "Day 5", value: 590 }), + Object.freeze({ label: "Day 6", value: 601 }), + Object.freeze({ label: "Day 7", value: 450 }), + ]), + }), + value: "450", + }), + Object.freeze({ + id: "provisioned-gateways", + status: Object.freeze({ + degraded: 6, + failed: 2, + provisioning: 9, + running: 80, + }), + trend: Object.freeze({ + points: Object.freeze([ + Object.freeze({ label: "Day 1", value: 94 }), + Object.freeze({ label: "Day 2", value: 84 }), + Object.freeze({ label: "Day 3", value: 85 }), + Object.freeze({ label: "Day 4", value: 89 }), + Object.freeze({ label: "Day 5", value: 90 }), + Object.freeze({ label: "Day 6", value: 93 }), + Object.freeze({ label: "Day 7", value: 97 }), + ]), + }), + value: "97", + }), + Object.freeze({ + id: "provisioned-sandboxes", + trend: Object.freeze({ + points: Object.freeze([ + Object.freeze({ label: "Day 1", value: 176 }), + Object.freeze({ label: "Day 2", value: 182 }), + Object.freeze({ label: "Day 3", value: 189 }), + Object.freeze({ label: "Day 4", value: 194 }), + Object.freeze({ label: "Day 5", value: 201 }), + Object.freeze({ label: "Day 6", value: 207 }), + Object.freeze({ label: "Day 7", value: 214 }), + ]), + }), + value: "214", + }), + Object.freeze({ + id: "nodes", + value: "8", + }), + Object.freeze({ + id: "cpu", + value: "48", + unit: "cores", + total: "60", + }), + Object.freeze({ + id: "memory", + value: "220", + unit: "GiB", + total: "237", + }), + Object.freeze({ + id: "pods", + value: "548", + total: "2000", + unit: "pods", + trend: Object.freeze({ + points: Object.freeze([ + Object.freeze({ label: "Day 1", value: 124 }), + Object.freeze({ label: "Day 2", value: 182 }), + Object.freeze({ label: "Day 3", value: 301 }), + Object.freeze({ label: "Day 4", value: 361 }), + Object.freeze({ label: "Day 5", value: 449 }), + Object.freeze({ label: "Day 6", value: 525 }), + Object.freeze({ label: "Day 7", value: 538 }), + ]), + }), + }), + Object.freeze({ + id: "provision-time", + value: "5.25", + unit: "minutes", + }), + ]), + 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 000000000..f5aa656d0 --- /dev/null +++ b/packages/operational-dashboard-ui/src/index.ts @@ -0,0 +1,38 @@ +export { + DashboardUiProvider, + useDashboardUi, + type DashboardUiNavigation, +} from "./dashboard-ui-provider"; +export type { + DashboardControlPlane, + DashboardInvocationContext, + DashboardOperations, + DashboardProbe, + DashboardProbeAction, + DashboardProbeName, + DashboardProbeOutcome, + DashboardProbePublisher, + DashboardWorkflowAction, + DashboardWorkflowRuntime, + OperationalDashboardMetrics, + OperationalMetric, + 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 000000000..e97171c95 --- /dev/null +++ b/packages/operational-dashboard-ui/src/messages.ts @@ -0,0 +1,295 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + activeUsers: { + id: "app.dashboard.widget.activeUsers", + defaultMessage: "Active users", + description: "Title for the active users dashboard widget.", + }, + 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: + "Monitor fleet utilization, gateway health, and operational capacity.", + 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.", + }, + 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.", + }, + 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.", + }, + gatewayStatusRunning: { + id: "app.dashboard.gatewayStatus.running", + defaultMessage: "Running", + description: "Legend label for running 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.", + }, + 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.", + }, + pods: { + id: "app.dashboard.summary.pods", + defaultMessage: "Pods", + description: "Summary label for pod utilization.", + }, + 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", + description: "Summary label for provision time.", + }, + 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.", + }, + resetToDefault: { + id: "app.dashboard.resetToDefault", + defaultMessage: "Reset to default", + description: + "Label for restoring the operational dashboard default layout.", + }, + sampleDataBannerBody: { + id: "app.dashboard.sampleData.bannerBody", + defaultMessage: + "Metrics on this page are sample data until live operational metrics are connected.", + description: + "Body for the banner shown when the operational dashboard uses mock metrics.", + }, + sampleDataBannerTitle: { + id: "app.dashboard.sampleData.bannerTitle", + defaultMessage: "Sample data", + description: + "Title for the banner shown when the operational dashboard uses mock metrics.", + }, + 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.", + }, + users: { + id: "app.dashboard.summary.users", + defaultMessage: "Users", + description: "Summary label for active users.", + }, + 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 000000000..7f091e407 --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/dashboard-widget.css @@ -0,0 +1,104 @@ +.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-gateway-status-card { + --pf-v6-c-content--MarginBottom: 0; +} + +.hypershell-dashboard-gateway-status-card .pf-v6-c-stack { + --pf-v6-c-stack--m-gutter--Gap: var(--pf-t--global--spacer--sm); +} + +.hypershell-dashboard-gateway-status-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 + ); + --hypershell-gateway-status-running-color: var( + --pf-t--global--icon--color--status--success--default + ); + /* + * 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; +} 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 000000000..3e173ffff --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx @@ -0,0 +1,525 @@ +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 { TrendSparklineChart } from "../dashboard/trend-sparkline-chart"; +import { getGatewayExceptionStatusCounts } from "../dashboard/gateway-exception-status-counts"; +import { GatewayStatusChart } from "../dashboard/gateway-status-chart"; +import { + getUtilizationPercentage, + getUtilizationStatusLevel, + isUtilizationMetric, + UtilizationChart, +} from "../dashboard/utilization-chart"; +import { messages } from "../messages"; + +function WidgetContent({ children }: Readonly) { + return ( + + {children} + + ); +} + +export function MetricCard({ + metric, + showTrend = true, + subtitle, + title, +}: Readonly<{ + metric: OperationalMetric; + showTrend?: boolean; + subtitle: string; + title: string; +}>) { + const intl = useIntl(); + + return ( + + + + + + + + {intl.formatMessage(messages.metricValue, { + label: title, + value: metric.value, + })} + + {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 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 SummaryUtilizationValue({ + metric, +}: Readonly<{ metric: OperationalMetric | undefined }>) { + const intl = useIntl(); + + if (!metric) { + return null; + } + + 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 trendChange = metric ? getMetricTrendChange(metric) : undefined; + + return ( + + {metric?.value} + {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} + + ); +} + +const USAGE_SUMMARY_METRIC_IDS = [ + "active-users", + "provisioned-gateways", + "provisioned-sandboxes", +] as const; + +const USAGE_SUMMARY_LABELS = { + "active-users": messages.users, + "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")} + /> + + + + + + + + {metrics.find((metric) => metric.id === "nodes")?.value} + + + + + + + + metric.id === "provision-time")} + /> + + + + + ); +} 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 000000000..42c5ffc70 --- /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 000000000..524acf5e1 --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx @@ -0,0 +1,572 @@ +import { + Alert, + Bullseye, + Button, + Content, + EmptyState, + EmptyStateBody, + EmptyStateVariant, + PageSection, + Spinner, + Flex, + FlexItem, + Title, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from "@patternfly/react-core"; +import { + ClusterIcon, + CubesIcon, + UsersIcon, + MicrochipIcon, + MemoryIcon, +} 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, + localizeDashboardLayoutTemplate, + SYSTEM_SUMMARY_WIDGET_HEIGHT, + USAGE_SUMMARY_WIDGET_HEIGHT, +} from "../dashboard/dashboard-layout-template"; +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, + SystemSummaryCard, + UsageSummaryCard, +} from "./dashboard-widget"; +import { useGetMetricsData } from "./get-metrics-data"; + +const baseTemplate = defaultDashboardLayoutTemplate; + +const LAYOUT_STORAGE_KEY = "hypershell.operational-dashboard.layout.v13"; +const CUSTOM_COLUMNS: Record = { + xl: 4, + lg: 4, + md: 4, + sm: 1, +}; + +function isValidSavedTemplate(savedTemplate: ExtendedTemplateConfig): boolean { + return (Object.keys(baseTemplate) as Variants[]).every((variant) => + Array.isArray(savedTemplate[variant]), + ); +} + +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]; +} + +function getAddedWidgetTypes( + currentTemplate: ExtendedTemplateConfig, + nextTemplate: ExtendedTemplateConfig, +): string[] { + const currentTypes = new Set(getActiveWidgetTypes(currentTemplate)); + + return getActiveWidgetTypes(nextTemplate).filter( + (type) => !currentTypes.has(type), + ); +} + +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); +} + +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)) { + 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" | "status" | "utilization", + ) => { + const metric = metricById.get(metricId); + const title = intl.formatMessage(titleMessage); + + if (!metric) { + return ( + + + + <FormattedMessage {...messages.metricUnavailableTitle} /> + + + + + + + ); + } + + if (metricType === "metric") { + return ; + } + + if (metricType === "status") { + 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: () => , + }, + "active-users": { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.activeUsers), + }, + renderWidget: () => + renderMetric("active-users", "", messages.activeUsers, "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, + "status", + ), + }, + "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: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.widgetPods), + }, + renderWidget: () => + renderMetric("pods", "", messages.widgetPods, "utilization"), + }, + }; +} + +export interface OperationalDashboardPageProps { + metrics?: OperationalDashboardMetrics; + title?: string; +} + +export function OperationalDashboardPage({ + metrics, + title, +}: Readonly) { + const intl = useIntl(); + const { probes = noopDashboardProbePublisher, usesSampleData = false } = + 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} +
+ {usesSampleData ? ( + + + + ) : 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 000000000..9f65a38ff --- /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 000000000..01917c1b1 --- /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 ( +