diff --git a/.dockerignore b/.dockerignore index f1019c59..0a3e8c56 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 8107c875..b5c6487b 100644 --- a/.github/component-paths.json +++ b/.github/component-paths.json @@ -57,6 +57,20 @@ ".github/workflows/lint.yml" ] }, + "operational_dashboard_ui": { + "directory": "packages/operational-dashboard-ui", + "lint_job": "lint-operational-dashboard-ui", + "paths": [ + "packages/operational-dashboard-ui/**", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "Makefile", + ".github/component-paths.json", + ".github/scripts/detect-components.sh", + ".github/workflows/lint.yml" + ] + }, "e2e": { "directory": "tests/e2e", "paths": [ @@ -112,6 +126,7 @@ "paths": [ "components/web-console/**", "packages/gateway-management-ui/**", + "packages/operational-dashboard-ui/**", "components/sdk-typescript/**", "package.json", "pnpm-lock.yaml", diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6f8b14ea..a39bf6be 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,6 +26,7 @@ jobs: cli: ${{ steps.detect.outputs.cli }} control_plane: ${{ steps.detect.outputs.control_plane }} gateway_management_ui: ${{ steps.detect.outputs.gateway_management_ui }} + operational_dashboard_ui: ${{ steps.detect.outputs.operational_dashboard_ui }} pr_test: ${{ steps.detect.outputs.pr_test }} sdk_typescript: ${{ steps.detect.outputs.sdk_typescript }} web_console: ${{ steps.detect.outputs.web_console }} @@ -189,6 +190,25 @@ jobs: - name: Run gateway management UI package quality gates run: pnpm --filter @openshift-online/hypershell-gateway-management-ui check + lint-operational-dashboard-ui: + name: Operational dashboard UI package quality gates + needs: detect-changes + if: needs.detect-changes.outputs.operational_dashboard_ui == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: .node-version + - name: Install pinned pnpm + run: bash scripts/bootstrap_pnpm.sh + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Run operational dashboard UI package quality gates + run: pnpm --filter @openshift-online/hypershell-operational-dashboard-ui check + lint-pr-test: name: PR test script validation needs: detect-changes @@ -254,6 +274,7 @@ jobs: - lint-cli - lint-control-plane - lint-gateway-management-ui + - lint-operational-dashboard-ui - lint-pr-test - lint-sdk-typescript - lint-web-console @@ -266,11 +287,12 @@ jobs: CLI_RESULT: ${{ needs.lint-cli.result }} CONTROL_PLANE_RESULT: ${{ needs.lint-control-plane.result }} GATEWAY_MANAGEMENT_UI_RESULT: ${{ needs.lint-gateway-management-ui.result }} + OPERATIONAL_DASHBOARD_UI_RESULT: ${{ needs.lint-operational-dashboard-ui.result }} PR_TEST_RESULT: ${{ needs.lint-pr-test.result }} SDK_TYPESCRIPT_RESULT: ${{ needs.lint-sdk-typescript.result }} WEB_CONSOLE_RESULT: ${{ needs.lint-web-console.result }} run: | - for result in "${DETECTION_RESULT}" "${API_SERVER_RESULT}" "${CLI_RESULT}" "${CONTROL_PLANE_RESULT}" "${GATEWAY_MANAGEMENT_UI_RESULT}" "${PR_TEST_RESULT}" "${SDK_TYPESCRIPT_RESULT}" "${WEB_CONSOLE_RESULT}"; do + for result in "${DETECTION_RESULT}" "${API_SERVER_RESULT}" "${CLI_RESULT}" "${CONTROL_PLANE_RESULT}" "${GATEWAY_MANAGEMENT_UI_RESULT}" "${OPERATIONAL_DASHBOARD_UI_RESULT}" "${PR_TEST_RESULT}" "${SDK_TYPESCRIPT_RESULT}" "${WEB_CONSOLE_RESULT}"; do if [[ "${result}" == failure || "${result}" == cancelled ]]; then echo "One or more lint jobs failed or were cancelled." exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 6f1764bf..2ed25f52 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 @@ -97,6 +98,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 61d8f4bb..3ffe9c12 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 00000000..c1dcea8d --- /dev/null +++ b/components/web-console/app/adapters/mock/dashboard-control-plane.ts @@ -0,0 +1,20 @@ +import type { + DashboardControlPlane, + DashboardInvocationContext, +} from "@openshift-online/hypershell-operational-dashboard-ui"; +import { mockOperationalDashboardMetrics } from "@openshift-online/hypershell-operational-dashboard-ui/fixtures"; + +export function createMockDashboardControlPlane(): DashboardControlPlane { + return { + async getOperationalMetrics(context: DashboardInvocationContext) { + context.signal?.throwIfAborted(); + + await new Promise((resolve) => setTimeout(resolve, 2000)); // This is just for demos for now + + return { + ...mockOperationalDashboardMetrics, + lastSuccessfulRefresh: new Date(), + }; + }, + }; +} diff --git a/components/web-console/app/composition/dashboard-composition.ts b/components/web-console/app/composition/dashboard-composition.ts new file mode 100644 index 00000000..896e0bb3 --- /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 00000000..5a414936 --- /dev/null +++ b/components/web-console/app/features/dashboard/operational-dashboard.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + createDashboardOperations, + DashboardUiProvider, + OperationalDashboardPage, + 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 { 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(), +}); + +function DashboardPreview({ + metrics, + useQuery = false, +}: Readonly<{ + metrics?: typeof mockOperationalDashboardMetrics; + useQuery?: boolean; +}>) { + 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 InShell: Story = { + render: () => , +}; + +export const PseudoLocalized: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +export const RightToLeft: Story = { + decorators: [ + (StoryComponent) => ( +
+ + + +
+ ), + ], +}; diff --git a/components/web-console/app/features/dashboard/require-dashboard-admin.test.tsx b/components/web-console/app/features/dashboard/require-dashboard-admin.test.tsx new file mode 100644 index 00000000..16f9c537 --- /dev/null +++ b/components/web-console/app/features/dashboard/require-dashboard-admin.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { IntlProvider } from "react-intl"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { englishMessages } from "../../i18n/catalog"; + +const { getSessionMock } = vi.hoisted(() => ({ + getSessionMock: vi.fn(), +})); + +vi.mock("../../composition/session-composition", () => ({ + sessionGateway: { getSession: getSessionMock }, +})); + +import { RequireDashboardAdmin } from "./require-dashboard-admin"; + +function renderGuard() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + +
+ + + , + ); +} + +describe("RequireDashboardAdmin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders children when the session is unauthenticated", async () => { + getSessionMock.mockResolvedValue({ authenticated: false, roles: [] }); + + renderGuard(); + + expect(await screen.findByTestId("dashboard-content")).toBeTruthy(); + }); + + it("renders children for hypershell-admins", async () => { + getSessionMock.mockResolvedValue({ + authenticated: true, + roles: ["hypershell-admins"], + }); + + renderGuard(); + + expect(await screen.findByTestId("dashboard-content")).toBeTruthy(); + }); + + it("renders children for platform:admin", async () => { + getSessionMock.mockResolvedValue({ + authenticated: true, + roles: ["platform:admin"], + }); + + renderGuard(); + + expect(await screen.findByTestId("dashboard-content")).toBeTruthy(); + }); + + it("shows access denied for authenticated non-admin users", async () => { + getSessionMock.mockResolvedValue({ + authenticated: true, + roles: ["hypershell-users"], + }); + + renderGuard(); + + expect( + await screen.findByRole("heading", { name: "Access denied" }), + ).toBeTruthy(); + expect(screen.queryByTestId("dashboard-content")).toBeNull(); + }); +}); diff --git a/components/web-console/app/features/dashboard/require-dashboard-admin.tsx b/components/web-console/app/features/dashboard/require-dashboard-admin.tsx new file mode 100644 index 00000000..3ccb7790 --- /dev/null +++ b/components/web-console/app/features/dashboard/require-dashboard-admin.tsx @@ -0,0 +1,51 @@ +import { + EmptyState, + EmptyStateBody, + EmptyStateVariant, + PageSection, + Spinner, +} from "@patternfly/react-core"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { messages } from "../../i18n/messages"; +import { hasDashboardAdminRole } from "../../lib/session-roles"; +import { useSession } from "../shell/use-session"; + +export function RequireDashboardAdmin({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const intl = useIntl(); + const { data: session, isLoading } = useSession(); + + if (isLoading) { + return ( + + + + ); + } + + if (session?.authenticated && !hasDashboardAdminRole(session.roles)) { + return ( + + + } + headingLevel="h1" + > + + + + + + ); + } + + return children; +} diff --git a/components/web-console/app/features/shell/application-shell.tsx b/components/web-console/app/features/shell/application-shell.tsx index 242a80ba..73d6f89b 100644 --- a/components/web-console/app/features/shell/application-shell.tsx +++ b/components/web-console/app/features/shell/application-shell.tsx @@ -20,11 +20,16 @@ import { GatewayUiProvider, type GatewayUiNavigation, } from "@openshift-online/hypershell-gateway-management-ui"; +import { + DashboardUiProvider, + type DashboardUiNavigation, +} from "@openshift-online/hypershell-operational-dashboard-ui"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Link, Outlet, useLocation, useNavigate } from "react-router"; +import { dashboardOperations } from "../../composition/dashboard-composition"; import { gatewayOperations } from "../../composition/gateway-composition"; import { messages } from "../../i18n/messages"; import productLogo from "../../../../../images/brand/logo.png"; @@ -46,6 +51,13 @@ export function ApplicationShell() { }), [navigate], ); + const dashboardNavigation = useMemo( + () => ({ + collectionHref: "/", + navigate: (href) => navigate(href), + }), + [navigate], + ); const { scheme, toggle: toggleColorScheme } = useColorScheme(); useRouteHeadingFocus(pathname); const segments = pathname.split("/").filter(Boolean); @@ -131,19 +143,24 @@ export function ApplicationShell() { } return ( - - - - - + + + + + ); } diff --git a/components/web-console/app/i18n/messages.ts b/components/web-console/app/i18n/messages.ts index 9d8d1c2a..6b6fba7f 100644 --- a/components/web-console/app/i18n/messages.ts +++ b/components/web-console/app/i18n/messages.ts @@ -11,6 +11,36 @@ export const messages = defineMessages({ defaultMessage: "Breadcrumb", description: "Accessible label for the application breadcrumb navigation.", }, + dashboardNav: { + id: "app.nav.dashboard", + defaultMessage: "Operational dashboard", + description: "Page and navigation label for the HyperShell dashboard.", + }, + dashboardPageDescription: { + id: "app.page.dashboard.description", + defaultMessage: + "Operational metrics dashboard for HyperShell adoption and provisioned resources.", + description: + "Browser metadata description for the operational dashboard page.", + }, + dashboardAccessDeniedBody: { + id: "app.page.dashboard.accessDenied.body", + defaultMessage: + "The operational dashboard is available only to HyperShell administrators.", + description: + "Recovery guidance shown when a signed-in user lacks the admin role for the dashboard.", + }, + dashboardAccessDeniedTitle: { + id: "app.page.dashboard.accessDenied.title", + defaultMessage: "Access denied", + description: + "Heading shown when a signed-in user lacks the admin role for the dashboard.", + }, + sessionLoadingLabel: { + id: "app.session.loading.ariaLabel", + defaultMessage: "Loading session", + description: "Accessible label for the session loading spinner.", + }, errorBody: { id: "app.error.body", defaultMessage: "Refresh the page to try again.", diff --git a/components/web-console/app/lib/session-roles.test.ts b/components/web-console/app/lib/session-roles.test.ts new file mode 100644 index 00000000..7b5f728d --- /dev/null +++ b/components/web-console/app/lib/session-roles.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { hasDashboardAdminRole } from "./session-roles"; + +describe("hasDashboardAdminRole", () => { + it("returns true when hypershell-admins is present", () => { + expect( + hasDashboardAdminRole(["hypershell-users", "hypershell-admins"]), + ).toBe(true); + }); + + it("returns true when platform:admin is present", () => { + expect(hasDashboardAdminRole(["hypershell-users", "platform:admin"])).toBe( + true, + ); + }); + + it("returns false for non-admin roles", () => { + expect(hasDashboardAdminRole(["hypershell-users", "gateway:creator"])).toBe( + false, + ); + }); +}); diff --git a/components/web-console/app/lib/session-roles.ts b/components/web-console/app/lib/session-roles.ts new file mode 100644 index 00000000..dbe38a25 --- /dev/null +++ b/components/web-console/app/lib/session-roles.ts @@ -0,0 +1,14 @@ +/** Keycloak realm role for HyperShell administrators. */ +export const HYPERSHELL_ADMIN_ROLE = "hypershell-admins"; + +/** Keycloak realm role for platform-wide administration. */ +export const PLATFORM_ADMIN_ROLE = "platform:admin"; + +const DASHBOARD_ADMIN_ROLES = new Set([ + HYPERSHELL_ADMIN_ROLE, + PLATFORM_ADMIN_ROLE, +]); + +export function hasDashboardAdminRole(roles: readonly string[]): boolean { + return roles.some((role) => DASHBOARD_ADMIN_ROLES.has(role)); +} diff --git a/components/web-console/app/routes.ts b/components/web-console/app/routes.ts index c20aa10f..87d024cb 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 00000000..a10096af --- /dev/null +++ b/components/web-console/app/routes/dashboard.tsx @@ -0,0 +1,17 @@ +import { OperationalDashboardPage } from "@openshift-online/hypershell-operational-dashboard-ui"; + +import { RequireDashboardAdmin } from "../features/dashboard/require-dashboard-admin"; +import { createPageMeta } from "../lib/page-meta"; + +export const meta = createPageMeta( + "app.nav.dashboard", + "app.page.dashboard.description", +); + +export default function DashboardRoute() { + return ( + + + + ); +} diff --git a/components/web-console/app/routes/home.tsx b/components/web-console/app/routes/home.tsx index bd342380..904d955b 100644 --- a/components/web-console/app/routes/home.tsx +++ b/components/web-console/app/routes/home.tsx @@ -1,6 +1,8 @@ import { GatewaysPage } from "@openshift-online/hypershell-gateway-management-ui"; +import { OperationalDashboardPage } from "@openshift-online/hypershell-operational-dashboard-ui"; import { useLocation, useNavigate, useSearchParams } from "react-router"; +import { RequireDashboardAdmin } from "../features/dashboard/require-dashboard-admin"; import { parseGatewayListState, serializeGatewayListState, @@ -16,6 +18,14 @@ export default function HomeRoute() { const location = useLocation(); const navigate = useNavigate(); const [searchParameters, setSearchParameters] = useSearchParams(); + const hostname = globalThis.location.hostname; + if (hostname === "dashboard.hypershell.localhost") { + 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 243113c3..c496b9b0 100644 --- a/components/web-console/bff/src/app.ts +++ b/components/web-console/bff/src/app.ts @@ -5,9 +5,14 @@ import path from "node:path"; import compress from "@fastify/compress"; import helmet from "@fastify/helmet"; import fastifyStatic from "@fastify/static"; -import Fastify, { type FastifyInstance, LogController } from "fastify"; +import Fastify, { + type FastifyInstance, + type FastifyRequest, + LogController, +} from "fastify"; import { clearSession, persistTokenSet, registerAuth } from "./auth.js"; +import { hasDashboardAdminRole } from "./roles.js"; import { browserRuntimeConfig, type BrowserRuntimeConfig, @@ -47,12 +52,48 @@ declare module "fastify" { function isApplicationRoute(pathname: string): boolean { return ( pathname === "/" || + pathname === "/dashboard" || pathname === "/login" || pathname === "/gateways/new" || /^\/gateways\/[^/]+\/?$/u.test(pathname) ); } +const DASHBOARD_HOST_PREFIX = "dashboard."; + +function isDashboardHost(hostHeader: string | undefined): boolean { + if (typeof hostHeader !== "string") { + return false; + } + + const host = hostHeader.split(":")[0] ?? ""; + return host.startsWith(DASHBOARD_HOST_PREFIX); +} + +function consoleRedirectForDashboardHost(request: FastifyRequest): string { + const hostHeader = request.headers.host ?? ""; + const [, port] = hostHeader.split(":"); + const host = hostHeader.split(":")[0] ?? ""; + const portSuffix = port ? `:${port}` : ""; + + if (host.startsWith(DASHBOARD_HOST_PREFIX)) { + const rest = host.slice(DASHBOARD_HOST_PREFIX.length); + return `${request.protocol}://console.${rest}${portSuffix}/`; + } + + return "/"; +} + +function requiresDashboardAdminAccess( + pathname: string, + hostHeader: string | undefined, +): boolean { + return ( + pathname === "/dashboard" || + (pathname === "/" && isDashboardHost(hostHeader)) + ); +} + function proxyBody( method: string, body: unknown, @@ -286,6 +327,17 @@ export async function buildApp( reply.redirect("/auth/login"); return; } + if ( + requiresDashboardAdminAccess(pathname, request.headers.host) && + !hasDashboardAdminRole(request.session.get("roles") ?? []) + ) { + reply.redirect( + pathname === "/" && isDashboardHost(request.headers.host) + ? consoleRedirectForDashboardHost(request) + : "/", + ); + return; + } } }); } @@ -315,12 +367,26 @@ export async function buildApp( }); const sendApplication = ( - _request: unknown, + request: FastifyRequest, reply: { header(name: string, value: string): unknown; + redirect(location: string): unknown; type(value: string): { send(payload: string): unknown }; }, ) => { + const pathname = new URL(request.url, "http://bff.invalid").pathname; + if ( + config.oidcIssuer && + requiresDashboardAdminAccess(pathname, request.headers.host) && + !hasDashboardAdminRole(request.session.get("roles") ?? []) + ) { + return reply.redirect( + pathname === "/" && isDashboardHost(request.headers.host) + ? consoleRedirectForDashboardHost(request) + : "/", + ); + } + reply.header("Cache-Control", "no-store"); return reply.type("text/html; charset=utf-8").send(indexDocument); }; diff --git a/components/web-console/bff/src/auth-roles.test.ts b/components/web-console/bff/src/auth-roles.test.ts new file mode 100644 index 00000000..4b49a2ed --- /dev/null +++ b/components/web-console/bff/src/auth-roles.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { extractRealmRoles } from "./auth.js"; + +describe("extractRealmRoles", () => { + it("reads roles from the roles claim", () => { + expect( + extractRealmRoles({ + roles: ["hypershell-admins", "hypershell-users"], + }), + ).toEqual(["hypershell-admins", "hypershell-users"]); + }); + + it("reads roles from the groups claim used by Keycloak", () => { + expect( + extractRealmRoles({ + groups: ["hypershell-admins", "hypershell-users"], + }), + ).toEqual(["hypershell-admins", "hypershell-users"]); + }); + + it("ignores non-string role entries", () => { + expect( + extractRealmRoles({ + roles: ["hypershell-admins", 42, null], + }), + ).toEqual(["hypershell-admins"]); + }); +}); diff --git a/components/web-console/bff/src/auth.ts b/components/web-console/bff/src/auth.ts index bf53deb5..5fb62053 100644 --- a/components/web-console/bff/src/auth.ts +++ b/components/web-console/bff/src/auth.ts @@ -65,6 +65,19 @@ export function persistTokenSet( } } +/** Reads realm roles from standard OIDC claim shapes. */ +export function extractRealmRoles(claims: Record): string[] { + for (const claimName of ["roles", "groups"] as const) { + const rawRoles = claims[claimName]; + if (Array.isArray(rawRoles)) { + return rawRoles.filter( + (role): role is string => typeof role === "string", + ); + } + } + return []; +} + /** Clears both session cookies on terminal authentication failure. */ export function clearSession(request: { session: secureSession.Session; @@ -223,11 +236,7 @@ export async function registerAuth( if (typeof claims.name === "string") { request.session.set("name", claims.name); } - const rawRoles = claims.roles; - const roles = Array.isArray(rawRoles) - ? rawRoles.filter((r): r is string => typeof r === "string") - : []; - request.session.set("roles", roles); + request.session.set("roles", extractRealmRoles(claims)); } request.session.options({ maxAge: config.sessionTtlSeconds }); diff --git a/components/web-console/bff/src/roles.test.ts b/components/web-console/bff/src/roles.test.ts new file mode 100644 index 00000000..0e900ca9 --- /dev/null +++ b/components/web-console/bff/src/roles.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { hasDashboardAdminRole } from "./roles.js"; + +describe("hasDashboardAdminRole", () => { + it("returns true when hypershell-admins is present", () => { + expect( + hasDashboardAdminRole(["hypershell-users", "hypershell-admins"]), + ).toBe(true); + }); + + it("returns true when platform:admin is present", () => { + expect(hasDashboardAdminRole(["hypershell-users", "platform:admin"])).toBe( + true, + ); + }); + + it("returns false for non-admin roles", () => { + expect(hasDashboardAdminRole(["hypershell-users", "gateway:creator"])).toBe( + false, + ); + }); +}); diff --git a/components/web-console/bff/src/roles.ts b/components/web-console/bff/src/roles.ts new file mode 100644 index 00000000..dbe38a25 --- /dev/null +++ b/components/web-console/bff/src/roles.ts @@ -0,0 +1,14 @@ +/** Keycloak realm role for HyperShell administrators. */ +export const HYPERSHELL_ADMIN_ROLE = "hypershell-admins"; + +/** Keycloak realm role for platform-wide administration. */ +export const PLATFORM_ADMIN_ROLE = "platform:admin"; + +const DASHBOARD_ADMIN_ROLES = new Set([ + HYPERSHELL_ADMIN_ROLE, + PLATFORM_ADMIN_ROLE, +]); + +export function hasDashboardAdminRole(roles: readonly string[]): boolean { + return roles.some((role) => DASHBOARD_ADMIN_ROLES.has(role)); +} diff --git a/components/web-console/bff/test/auth.test.ts b/components/web-console/bff/test/auth.test.ts index 43212dce..a2939eb9 100644 --- a/components/web-console/bff/test/auth.test.ts +++ b/components/web-console/bff/test/auth.test.ts @@ -783,13 +783,69 @@ 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"); } }); + it("redirects non-admin users away from /dashboard", async () => { + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-users"], + }); + const cookie = `session=${encodeURIComponent(app.encodeSecureSession(session))}`; + const response = await app.inject({ + headers: { cookie }, + method: "GET", + url: "/dashboard", + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe("/"); + }); + + it("serves /dashboard to hypershell-admins", async () => { + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["hypershell-admins"], + }); + const cookie = `session=${encodeURIComponent(app.encodeSecureSession(session))}`; + const response = await app.inject({ + headers: { cookie }, + method: "GET", + url: "/dashboard", + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("text/html"); + }); + + it("serves /dashboard to platform:admin", async () => { + const session = app.createSecureSession({ + accessToken: "test-access-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + roles: ["platform:admin"], + }); + const cookie = `session=${encodeURIComponent(app.encodeSecureSession(session))}`; + const response = await app.inject({ + headers: { cookie }, + method: "GET", + url: "/dashboard", + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("text/html"); + }); + it("serves application routes when authenticated", async () => { const cookie = authenticateSession(); const response = await app.inject({ diff --git a/components/web-console/domain-probes/package.json b/components/web-console/domain-probes/package.json index 71d9e864..fadcb68b 100644 --- a/components/web-console/domain-probes/package.json +++ b/components/web-console/domain-probes/package.json @@ -5,15 +5,15 @@ "description": "Typed domain-probe contracts and fan-out delivery for HyperShell web runtimes", "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 377c6449..906889b5 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -27,6 +27,142 @@ "defaultMessage": "Copy", "description": "Tooltip for a button that copies text to the clipboard." }, + "app.dashboard.description": { + "defaultMessage": "This dashboard is in development and is currently using mocked data.", + "description": "Supporting text on the operational dashboard page." + }, + "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.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.sandboxes": { + "defaultMessage": "Sandboxes", + "description": "Summary label for provisioned sandboxes." + }, + "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.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.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.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.memory": { + "defaultMessage": "Memory", + "description": "Title for the memory utilization dashboard widget." + }, + "app.dashboard.widget.namespaces": { + "defaultMessage": "Namespaces", + "description": "Title for the namespaces 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.provisionedSandboxes": { + "defaultMessage": "Provisioned sandboxes", + "description": "Title for the provisioned sandboxes dashboard widget." + }, + "app.dashboard.widget.summary": { + "defaultMessage": "Summary", + "description": "Title for the operational dashboard summary widget." + }, "app.error.body": { "defaultMessage": "Refresh the page to try again.", "description": "Recovery guidance shown after an unexpected route failure." @@ -647,6 +783,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 +803,18 @@ "defaultMessage": "Notifications", "description": "Accessible label for transient application notifications." }, + "app.page.dashboard.accessDenied.body": { + "defaultMessage": "The operational dashboard is available only to HyperShell administrators.", + "description": "Recovery guidance shown when a signed-in user lacks the admin role for the dashboard." + }, + "app.page.dashboard.accessDenied.title": { + "defaultMessage": "Access denied", + "description": "Heading shown when a signed-in user lacks the admin role for the dashboard." + }, + "app.page.dashboard.description": { + "defaultMessage": "Operational metrics dashboard for HyperShell adoption and provisioned resources.", + "description": "Browser metadata description for the operational dashboard page." + }, "app.page.gateway.description": { "defaultMessage": "Connect to this gateway and review its configuration.", "description": "Metadata description for the gateway detail page." @@ -719,6 +871,10 @@ "defaultMessage": "HyperShell", "description": "HyperShell product name." }, + "app.session.loading.ariaLabel": { + "defaultMessage": "Loading session", + "description": "Accessible label for the session loading spinner." + }, "app.skipToContent": { "defaultMessage": "Skip to content", "description": "Accessibility link that moves focus to the main page content." diff --git a/components/web-console/package.json b/components/web-console/package.json index d35afefb..ca2ddcf1 100644 --- a/components/web-console/package.json +++ b/components/web-console/package.json @@ -10,8 +10,8 @@ "check": "pnpm run format:check && pnpm run architecture:check && pnpm run lint && pnpm run typecheck && pnpm run test:run && pnpm run i18n:check && pnpm run build && pnpm run build:storybook", "dev": "react-router dev", "format:check": "prettier --check .", - "i18n:check": "formatjs extract 'app/**/*.{ts,tsx}' '../../packages/gateway-management-ui/src/**/*.{ts,tsx}' --out-file /tmp/hypershell-web-console-en.json && cmp locales/en.json /tmp/hypershell-web-console-en.json", - "i18n:extract": "formatjs extract 'app/**/*.{ts,tsx}' '../../packages/gateway-management-ui/src/**/*.{ts,tsx}' --out-file locales/en.json", + "i18n:check": "formatjs extract 'app/**/*.{ts,tsx}' '../../packages/gateway-management-ui/src/**/*.{ts,tsx}' '../../packages/operational-dashboard-ui/src/**/*.{ts,tsx}' --out-file /tmp/hypershell-web-console-en.json && cmp locales/en.json /tmp/hypershell-web-console-en.json", + "i18n:extract": "formatjs extract 'app/**/*.{ts,tsx}' '../../packages/gateway-management-ui/src/**/*.{ts,tsx}' '../../packages/operational-dashboard-ui/src/**/*.{ts,tsx}' --out-file locales/en.json", "lint": "eslint . --max-warnings=0", "storybook": "STORYBOOK=true storybook dev --port 6006 --no-open", "test": "vitest", @@ -24,6 +24,7 @@ "dependencies": { "@openshift-online/hypershell-domain-probes": "workspace:0.0.0", "@openshift-online/hypershell-gateway-management-ui": "workspace:0.0.0", + "@openshift-online/hypershell-operational-dashboard-ui": "workspace:0.0.0", "@openshift-online/hypershell-sdk": "workspace:0.0.0", "@opentelemetry/api": "1.9.1", "@opentelemetry/core": "2.10.0", @@ -31,9 +32,11 @@ "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0", "@opentelemetry/semantic-conventions": "1.43.0", + "@patternfly/react-charts": "8.6.1", "@patternfly/react-core": "6.6.0", "@patternfly/react-icons": "6.6.0", "@patternfly/react-table": "6.6.0", + "@patternfly/widgetized-dashboard": "1.0.0-prerelease.6", "@react-router/node": "8.3.0", "@tanstack/react-query": "5.101.4", "isbot": "5.2.1", @@ -42,6 +45,11 @@ "react-hook-form": "7.82.0", "react-intl": "10.1.18", "react-router": "8.3.0", + "victory-area": "37.3.6", + "victory-core": "37.3.6", + "victory-group": "37.3.6", + "victory-tooltip": "37.3.6", + "victory-voronoi-container": "37.3.6", "web-vitals": "5.3.0", "zod": "4.4.3" }, diff --git a/components/web-console/route-contract.json b/components/web-console/route-contract.json index 3ac15da3..6073cb21 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 f2c8d8f0..bd967534 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "pnpm": ">=11.15.1" }, "scripts": { - "build:web": "pnpm --filter @openshift-online/hypershell-sdk build && pnpm --filter @openshift-online/hypershell-domain-probes build && pnpm --filter @openshift-online/hypershell-gateway-management-ui build && pnpm --filter @openshift-online/hypershell-web-console build && pnpm --filter @openshift-online/hypershell-web-console-bff build", - "check:web": "pnpm --filter @openshift-online/hypershell-sdk check && pnpm --filter @openshift-online/hypershell-sdk build && pnpm --filter @openshift-online/hypershell-domain-probes check && pnpm --filter @openshift-online/hypershell-domain-probes build && pnpm --filter @openshift-online/hypershell-gateway-management-ui check && pnpm --filter @openshift-online/hypershell-web-console check && pnpm --filter @openshift-online/hypershell-web-console-bff check", + "build:web": "pnpm --filter @openshift-online/hypershell-sdk build && pnpm --filter @openshift-online/hypershell-domain-probes build && pnpm --filter @openshift-online/hypershell-gateway-management-ui build && pnpm --filter @openshift-online/hypershell-operational-dashboard-ui build && pnpm --filter @openshift-online/hypershell-web-console build && pnpm --filter @openshift-online/hypershell-web-console-bff build", + "check:web": "pnpm --filter @openshift-online/hypershell-sdk check && pnpm --filter @openshift-online/hypershell-sdk build && pnpm --filter @openshift-online/hypershell-domain-probes check && pnpm --filter @openshift-online/hypershell-domain-probes build && pnpm --filter @openshift-online/hypershell-gateway-management-ui check && pnpm --filter @openshift-online/hypershell-operational-dashboard-ui check && pnpm --filter @openshift-online/hypershell-web-console check && pnpm --filter @openshift-online/hypershell-web-console-bff check", "dev": "pnpm --filter @openshift-online/hypershell-web-console dev", "dev:bff": "pnpm --filter @openshift-online/hypershell-web-console-bff dev", "test:e2e": "pnpm --filter @openshift-online/hypershell-web-console test:e2e", diff --git a/packages/gateway-management-ui/plan.md b/packages/gateway-management-ui/plan.md new file mode 100644 index 00000000..54513369 --- /dev/null +++ b/packages/gateway-management-ui/plan.md @@ -0,0 +1,187 @@ +# Implementation Plan: Update Daily Note - Jira + +**Branch**: `004-daily-jira-items` | **Date**: 2026-08-20 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/004-daily-jira-items/spec.md` + +**Note**: Stack and testing per Constitution **Principles VII–IX** (Jest, Node.js script stack, +skill quality gates). Reuse `.agents/scripts/lib/load-config.js`, `resolve-date.js`, and +section/placeholder patterns from features 001–003. New skill at `.agents/skills/` for +`#question-awaiting-reply` only. Jira Cloud REST API v3 via built-in `fetch` (zero runtime npm +deps); auth from env vars `JIRA_SITE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`. + +## Summary + +Deliver a Node.js CLI (`sync-daily-jira-items.js`) that updates the jira-automation region in an +**existing** daily note: resolve date (default today), verify **Jira auth env**, load issues via +**Jira REST API v3** (normal HTTP calls, not MCP), run a **fetch → classify → apply** pipeline, +merge lines by issue key (never remove `- [x]` or manual content), remove configured placeholders +only when inserting items, persist comment classification state in `.agents/state/daily-jira-items.json`, +and optionally apply `#question-awaiting-reply` via a semver skill. Auth failure **stops +immediately** with stderr alert - no fetch, no note writes. Script completes without the skill if +question classification fails. + +## Technical Context + +**Stack**: Constitution Principles VII–VIII (default; no deviations). External access: **Jira Cloud +REST API v3** via Node built-in `fetch` - **sole** source for Jira data (issues, comments, viewer +identity via `/rest/api/3/myself`). + +**Jira access**: + +- **Env vars** (required for Basic auth v1): + - `JIRA_SITE_URL` - e.g. `https://yoursite.atlassian.net` (trailing slash stripped) + - `JIRA_EMAIL` - Atlassian account email + - `JIRA_API_TOKEN` - API token from Atlassian account settings +- **Optional**: `JIRA_OAUTH_TOKEN` - Bearer auth if set (legacy Bob supported; Basic preferred when + token pair is set) +- **Single account**: operator uses one Jira login; no multi-account switching in v1 +- **Auth gate**: verify all required env vars **before** reading the note or calling Jira. On + failure → stderr alert (list missing vars), exit 1, note unchanged +- **Network**: live Jira calls require outbound HTTPS; may need to run **outside a sandbox** + (Cursor agent sandbox blocks arbitrary hosts) + +**Storage**: + +- Input: `configs/daily_template.json` (jira region), existing daily note, Jira Cloud via REST +- State: `.agents/state/daily-jira-items.json` (`lastCommentId`, `classified`, `lastUnresolved` + per issue key) +- Output: in-place update to resolved daily note path (`configs/daily_template.json.file_name`) + +**Config-driven section mapping** (see [spec.md § Config-driven Jira section mapping](./spec.md#config-driven-jira-section-mapping)): + +| Config key | Role in sync | +| -------------------- | ---------------------------------------------------------------------- | +| `automation: "jira"` | Node is the sync target; all other sections untouched | +| `title` | Locate region in note by markdown heading at depth `2 + nesting level` | +| `placeholders[]` | Removable stub lines (FR-009) | + +Single flat list - no sub-buckets (unlike github's four regions). + +**Fetch / apply pipeline** (internal; mirrors legacy Bob `sync.js fetch|apply`): + +| Phase | Output | +| ------------ | ------------------------------------------------------------------------------------------------------- | +| **fetch** | `matched[]`, `pendingClassification[]`, `resolvedPings[]`, `carryUnresolvedTag[]`, `commentCursors{}` | +| **classify** | `classifications[]` as `{ key, commentId, isQuestion }` only (skill or `--no-skill-classify`) | +| **apply** | merge note, write state; advance `lastCommentId` only when all pending comments for that key classified | + +**Testing**: Jest unit tests for inclusion rules, merge, placeholder removal, line formatting, +state cursor logic, ADF→text ping detection (in-memory only); manual validation via +[quickstart.md](./quickstart.md) **with network outside sandbox** + +**Target Platform**: macOS vault workspace; vault root as cwd + +**Project Type**: CLI script + one companion skill + +**Performance Goals**: Under 30 seconds for ~100 candidate issues/comments (SC-005) + +**Constraints**: + +- Zero runtime npm packages; `fetch` + `node:fs` for all Jira.com access +- Auth failure is fatal - alert user and exit; no partial sync or silent skip +- Section headings from config only (Principle VI) +- `- [x]` lines never removed or modified (FR-012) +- Skill under `.agents/skills/classify-jira-question/`; pass SkillSpector + skillsaw (Principle IX); register the skill path in `.github/component-paths.json` and update `.github/workflows/lint.yml` via `/maintain-ci` when the skill is added +- Skill authored with `kim-write-for-llm`; concise LLM-oriented prose +- Title/URL never from classification payload (FR-014) + +**Scale/Scope**: One jira region; six reason tags; state file grows with seen issue keys; +`configs/daily_template.json` already defines `automation: "jira"` on the Jira section + +## Constitution Check + +_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ + +| Principle | Requirement | Status | +| ---------------------------- | -------------------------------------------------------- | ----------------- | +| I. Obsidian Vault Native | Markdown note updates in vault paths | ✅ PASS | +| II. Scripts Over Skills | Deterministic sync script; skill only for question pings | ✅ PASS | +| III. Full Documentation | JSDoc + README + skill SKILL.md | ✅ PASS (planned) | +| IV. Skill Semver | Skill declares MAJOR.MINOR.PATCH | ✅ PASS (planned) | +| V. Skills Compose Scripts | Skill classifies; script fetches/merges | ✅ PASS (planned) | +| VI. Config-Driven References | Jira region from `automation`/`title`/`placeholders[]` | ✅ PASS (planned) | +| VII. Isolated Unit Testing | Jest; no I/O under `templates/` or `Daily_Notes/` | ✅ PASS (planned) | +| VIII. Node.js Script Stack | `.agents/scripts/`, ES modules, zero runtime deps | ✅ PASS | +| IX. Skill Quality Gates | SkillSpector + skillsaw before complete | ✅ PASS (planned) | + +**Post-design re-check**: Contracts and data model enforce single jira region, env auth gate, +fetch/apply state ordering, merge preservation, and optional skill invocation; no gate failures. + +## Execution flow + +```text +1. Parse CLI args (--date, --config, --no-skill-classify, optional --fetch-only / --apply-json) +2. assertJiraAuth() → fail fast if env missing +3. loadConfig() → configs/daily_template.json +4. findJiraRegion() → single automation:"jira" node +5. resolveNotePath() → fail if daily note missing +6. readState() → .agents/state/daily-jira-items.json +7. fetchJiraData() → REST: JQL rules 1/3/4 + comment ~ displayName for rule 2 +8. [optional] classify pings → skill or skip; output { key, commentId, isQuestion }[] +9. applyJiraSync() → merge section, write state (cursor rules), resolve title/url +10. Write note if changed → stdout updated:|unchanged: +``` + +## Project Structure + +### Documentation (this feature) + +```text +specs/004-daily-jira-items/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── tasks.md # via /speckit-tasks +├── contracts/ +│ ├── sync-daily-jira-items-cli.md +│ └── classify-jira-question-skill.md +└── spec.md +``` + +### Source Code (repository root) + +```text +.agents/scripts/ +├── sync-daily-jira-items.js # Main CLI (new) +├── lib/ +│ ├── load-config.js # Reused +│ ├── resolve-date.js # Reused +│ ├── jira-config.js # assertJiraAuth, jiraRequest (new) +│ ├── jira-fetch.js # fetchJiraData, JQL, comments, ADF text (new) +│ ├── jira-classify.js # Deterministic rules + tags (new) +│ ├── jira-state.js # read/write state, persistApplyState (new) +│ ├── jira-line-format.js # `- [ ] [KEY: Title](url) #tags` (new) +│ ├── jira-merge.js # Merge by issue key; preserve - [x] (new) +│ ├── jira-note.js # Locate jira region; insert above --- (new) +│ └── jira-placeholder.js # Placeholder removal before insert (new) +├── __tests__/ +│ └── sync-daily-jira-items.test.js # (new) +└── README.md + +.agents/skills/ +└── classify-jira-question/ + └── SKILL.md # #question-awaiting-reply classifier (new) + +.agents/state/ +└── daily-jira-items.json # Created/updated at runtime (gitignored if sensitive) + +configs/ +└── daily_template.json # Existing jira section (no schema change required) + +package.json # npm run sync-daily-jira-items +``` + +**Structure Decision**: Single CLI with focused `lib/` modules; fetch/apply split as exported +functions (legacy Bob `sync.js` logic ported to ES modules). `jira-note.js` adapts +`github-note.js` patterns for one top-level region ending at `---`. Placeholder logic mirrors +feature 003. State file path matches legacy Bob for migration continuity. + +## Complexity Tracking + +> No constitution violations requiring justification. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +| --------- | ---------- | ------------------------------------ | +| - | - | - | diff --git a/packages/operational-dashboard-ui/eslint.config.mjs b/packages/operational-dashboard-ui/eslint.config.mjs new file mode 100644 index 00000000..ebc46e07 --- /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 00000000..9034c90d --- /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 00000000..6861c0b7 --- /dev/null +++ b/packages/operational-dashboard-ui/src/application/dashboard-operations.ts @@ -0,0 +1,101 @@ +import type { + DashboardControlPlane, + DashboardInvocationContext, + DashboardOperations, + DashboardWorkflowRuntime, +} from "./dashboard-types"; +import type { + DashboardProbe, + DashboardProbePublisher, + DashboardWorkflowAction, +} from "./dashboard-probes"; +import { noopDashboardProbePublisher } from "./dashboard-probes"; + +export interface DashboardOperationDependencies { + controlPlane: DashboardControlPlane; + probes?: DashboardProbePublisher; + runtime?: DashboardWorkflowRuntime; +} + +const defaultRuntime: DashboardWorkflowRuntime = { + createCorrelationId: () => crypto.randomUUID(), +}; + +const workflowAction: DashboardWorkflowAction = "get-operational-metrics"; + +function isCancelled(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError" + ); +} + +function workflowProbe( + action: DashboardWorkflowAction, + correlationId: string, + name: DashboardProbe["name"], + occurredAt: string, + outcome: DashboardProbe["fields"]["outcome"], +): DashboardProbe { + return Object.freeze({ + context: Object.freeze({ correlationId }), + fields: Object.freeze({ action, outcome }), + name, + occurredAt, + schemaVersion: 1, + }); +} + +export function createDashboardOperations({ + controlPlane, + probes = noopDashboardProbePublisher, + runtime = defaultRuntime, +}: DashboardOperationDependencies): DashboardOperations { + return { + getOperationalMetrics: async (signal) => { + const correlationId = runtime.createCorrelationId(); + const context: DashboardInvocationContext = { + correlationId, + ...(signal === undefined ? {} : { signal }), + }; + const occurredAt = new Date().toISOString(); + + probes.publish( + workflowProbe( + workflowAction, + correlationId, + "dashboard.workflow.started", + occurredAt, + "started", + ), + ); + + try { + const metrics = await controlPlane.getOperationalMetrics(context); + probes.publish( + workflowProbe( + workflowAction, + correlationId, + "dashboard.workflow.completed", + new Date().toISOString(), + "succeeded", + ), + ); + return metrics; + } catch (error) { + probes.publish( + workflowProbe( + workflowAction, + correlationId, + "dashboard.workflow.completed", + new Date().toISOString(), + isCancelled(error) ? "cancelled" : "failed", + ), + ); + throw error; + } + }, + }; +} diff --git a/packages/operational-dashboard-ui/src/application/dashboard-probes.ts b/packages/operational-dashboard-ui/src/application/dashboard-probes.ts new file mode 100644 index 00000000..5c01bab3 --- /dev/null +++ b/packages/operational-dashboard-ui/src/application/dashboard-probes.ts @@ -0,0 +1,35 @@ +import type { + DomainProbe, + DomainProbePublisher, +} from "@openshift-online/hypershell-domain-probes"; + +export type DashboardWorkflowAction = "get-operational-metrics"; +export type DashboardLayoutAction = "persist-layout-template"; +export type DashboardProbeAction = + DashboardWorkflowAction | DashboardLayoutAction; + +export type DashboardProbeOutcome = + "started" | "succeeded" | "failed" | "cancelled"; + +export type DashboardProbeName = + | "dashboard.workflow.started" + | "dashboard.workflow.completed" + | "dashboard.layout.template.invalid" + | "dashboard.layout.template.persistence-failed"; + +export type DashboardProbe = DomainProbe< + DashboardProbeName, + 1, + { + readonly action: DashboardProbeAction; + readonly outcome: DashboardProbeOutcome; + } +>; + +export type DashboardProbePublisher = DomainProbePublisher; + +export const noopDashboardProbePublisher: DashboardProbePublisher = { + publish() { + return undefined; + }, +}; diff --git a/packages/operational-dashboard-ui/src/application/dashboard-types.ts b/packages/operational-dashboard-ui/src/application/dashboard-types.ts new file mode 100644 index 00000000..fcfefe77 --- /dev/null +++ b/packages/operational-dashboard-ui/src/application/dashboard-types.ts @@ -0,0 +1,59 @@ +export interface OperationalMetricTrendPoint { + label: string; + value: number; +} + +export interface OperationalMetricTrend { + points: readonly OperationalMetricTrendPoint[]; +} + +export interface OperationalMetric { + id: string; + total?: string; + trend?: OperationalMetricTrend; + unit?: string; + value: string; +} + +export interface SignupTrendPoint { + label: string; + value: number; +} + +export interface OperationalDashboardMetrics { + lastSuccessfulRefresh: Date; + metrics: readonly OperationalMetric[]; +} + +export interface DashboardInvocationContext { + correlationId: string; + signal?: AbortSignal; +} + +/** Application-owned driven port for operational dashboard metrics. */ +export interface DashboardControlPlane { + getOperationalMetrics( + context: DashboardInvocationContext, + ): Promise; +} + +/** Driving entry port used by the operational dashboard presentation adapters. */ +export interface DashboardOperations { + getOperationalMetrics( + signal?: AbortSignal, + ): Promise; +} + +/** Application-owned port for nondeterministic workflow context. */ +export interface DashboardWorkflowRuntime { + createCorrelationId(): string; +} + +export type { + DashboardProbe, + DashboardProbeAction, + DashboardProbeName, + DashboardProbeOutcome, + DashboardProbePublisher, + DashboardWorkflowAction, +} from "./dashboard-probes"; diff --git a/packages/operational-dashboard-ui/src/dashboard-ui-provider.tsx b/packages/operational-dashboard-ui/src/dashboard-ui-provider.tsx new file mode 100644 index 00000000..859f1a75 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard-ui-provider.tsx @@ -0,0 +1,41 @@ +import { createContext, type PropsWithChildren, useContext } from "react"; + +import type { DashboardOperations } from "./application/dashboard-types"; +import type { DashboardProbePublisher } from "./application/dashboard-probes"; + +export interface DashboardUiNavigation { + collectionHref: string; + navigate: (href: string) => Promise | void; +} + +export interface DashboardUiServices { + dashboard: DashboardOperations; + navigation: DashboardUiNavigation; + probes?: DashboardProbePublisher; +} + +const DashboardUiContext = createContext( + undefined, +); + +export function DashboardUiProvider({ + children, + dashboard, + navigation, + probes, +}: PropsWithChildren) { + return ( + + {children} + + ); +} + +export function useDashboardUi(): DashboardUiServices { + const services = useContext(DashboardUiContext); + if (!services) { + throw new Error("Dashboard UI must be rendered within DashboardUiProvider"); + } + + return services; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-data.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-data.ts new file mode 100644 index 00000000..e015482a --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-data.ts @@ -0,0 +1,10 @@ +export const operationalDashboardMetricsQueryRoot = [ + "operational-dashboard", + "metrics", +] as const; + +export const operationalDashboardRefreshMilliseconds = 15 * 60 * 1000; + +export function operationalDashboardMetricsQueryKey() { + return [...operationalDashboardMetricsQueryRoot] as const; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts new file mode 100644 index 00000000..35f89153 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts @@ -0,0 +1,219 @@ +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; +const METRIC_ROW_COUNT = 3; +/** Height spanning all metric rows in adjacent columns (3 widgets + 2 gaps). */ +export const SUMMARY_WIDGET_HEIGHT = + METRIC_WIDGET_HEIGHT + (METRIC_ROW_COUNT - 1) * METRIC_ROW_STEP; + +const WIDGET_TITLE_MESSAGES = { + summary: messages.summary, + "active-users": messages.activeUsers, + "provisioned-gateways": messages.provisionedGateways, + memory: messages.widgetMemory, + namespaces: messages.namespaces, + "provisioned-sandboxes": messages.provisionedSandboxes, + cpu: messages.widgetCpu, + nodes: messages.nodes, + pods: messages.widgetPods, +} as const; + +type DashboardWidgetType = keyof typeof WIDGET_TITLE_MESSAGES; + +const fourColumnLayout = [ + { + h: SUMMARY_WIDGET_HEIGHT, + i: "summary#1", + title: "Summary", + w: 1, + widgetType: "summary", + x: 0, + y: 0, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "active-users#1", + title: "Active users", + w: 1, + widgetType: "active-users", + x: 1, + y: 0, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "provisioned-gateways#1", + title: "Provisioned gateways", + w: 1, + widgetType: "provisioned-gateways", + x: 2, + y: 0, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "memory#1", + title: "Memory", + w: 1, + widgetType: "memory", + x: 3, + y: 0, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "namespaces#1", + title: "Namespaces", + w: 1, + widgetType: "namespaces", + x: 1, + y: METRIC_ROW_STEP, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "provisioned-sandboxes#1", + title: "Provisioned sandboxes", + w: 1, + widgetType: "provisioned-sandboxes", + 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: "nodes#1", + title: "Nodes", + w: 1, + widgetType: "nodes", + x: 1, + y: METRIC_ROW_STEP * 2, + }, + { + 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: SUMMARY_WIDGET_HEIGHT, + i: "summary#1", + title: "Summary", + w: 1, + widgetType: "summary", + x: 0, + y: 0, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "active-users#1", + title: "Active users", + w: 1, + widgetType: "active-users", + x: 0, + y: SUMMARY_WIDGET_HEIGHT, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "provisioned-gateways#1", + title: "Provisioned gateways", + w: 1, + widgetType: "provisioned-gateways", + x: 0, + y: SUMMARY_WIDGET_HEIGHT + METRIC_ROW_STEP, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "memory#1", + title: "Memory", + w: 1, + widgetType: "memory", + x: 0, + y: SUMMARY_WIDGET_HEIGHT + METRIC_ROW_STEP * 2, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "namespaces#1", + title: "Namespaces", + w: 1, + widgetType: "namespaces", + x: 0, + y: SUMMARY_WIDGET_HEIGHT + METRIC_ROW_STEP * 3, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "provisioned-sandboxes#1", + title: "Provisioned sandboxes", + w: 1, + widgetType: "provisioned-sandboxes", + x: 0, + y: SUMMARY_WIDGET_HEIGHT + METRIC_ROW_STEP * 4, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "cpu#1", + title: "CPU", + w: 1, + widgetType: "cpu", + x: 0, + y: SUMMARY_WIDGET_HEIGHT + METRIC_ROW_STEP * 5, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "nodes#1", + title: "Nodes", + w: 1, + widgetType: "nodes", + x: 0, + y: SUMMARY_WIDGET_HEIGHT + METRIC_ROW_STEP * 6, + }, + { + h: METRIC_WIDGET_HEIGHT, + i: "pods#1", + title: "Pods", + w: 1, + widgetType: "pods", + x: 0, + y: SUMMARY_WIDGET_HEIGHT + METRIC_ROW_STEP * 7, + }, + ], +}; + +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/trend-sparkline-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx new file mode 100644 index 00000000..3f7710e5 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx @@ -0,0 +1,87 @@ +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; +} + +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, + }); + + return ( +
+ formatTooltip(datum as SparklineDatum)} + /> + } + height={52} + padding={{ bottom: 2, left: 2, right: 2, top: 2 }} + themeColor={ChartThemeColor.blue} + width={width} + > + + +
+ ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/utilization-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/utilization-chart.tsx new file mode 100644 index 00000000..1d9b499b --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/utilization-chart.tsx @@ -0,0 +1,77 @@ +import { ChartDonutUtilization } from "@patternfly/react-charts/victory"; +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 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 = Math.round((Number(value) / Number(total)) * 100); + const capacityLabel = intl.formatMessage(messages.utilizationCapacity, { + unit, + }); + + const data: UsageData = { x: capacityLabel, y: percentage }; + + return ( +
+ + datum.x + ? intl.formatMessage(messages.utilizationDataLabel, { + capacity: datum.x, + percentage: datum.y, + }) + : null + } + name="chart1" + subTitle={intl.formatMessage(messages.utilizationSubtitle, { + total, + unit, + })} + title={intl.formatMessage(messages.utilizationLabel, { + unit, + value, + })} + thresholds={[{ value: 60 }, { value: 90 }]} + /> +
+ ); +} diff --git a/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts b/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts new file mode 100644 index 00000000..1f2454b8 --- /dev/null +++ b/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts @@ -0,0 +1,101 @@ +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: 612 }), + ]), + }), + value: "612", + }), + Object.freeze({ + id: "provisioned-gateways", + trend: Object.freeze({ + points: Object.freeze([ + Object.freeze({ label: "Day 1", value: 82 }), + 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: "namespaces", + trend: Object.freeze({ + points: Object.freeze([ + Object.freeze({ label: "Day 1", value: 12 }), + Object.freeze({ label: "Day 2", value: 18 }), + Object.freeze({ label: "Day 3", value: 20 }), + Object.freeze({ label: "Day 4", value: 27 }), + Object.freeze({ label: "Day 5", value: 55 }), + Object.freeze({ label: "Day 6", value: 74 }), + Object.freeze({ label: "Day 7", value: 80 }), + ]), + }), + value: "80", + }), + Object.freeze({ + id: "nodes", + value: "8", + }), + Object.freeze({ + id: "cpu", + value: "4.2", + unit: "cores", + total: "60", + }), + Object.freeze({ + id: "memory", + value: "92.3", + 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 }), + ]), + }), + }), + ]), + lastSuccessfulRefresh: new Date("2026-08-25T10:55:00.000Z"), + }); diff --git a/packages/operational-dashboard-ui/src/index.ts b/packages/operational-dashboard-ui/src/index.ts new file mode 100644 index 00000000..8b96b161 --- /dev/null +++ b/packages/operational-dashboard-ui/src/index.ts @@ -0,0 +1,37 @@ +export { + DashboardUiProvider, + useDashboardUi, + type DashboardUiNavigation, +} from "./dashboard-ui-provider"; +export type { + DashboardControlPlane, + DashboardInvocationContext, + DashboardOperations, + DashboardProbe, + DashboardProbeAction, + DashboardProbeName, + DashboardProbeOutcome, + DashboardProbePublisher, + DashboardWorkflowAction, + DashboardWorkflowRuntime, + OperationalDashboardMetrics, + OperationalMetric, + OperationalMetricTrend, + OperationalMetricTrendPoint, + SignupTrendPoint, +} from "./application/dashboard-types"; +export { noopDashboardProbePublisher } from "./application/dashboard-probes"; +export { + createDashboardOperations, + type DashboardOperationDependencies, +} from "./application/dashboard-operations"; +export { + operationalDashboardMetricsQueryKey, + operationalDashboardRefreshMilliseconds, +} from "./dashboard/dashboard-data"; +export { ResourceRefreshButton } from "./shared/resource-refresh-button"; +export { + OperationalDashboardPage, + type OperationalDashboardPageProps, +} from "./pages/operational-dashboard-page"; +export { messages as dashboardMessages } from "./messages"; diff --git a/packages/operational-dashboard-ui/src/messages.ts b/packages/operational-dashboard-ui/src/messages.ts new file mode 100644 index 00000000..da54e7ac --- /dev/null +++ b/packages/operational-dashboard-ui/src/messages.ts @@ -0,0 +1,185 @@ +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.", + }, + cpus: { + id: "app.dashboard.summary.cpus", + defaultMessage: "CPUs", + description: "Summary label for provisioned CPU capacity.", + }, + description: { + id: "app.dashboard.description", + defaultMessage: + "This dashboard is in development and is currently using mocked data.", + description: "Supporting text on the operational dashboard page.", + }, + gateways: { + id: "app.dashboard.summary.gateways", + defaultMessage: "Gateways", + description: "Summary label for provisioned gateways.", + }, + 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.", + }, + namespaces: { + id: "app.dashboard.widget.namespaces", + defaultMessage: "Namespaces", + description: "Title for the namespaces dashboard widget.", + }, + 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.", + }, + provisionedSandboxes: { + id: "app.dashboard.widget.provisionedSandboxes", + defaultMessage: "Provisioned sandboxes", + description: "Title for the provisioned sandboxes dashboard widget.", + }, + refresh: { + id: "app.dashboard.refresh", + defaultMessage: "Refresh dashboard metrics", + description: + "Accessible label for refreshing operational dashboard 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.", + }, + 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.", + }, + title: { + id: "app.dashboard.title", + defaultMessage: "HyperShell operational dashboard", + description: "Main heading on the operational dashboard page.", + }, + trendTooltip: { + id: "app.dashboard.trend.tooltip", + defaultMessage: "{date}: {value} {metric}", + description: "Tooltip for a dashboard metric trend sparkline point.", + }, + 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.", + }, + 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: "Summary label for provisioned sandboxes.", + }, +}); diff --git a/packages/operational-dashboard-ui/src/pages/dashboard-widget.css b/packages/operational-dashboard-ui/src/pages/dashboard-widget.css new file mode 100644 index 00000000..c6380ee3 --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/dashboard-widget.css @@ -0,0 +1,26 @@ +/* + * Widget action menus render in a document-level popper. Hide Remove until product + * supports an add-widgets drawer to restore removed tiles. + */ +body.hypershell-operational-dashboard [data-ouia-component-id="remove-widget"] { + display: none; +} + +.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 { + height: 52px; + width: 100%; +} + +.hypershell-dashboard-utilization-chart { + aspect-ratio: 1 / 1; + height: 100%; + margin-inline: auto; +} diff --git a/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx b/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx new file mode 100644 index 00000000..5e56f7d8 --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx @@ -0,0 +1,197 @@ +import { + Card, + CardBody, + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Stack, + StackItem, + Title, +} from "@patternfly/react-core"; +import type { PropsWithChildren } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import type { OperationalMetric } from "../application/dashboard-types"; +import { TrendSparklineChart } from "../dashboard/trend-sparkline-chart"; +import { + isUtilizationMetric, + UtilizationChart, +} from "../dashboard/utilization-chart"; +import { messages } from "../messages"; + +function WidgetContent({ children }: Readonly) { + return ( + + {children} + + ); +} + +export function MetricCard({ + metric, + subtitle, + title, +}: Readonly<{ metric: OperationalMetric; subtitle: string; title: string }>) { + const intl = useIntl(); + + return ( + + + + + + + + {intl.formatMessage(messages.metricValue, { + label: title, + value: metric.value, + })} + + {subtitle ? {subtitle} : null} + + + + {metric.trend ? ( + + + + ) : null} + + + + ); +} + +export function UtilizationCard({ + metric, + subtitle, +}: Readonly<{ metric: OperationalMetric; subtitle: string; title: string }>) { + return ( + + + + + + +

+ {metric.value} {metric.unit} +

+ {subtitle} +
+
+
+ {isUtilizationMetric(metric) ? ( + + + + ) : null} +
+
+
+ ); +} + +export function SummaryCard({ + metrics, +}: Readonly<{ metrics: readonly OperationalMetric[] }>) { + const intl = useIntl(); + + return ( + + + + + <FormattedMessage {...messages.summaryUsage} /> + + + + + + + + + + {metrics.find((metric) => metric.id === "active-users")?.value} + + + + + + + + { + metrics.find((metric) => metric.id === "provisioned-gateways") + ?.value + } + + + + + + + + {metrics.find((metric) => metric.id === "namespaces")?.value} + + + + + + + + { + metrics.find( + (metric) => metric.id === "provisioned-sandboxes", + )?.value + } + + + + +
+ + + <FormattedMessage {...messages.summarySystem} /> + + + + + + + + + + {metrics.find((metric) => metric.id === "memory")?.value} + + + + + + + + {metrics.find((metric) => metric.id === "cpu")?.value} + + + + + + + + {metrics.find((metric) => metric.id === "pods")?.value} + + + + +
+
+ ); +} diff --git a/packages/operational-dashboard-ui/src/pages/get-metrics-data.ts b/packages/operational-dashboard-ui/src/pages/get-metrics-data.ts new file mode 100644 index 00000000..42c5ffc7 --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/get-metrics-data.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + operationalDashboardMetricsQueryKey, + operationalDashboardRefreshMilliseconds, +} from "../dashboard/dashboard-data"; +import { useDashboardUi } from "../dashboard-ui-provider"; + +export interface UseGetMetricsDataOptions { + enabled?: boolean; +} + +export function useGetMetricsData({ + enabled = true, +}: UseGetMetricsDataOptions = {}) { + const { dashboard } = useDashboardUi(); + + return useQuery({ + enabled, + queryFn: async ({ signal }) => { + return dashboard.getOperationalMetrics(signal); + }, + queryKey: operationalDashboardMetricsQueryKey(), + refetchInterval: operationalDashboardRefreshMilliseconds, + staleTime: operationalDashboardRefreshMilliseconds, + }); +} diff --git a/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx b/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx new file mode 100644 index 00000000..dd90344f --- /dev/null +++ b/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx @@ -0,0 +1,405 @@ +import { + Alert, + Bullseye, + Content, + EmptyState, + EmptyStateBody, + EmptyStateVariant, + PageSection, + Spinner, + Flex, + FlexItem, + Title, +} from "@patternfly/react-core"; +import { + ClusterIcon, + CubesIcon, + UsersIcon, + MicrochipIcon, + MemoryIcon, +} from "@patternfly/react-icons"; +import { + GridLayout, + type ExtendedTemplateConfig, + type Variants, + type WidgetMapping, +} from "@patternfly/widgetized-dashboard"; +import "@patternfly/widgetized-dashboard/dist/esm/styles.css"; +import { useEffect, useMemo, 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, + localizeDashboardLayoutTemplate, + 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 { MetricCard, SummaryCard } from "./dashboard-widget"; +import { useGetMetricsData } from "./get-metrics-data"; + +const OPERATIONAL_DASHBOARD_BODY_CLASS = "hypershell-operational-dashboard"; + +const baseTemplate = defaultDashboardLayoutTemplate; + +const LAYOUT_STORAGE_KEY = "hypershell.operational-dashboard.layout.v6"; +const CUSTOM_COLUMNS: Record = { + xl: 4, + lg: 4, + md: 4, + sm: 1, +}; + +function preservesRequiredWidgets( + nextTemplate: ExtendedTemplateConfig, +): boolean { + for (const variant of Object.keys(baseTemplate) as Variants[]) { + if (!Array.isArray(nextTemplate[variant])) { + return false; + } + + const requiredIds = new Set(baseTemplate[variant].map((item) => item.i)); + + for (const id of requiredIds) { + if (!nextTemplate[variant].some((item) => item.i === id)) { + return false; + } + } + } + + return true; +} + +function restoreRequiredWidgets( + savedTemplate: ExtendedTemplateConfig, +): ExtendedTemplateConfig { + return (Object.keys(baseTemplate) as Variants[]).reduce((acc, variant) => { + const savedById = new Map( + savedTemplate[variant].map((item) => [item.i, item]), + ); + + acc[variant] = baseTemplate[variant].map( + (defaultItem) => savedById.get(defaultItem.i) ?? defaultItem, + ); + + return acc; + }, {} as ExtendedTemplateConfig); +} + +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" | "utilization", + ) => { + const metric = metricById.get(metricId); + const title = intl.formatMessage(titleMessage); + + if (!metric) { + return ( + + + + <FormattedMessage {...messages.metricUnavailableTitle} /> + + + + + + + ); + } + + return metricType === "metric" ? ( + + ) : ( + + ); + }; + + return { + summary: { + defaults: { + h: SUMMARY_WIDGET_HEIGHT, + maxH: SUMMARY_WIDGET_HEIGHT + 2, + minH: METRIC_WIDGET_DEFAULTS.minH, + w: 1, + }, + config: { + icon: , + title: intl.formatMessage(messages.summary), + }, + renderWidget: () => , + }, + "active-users": { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.activeUsers), + }, + renderWidget: () => + renderMetric("active-users", "", messages.activeUsers, "metric"), + }, + "provisioned-gateways": { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.provisionedGateways), + }, + renderWidget: () => + renderMetric( + "provisioned-gateways", + "", + messages.provisionedGateways, + "metric", + ), + }, + "provisioned-sandboxes": { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.provisionedSandboxes), + }, + renderWidget: () => + renderMetric( + "provisioned-sandboxes", + "", + messages.provisionedSandboxes, + "metric", + ), + }, + namespaces: { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.namespaces), + }, + renderWidget: () => + renderMetric("namespaces", "", messages.namespaces, "metric"), + }, + nodes: { + defaults: METRIC_WIDGET_DEFAULTS, + config: { + icon: , + title: intl.formatMessage(messages.nodes), + }, + renderWidget: () => renderMetric("nodes", "", messages.nodes, "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 } = useDashboardUi(); + const pageTitle = title ?? intl.formatMessage(messages.title); + const metricsQuery = useGetMetricsData({ + enabled: metrics === undefined, + }); + const dashboardMetrics = metrics ?? metricsQuery.data; + const localizedBaseTemplate = useMemo( + () => localizeDashboardLayoutTemplate(baseTemplate, intl), + [intl], + ); + + useEffect(() => { + document.body.classList.add(OPERATIONAL_DASHBOARD_BODY_CLASS); + + return () => { + document.body.classList.remove(OPERATIONAL_DASHBOARD_BODY_CLASS); + }; + }, []); + + const savedTemplate = useMemo(() => { + if (typeof window === "undefined") { + return localizedBaseTemplate; + } + + try { + const rawTemplate = window.localStorage.getItem(LAYOUT_STORAGE_KEY); + if (!rawTemplate) { + return localizedBaseTemplate; + } + + return localizeDashboardLayoutTemplate( + restoreRequiredWidgets( + JSON.parse(rawTemplate) as ExtendedTemplateConfig, + ), + intl, + ); + } catch { + return localizedBaseTemplate; + } + }, [intl, localizedBaseTemplate]); + const [dashboardTemplate, setDashboardTemplate] = + useState(savedTemplate); + const [gridLayoutKey, setGridLayoutKey] = useState(0); + const displayTemplate = useMemo( + () => localizeDashboardLayoutTemplate(dashboardTemplate, intl), + [dashboardTemplate, intl], + ); + const widgetMapping = useMemo( + () => + dashboardMetrics + ? createWidgetMapping(dashboardMetrics, intl) + : undefined, + [dashboardMetrics, intl], + ); + + const handleTemplateChange = (nextTemplate: ExtendedTemplateConfig) => { + const correlationId = crypto.randomUUID(); + + if (!preservesRequiredWidgets(nextTemplate)) { + probes.publish( + layoutProbe( + correlationId, + "dashboard.layout.template.invalid", + "failed", + ), + ); + setGridLayoutKey((currentKey) => currentKey + 1); + return; + } + + setDashboardTemplate(nextTemplate); + + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + LAYOUT_STORAGE_KEY, + JSON.stringify(nextTemplate), + ); + } catch { + probes.publish( + layoutProbe( + correlationId, + "dashboard.layout.template.persistence-failed", + "failed", + ), + ); + } + }; + + return ( + + + + + {pageTitle} +

+ +

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