diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..aa7b694b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +# Go files use tabs +[*.go] +indent_style = tab +indent_size = 4 + +# Frontend files (TypeScript, JavaScript, CSS, HTML, JSON, YAML) +[*.{ts,tsx,js,jsx,css,html,json,yaml,yml}] +indent_style = space +indent_size = 2 + +# Makefiles require tabs +[Makefile] +indent_style = tab + +# Markdown and snapshots +[*.md] +max_line_length = off +trim_trailing_whitespace = false + +[*.snap] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.github/workflows/build-container-images.yaml b/.github/workflows/build-container-images.yaml index d7155504..6f85e2df 100644 --- a/.github/workflows/build-container-images.yaml +++ b/.github/workflows/build-container-images.yaml @@ -207,6 +207,55 @@ jobs: run: | podman logout quay.io + console: + runs-on: ubuntu-latest + needs: setup + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Login to Quay.io + run: | + echo "${{ secrets.QUAY_PASSWORD }}" | podman login quay.io -u "${{ secrets.QUAY_USERNAME }}" --password-stdin + + - name: Container image building + run: | + echo "Building ClusterIQ Console (${{ needs.setup.outputs.BRANCH }}/${{ needs.setup.outputs.SHA_COMMIT }})" + podman build \ + --platform linux/amd64 \ + --build-arg VERSION=${{ needs.setup.outputs.GIT_TAG }} \ + --build-arg COMMIT=${{ needs.setup.outputs.SHA_COMMIT }} \ + -t quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-console:${{ needs.setup.outputs.SHA_COMMIT }} \ + -f ./console/deployments/containerfiles/Containerfile ./console + + - name: Pushing Hash based image + run: | + podman push quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-console:${{ needs.setup.outputs.SHA_COMMIT }} + + - name: Tagging and Pushing Latest Image + if: ${{ needs.setup.outputs.LATEST_TAG != '' && needs.setup.outputs.LATEST_TAG != null }} + run: | + podman tag \ + quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-console:${{ needs.setup.outputs.SHA_COMMIT }} \ + quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-console:${{ needs.setup.outputs.LATEST_TAG }} + podman push quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-console:${{ needs.setup.outputs.LATEST_TAG }} + + - name: Tagging and Pushing GitTag based image + if: ${{ needs.setup.outputs.GIT_TAG != '' && needs.setup.outputs.GIT_TAG != null }} + run: | + echo "Building Tagged version image: ${{ needs.setup.outputs.GIT_TAG }}" + podman tag \ + quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-console:${{ needs.setup.outputs.SHA_COMMIT }} \ + quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-console:${{ needs.setup.outputs.GIT_TAG }} + podman push quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-console:${{ needs.setup.outputs.GIT_TAG }} + + - name: Logout from Quay.io + run: | + podman logout quay.io + final: runs-on: ubuntu-latest needs: @@ -214,9 +263,11 @@ jobs: - api - agent - scanner + - console steps: - name: Validating run: | podman pull quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-api:${{ needs.setup.outputs.SHA_COMMIT }} podman pull quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-agent:${{ needs.setup.outputs.SHA_COMMIT }} podman pull quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-scanner:${{ needs.setup.outputs.SHA_COMMIT }} + podman pull quay.io/${{ secrets.QUAY_ORG_NAME }}/cluster-iq-console:${{ needs.setup.outputs.SHA_COMMIT }} diff --git a/.github/workflows/validate-pr.yaml b/.github/workflows/validate-pr.yaml index 3466a19d..e2a823ba 100644 --- a/.github/workflows/validate-pr.yaml +++ b/.github/workflows/validate-pr.yaml @@ -29,6 +29,35 @@ jobs: only-new-issues: true args: --whole-files + console-lint: + name: Console Lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./console + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'npm' + cache-dependency-path: console/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Code Prettier + run: make ts-prettier + + - name: Code Linter + run: make ts-eslint + + - name: TypeScript type check + run: make ts-tsc + call-unit-tests: name: Go Unit tests needs: diff --git a/.gitignore b/.gitignore index b0e46942..841fa54a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,9 @@ go.work.sum # IDE files .idea/ .vscode/ + +# Console (frontend) +console/node_modules/ +console/dist/ +console/dist-ssr/ +console/coverage/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..3c032078 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/CLAUDE.md b/CLAUDE.md index ddb5d76f..1e7515c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,9 +7,10 @@ This file provides guidance to Claude Code when working with this repository. **ClusterIQ** is an inventory and cost estimation platform for OpenShift clusters across multi-cloud environments (currently AWS only). It provides automated discovery, cost tracking, and lifecycle management. **Architecture Components:** -1. **Scanner**: CronJob that discovers cloud resources using "Stocker" pattern +1. **Scanner**: Long-running gRPC service that discovers cloud resources using "Stocker" pattern 2. **API Server**: REST API (Gin framework) for inventory queries and cluster operations 3. **Agent**: gRPC service handling cluster power operations (instant, scheduled, recurring) +4. **Console**: React/TypeScript web UI (PatternFly, Vite) **Repository Structure:** ``` @@ -23,6 +24,10 @@ internal/ ├── services/ # Business logic ├── api/handlers/ # HTTP handlers └── models/ # DTO, DB, domain models +console/ # Web UI (React/TypeScript/Vite/PatternFly) + ├── src/ # Application source code + ├── deployments/ # Console Containerfile + └── nginx/ # NGINX config template and startup script db/sql/ # Schema definitions (init.sql, cron.sql) test/integration/ # Integration tests ``` @@ -53,6 +58,13 @@ make lint-staged # Lint staged files only # Code Generation make generate-converters # Goverter (DB to DTO) make swagger-doc # OpenAPI docs + +# Console (frontend) +make console-install # Install npm dependencies +make console-build # Build console locally +make console-start-dev # Vite dev server (port 3000) +make console-lint # Run prettier + eslint + tsc +make build-console # Build console container image ``` ## Architecture Patterns @@ -111,12 +123,13 @@ go tool cover -html=coverage.out -o coverage.html # Visual ## Development Workflow 1. Make code changes -2. Run `make lint-staged` before committing +2. Run `make lint-staged` before committing (Go), `make console-lint` (Console) 3. Run relevant tests: `make go-unit-tests` 4. For API changes: update Swagger with `make swagger-doc` 5. For DB changes: update `db/sql/init.sql` or add data migration in `doc/releases/` 6. For protobuf changes: `make local-build-agent` 7. For goverter changes: `make generate-converters` +8. For console changes: run `make console-lint` and test in browser via `make console-start-dev` **Commit Convention:** - Use conventional commits format: `type(scope): brief description` diff --git a/Makefile b/Makefile index eda96004..59dde545 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,10 @@ AGENT_PROTO_PATH ?= ./cmd/agent/proto/agent.proto PGSQL_IMG_NAME ?= $(PROJECT_NAME)-pgsql PGSQL_IMAGE ?= $(REGISTRY)/$(REGISTRY_REPO)/$(PGSQL_IMG_NAME) PGSQL_CONTAINERFILE ?= ./$(DEPLOYMENTS_DIR)/containerfiles/Containerfile-pgsql +CONSOLE_DIR ?= ./console +CONSOLE_IMG_NAME ?= $(PROJECT_NAME)-console +CONSOLE_IMAGE ?= $(REGISTRY)/$(REGISTRY_REPO)/$(CONSOLE_IMG_NAME) +CONSOLE_CONTAINERFILE ?= $(CONSOLE_DIR)/deployments/containerfiles/Containerfile # Standard targets all: ## Stop, build and start the development environment based on containers @@ -89,10 +93,10 @@ local-build-agent: ## Build the agent binary # Container based working targets clean: ## Remove the container images @echo "### [Cleaning Container images] ###" - @-$(CONTAINER_ENGINE) images | grep -e $(SCANNER_IMAGE) -e $(API_IMAGE) -e $(AGENT_IMAGE) -e $(PGSQL_IMAGE) | awk '{print $$3}' | xargs $(CONTAINER_ENGINE) rmi -f + @-$(CONTAINER_ENGINE) images | grep -e $(SCANNER_IMAGE) -e $(API_IMAGE) -e $(AGENT_IMAGE) -e $(PGSQL_IMAGE) -e $(CONSOLE_IMAGE) | awk '{print $$3}' | xargs $(CONTAINER_ENGINE) rmi -f build: ## Build all container images -build: build-api build-scanner build-agent build-pgsql +build: build-api build-scanner build-agent build-pgsql build-console build-api: generate-converters ## Build the API container image @echo "### [Building API container image] ###" @$(CONTAINER_ENGINE) build \ @@ -129,12 +133,22 @@ build-pgsql: ## Build the PGSQL container image @$(CONTAINER_ENGINE) tag $(PGSQL_IMAGE):latest $(PGSQL_IMAGE):$(SHORT_COMMIT_HASH) @echo "Build Successful" +build-console: ## Build the Console container image + @echo "### [Building Console container image] ###" + @$(CONTAINER_ENGINE) build \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(SHORT_COMMIT_HASH) \ + -t $(CONSOLE_IMAGE):latest -f $(CONSOLE_CONTAINERFILE) $(CONSOLE_DIR) + @$(CONTAINER_ENGINE) tag $(CONSOLE_IMAGE):latest $(CONSOLE_IMAGE):$(SHORT_COMMIT_HASH) + @echo "Build Successful" + # Development targets start-dev: ## Start the container-based development environment @echo "### [Starting dev environment] ###" @$(CONTAINER_ENGINE)-compose -f $(DEPLOYMENTS_DIR)/compose/compose-devel.yaml up -d @echo "### [Running dev environment] ###" + @echo "### [Console: http://localhost:8080 ] ###" @echo "### [API: http://localhost:8081/api/v1/healthcheck ] ###" stop-dev: ## Stop the container-based development environment @@ -207,6 +221,23 @@ swagger-doc: ## Generate Swagger documentation for ClusterIQ API @$(SWAGGER) init --generalInfo ./cmd/api/server.go --parseDependency --output ./cmd/api/docs +# Console targets (delegated to console/Makefile) +console-install: ## Install console dependencies + @$(MAKE) -C $(CONSOLE_DIR) local-install + +console-build: ## Build console locally + @$(MAKE) -C $(CONSOLE_DIR) local-build + +console-clean: ## Clean console build artifacts + @$(MAKE) -C $(CONSOLE_DIR) local-clean + +console-start-dev: ## Start console dev server + @$(MAKE) -C $(CONSOLE_DIR) local-start-dev + +console-lint: ## Run console linters (prettier + eslint + tsc) + @$(MAKE) -C $(CONSOLE_DIR) ts-test + + # Set the default target to "help" .DEFAULT_GOAL := help # Help diff --git a/README.md b/README.md index 7221a035..310da7a7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/RHEcosystemAppEng/cluster-iq)](https://goreportcard.com/report/github.com/RHEcosystemAppEng/cluster-iq) [![Go Reference](https://pkg.go.dev/badge/github.com/RHEcosystemAppEng/cluster-iq.svg)](https://pkg.go.dev/github.com/RHEcosystemAppEng/cluster-iq) -![Version](https://img.shields.io/badge/version-0.5-blue) +![Version](https://img.shields.io/badge/version-0.6-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green) --- [![Container image building](https://github.com/RHEcosystemAppEng/cluster-iq/actions/workflows/build-container-images.yaml/badge.svg)](https://github.com/RHEcosystemAppEng/cluster-iq/actions/workflows/build-container-images.yaml) @@ -19,8 +19,8 @@ goal is to provide a continually updated inventory of clusters. This helps users efficiently identify and manage their clusters, offering a simplified approach to estimating costs and ensuring better resource management. -ClusterIQ has a Web UI called [ClusterIQ Console](https://github.com/RHEcosystemAppEng/cluster-iq-console). -Follow this [link](https://github.com/RHEcosystemAppEng/cluster-iq-console?tab=readme-ov-file#development-scripts) for installation instructions. +ClusterIQ includes a Web UI (Console) under the `console/` directory. +See the [Console](#console) section for development instructions. ## Supported cloud providers @@ -138,11 +138,8 @@ For more information about the supported parameters, check the [Configuration Se helm list -n $NAMESPACE ``` -6. Once every pod is up and running, trigger the scanner manually for - initializing the inventory - ```sh - oc create job --from=cronjob/scanner scanner-init -n $NAMESPACE - ``` +6. Once every pod is up and running, the scanner will automatically begin + discovering cloud resources. ### Uninstalling To uninstall ClusterIQ Helm chart, use the following commands @@ -206,7 +203,7 @@ make local-build-api The Agent performs actions over the selected cloud resources. It only accepts incoming requests from the API. -Currently, on release `v0.4`, the agent only supports Power On/Off clusters on AWS. +The Agent supports Power On/Off operations for clusters on AWS, including instant, scheduled, and recurring actions. ```shell # Building in a container @@ -216,6 +213,35 @@ make build-agent make local-build-agent ``` +## Console + +The web console is a React/TypeScript application located under `console/`. +It provides the ClusterIQ Web UI with cluster inventory views, cost tracking, and action management. + +### Prerequisites +- [Node.js](https://nodejs.org/) 18.x or higher +- [npm](https://www.npmjs.com/) 8.x or higher + +### Development Commands +```shell +# Install console dependencies +make console-install + +# Build console locally +make console-build + +# Start console dev server (port 3000, proxies API to localhost:8081) +make console-start-dev + +# Run console linters (prettier + eslint + tsc) +make console-lint + +# Build console container image +make build-console +``` + +For more details, see `console/README.md`. + ## Extra Documentation The following documentation is available: diff --git a/VERSION b/VERSION index 83b4ac55..74d51203 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.5 +v0.6 diff --git a/console/.dockerignore b/console/.dockerignore new file mode 100644 index 00000000..2d55c911 --- /dev/null +++ b/console/.dockerignore @@ -0,0 +1,10 @@ +.env +.git +.gitignore +*.md +dist +Dockerfile +Containerfile +Makefile +node_modules +npm-debug.log \ No newline at end of file diff --git a/console/.prettierignore b/console/.prettierignore new file mode 100644 index 00000000..ec6d3cdd --- /dev/null +++ b/console/.prettierignore @@ -0,0 +1 @@ +package.json diff --git a/console/.prettierrc.js b/console/.prettierrc.js new file mode 100644 index 00000000..b4f84dca --- /dev/null +++ b/console/.prettierrc.js @@ -0,0 +1,63 @@ +export default { + // Use single quotes instead of double quotes + // Example: const name = 'John' vs "John" + singleQuote: true, + + // Maximum line length before wrapping + // Longer lines will be broken into multiple lines + printWidth: 120, + + // Add semicolons at the end of statements + // Example: const name = 'John'; + semi: true, + + // Add trailing commas in objects/arrays where valid in ES5 + // Example: { name: 'John', age: 30, } + trailingComma: 'es5', + + // Number of spaces for each indentation level + // Example: + // { + // name: 'John' + // } + tabWidth: 2, + + // Add spaces between brackets in object literals + // Example: { name: 'John' } vs {name: 'John'} + bracketSpacing: true, + + // Put the closing bracket of JSX elements on a new line + // Example: + // + + + + + + setQuery({ accountId: value })} + actionOperation={action} + setOperation={value => setQuery({ action: value || [] })} + actionType={type} + setType={value => setQuery({ type: value })} + actionStatus={status} + setStatus={value => setQuery({ status: value })} + actionEnabled={enabled} + setEnabled={value => setQuery({ enabled: value })} + /> + + + setReloadFlag(k => k + 1)} + /> + + + ); +}; + +export default Scheduler; diff --git a/console/src/app/Actions/Scheduler/components/AccountSelector.tsx b/console/src/app/Actions/Scheduler/components/AccountSelector.tsx new file mode 100644 index 00000000..1a785a68 --- /dev/null +++ b/console/src/app/Actions/Scheduler/components/AccountSelector.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { + Button, + FormGroup, + Select, + SelectOption, + MenuToggle, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Tooltip, +} from '@patternfly/react-core'; +import { AccountResponseApi } from '@api'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +interface AccountTypeaheadSelectProps { + accounts: AccountResponseApi[]; + selectedAccount: AccountResponseApi | null; + onSelectAccount: (account: AccountResponseApi | null) => void; + onClearAccount: () => void; +} + +export const AccountTypeaheadSelect: React.FunctionComponent = ({ + accounts, + selectedAccount, + onSelectAccount, + onClearAccount, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + + const safeAccounts = React.useMemo(() => (Array.isArray(accounts) ? accounts : []), [accounts]); + + const filteredAccounts = React.useMemo(() => { + const q = inputValue.trim().toLowerCase(); + if (!q) return safeAccounts; + + return safeAccounts.filter(a => { + const haystack = `${a.accountName ?? ''} ${a.accountId ?? ''}`.toLowerCase(); + return haystack.includes(q); + }); + }, [safeAccounts, inputValue]); + + const onSelect = (_event?: React.MouseEvent, value?: string | number) => { + const id = String(value ?? ''); + const acc = safeAccounts.find(a => a.accountId === id) ?? null; + + // Keep input in sync with selection for a predictable UX + setInputValue(acc ? `${acc.accountName} (${acc.accountId})` : ''); + onSelectAccount(acc); + setIsOpen(false); + }; + + return ( + + + + ); +}; diff --git a/console/src/app/Actions/Scheduler/components/ActionsKebabMenu.tsx b/console/src/app/Actions/Scheduler/components/ActionsKebabMenu.tsx new file mode 100644 index 00000000..a29f7d94 --- /dev/null +++ b/console/src/app/Actions/Scheduler/components/ActionsKebabMenu.tsx @@ -0,0 +1,27 @@ +import { api, ActionResponseApi } from '@api'; + +export const rowActions = (action: ActionResponseApi, reloadActions: () => Promise) => [ + { + title: 'Enable', + onClick: async () => { + await api.actions.actionsEnable(action.id); + await reloadActions(); + }, + }, + { + title: 'Disable', + onClick: async () => { + await api.actions.actionsDisable(action.id); + await reloadActions(); + }, + }, + { isSeparator: true }, + { + title: 'Delete', + isDanger: true, + onClick: async () => { + await api.actions.actionsDelete(action.id); + await reloadActions(); + }, + }, +]; diff --git a/console/src/app/Actions/Scheduler/components/ActionsTable.tsx b/console/src/app/Actions/Scheduler/components/ActionsTable.tsx new file mode 100644 index 00000000..15bc4cce --- /dev/null +++ b/console/src/app/Actions/Scheduler/components/ActionsTable.tsx @@ -0,0 +1,137 @@ +import { renderActionTypeLabel, renderOperationLabel, renderActionStatusLabel } from '@app/utils/renderUtils'; +import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import { Label } from '@patternfly/react-core'; +import React, { useEffect, useMemo } from 'react'; +import { ActionStatus, ActionOperations, ActionTypes } from '@app/types/types'; +import { Link } from 'react-router-dom'; +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TablePagination } from '@app/components/common/TablesPagination'; +import { ActionsColumn } from '@patternfly/react-table'; +import { rowActions } from './ActionsKebabMenu'; +import { useScheduleActions, useInvalidateScheduleActions } from '@app/hooks/useScheduleActions'; +import { useTablePagination } from '@app/hooks/useTablePagination'; + +export const ScheduleActionsTable: React.FunctionComponent<{ + actionType: ActionTypes | null; + actionOperation: ActionOperations[] | null; + actionStatus: ActionStatus | null; + actionEnabled: boolean | null; + accountId: string | null; + reloadFlag: number; +}> = ({ actionType, actionOperation, actionStatus, actionEnabled, accountId, reloadFlag }) => { + const { data: allActions = [], isLoading, refetch } = useScheduleActions(); + const invalidateScheduleActions = useInvalidateScheduleActions(); + + useEffect(() => { + if (reloadFlag > 0) { + refetch(); + } + }, [reloadFlag, refetch]); + + const filtered = useMemo(() => { + let result = allActions; + + if (actionType) { + result = result.filter(item => item.type === actionType); + } + + if (accountId) { + result = result.filter(item => item.accountId?.includes(accountId)); + } + + if (actionOperation?.length) { + result = result.filter(item => { + return actionOperation.includes(item.operation as never); + }); + } + + if (actionStatus) { + result = result.filter(item => item.status === actionStatus); + } + + if (actionEnabled !== null) { + result = result.filter(item => item.enabled === actionEnabled); + } + + return result; + }, [allActions, actionType, accountId, actionOperation, actionStatus, actionEnabled]); + + const { page, perPage, setPage, setPerPage, paginatedData, totalItems } = useTablePagination({ + data: filtered, + filterDeps: [actionType, actionOperation, actionStatus, actionEnabled, accountId], + }); + + const columnNames = { + id: 'ID', + type: 'Action Type', + time: 'Time', + cronExpression: 'Cron Expression', + operation: 'Operation', + status: 'Status', + clusterId: 'Cluster ID', + accountId: 'Account ID', + region: 'Region', + enabled: 'Enabled', + }; + + return ( + <> + {isLoading ? ( + + ) : ( + + + + + + + + + + + + + + + + + {paginatedData.map(action => ( + + + + + + + + + + + + + + ))} + +
{columnNames.id}{columnNames.type}{columnNames.time}{columnNames.cronExpression}{columnNames.operation}{columnNames.status}{columnNames.clusterId}{columnNames.region}{columnNames.accountId}{columnNames.enabled}
{action.id}{renderActionTypeLabel(action.type)}{action.type !== ActionTypes.CRON_ACTION ? action.time : '-'} + {action.type === ActionTypes.CRON_ACTION ? action.cronExpression : '-'} + {renderOperationLabel(action.operation)}{renderActionStatusLabel(action.status)} + {action.clusterId} + {action.region} + {action.accountId} + + {action.enabled ? : } + + +
+ )} + + + ); +}; + +export default ScheduleActionsTable; diff --git a/console/src/app/Actions/Scheduler/components/ActionsToolBar.tsx b/console/src/app/Actions/Scheduler/components/ActionsToolBar.tsx new file mode 100644 index 00000000..6e52cb9e --- /dev/null +++ b/console/src/app/Actions/Scheduler/components/ActionsToolBar.tsx @@ -0,0 +1,731 @@ +import { + Badge, + Menu, + MenuContent, + MenuItem, + MenuList, + MenuToggle, + Popper, + SearchInput, + Toolbar, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import React from 'react'; +import debounce from 'lodash.debounce'; +import { ActionTypes, ActionOperations, ActionStatus } from '@app/types/types'; +import { usePopperContainer } from '@app/hooks/usePopperContainer'; + +type AttributeMenuOption = 'Account' | 'Action' | 'Type' | 'Status' | 'Enabled'; + +export interface SchedulerTableToolbarProps { + searchValue: string; + setSearchValue: (value: string) => void; + actionType: ActionTypes | null; + setType: (value: ActionTypes | null) => void; + actionOperation: ActionOperations[] | null; + setOperation: (value: ActionOperations[] | null) => void; + actionStatus: ActionStatus | null; + setStatus: (value: ActionStatus | null) => void; + actionEnabled: boolean | null; + setEnabled: (value: boolean | null) => void; +} + +export const ScheduleActionsTableToolbar: React.FunctionComponent = ({ + searchValue, + setSearchValue, + actionType, + setType, + actionOperation, + setOperation, + actionStatus, + setStatus, + actionEnabled, + setEnabled, +}) => { + const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); + + React.useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + // Set up account name search input + const searchInput = ( + debouncedSearch(value)} + onClear={() => debouncedSearch('')} + /> + ); + + // Actions filter setup + const [isActionMenuOpen, setIsActionMenuOpen] = React.useState(false); + const actionToggleRef = React.useRef(null); + const actionMenuRef = React.useRef(null); + const { containerRef: actionContainerRef, containerElement: actionContainerElement } = usePopperContainer(); + + const handleActionMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleActionClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleActionMenuKeysRef.current = (event: KeyboardEvent) => { + if (isActionMenuOpen && actionMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsActionMenuOpen(!isActionMenuOpen); + actionToggleRef.current?.focus(); + } + } + }; + + handleActionClickOutsideRef.current = (event: MouseEvent) => { + if (isActionMenuOpen && !actionMenuRef.current?.contains(event.target as Node)) { + setIsActionMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleActionMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleActionClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isActionMenuOpen]); + + const onActionMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (actionMenuRef.current) { + const firstElement = actionMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsActionMenuOpen(!isActionMenuOpen); + }; + + function onActionMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const selectedAction = itemId as ActionOperations; + setOperation( + actionOperation && actionOperation.includes(selectedAction) + ? actionOperation.filter(item => item !== selectedAction) + : selectedAction + ? [selectedAction, ...(actionOperation || [])] + : [] + ); + } + + const actionToggle = ( + 0 && { + badge: {actionOperation.length}, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + Filter by action + + ); + + const actionMenu = ( + + + + + {ActionOperations.POWER_ON} + + + {ActionOperations.POWER_OFF} + + + + + ); + + const actionSelect = ( +
+ +
+ ); + + // Type filter setup + const [isTypeMenuOpen, setIsTypeMenuOpen] = React.useState(false); + const typeToggleRef = React.useRef(null); + const typeMenuRef = React.useRef(null); + const { containerRef: typeContainerRef, containerElement: typeContainerElement } = usePopperContainer(); + + const handleTypeMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleTypeClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleTypeMenuKeysRef.current = (event: KeyboardEvent) => { + if (isTypeMenuOpen && typeMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsTypeMenuOpen(!isTypeMenuOpen); + typeToggleRef.current?.focus(); + } + } + }; + + handleTypeClickOutsideRef.current = (event: MouseEvent) => { + if (isTypeMenuOpen && !typeMenuRef.current?.contains(event.target as Node)) { + setIsTypeMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleTypeMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleTypeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isTypeMenuOpen]); + + const onTypeMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (typeMenuRef.current) { + const firstElement = typeMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsTypeMenuOpen(!isTypeMenuOpen); + }; + + function onTypeMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const selectedType = itemId as ActionTypes | null; + // Toggle type selection - clear if already selected, otherwise set new value + setType(actionType === selectedType ? null : selectedType); + setIsTypeMenuOpen(false); + } + + const typeToggleLabel = (t?: ActionTypes | null) => { + if (!t) return 'Filter by type'; + + switch (t) { + case ActionTypes.INSTANT_ACTION: + return 'Instant Action'; + case ActionTypes.SCHEDULED_ACTION: + return 'Scheduled Action'; + case ActionTypes.CRON_ACTION: + return 'Cron Action'; + default: + return 'Filter by type'; + } + }; + + const actionTypeLabel = (t: ActionTypes | null) => { + if (!t) return ''; + if (t === ActionTypes.INSTANT_ACTION) return 'Instant Action'; + if (t === ActionTypes.SCHEDULED_ACTION) return 'Scheduled Action'; + return 'Cron Action'; + }; + + const typeToggle = ( + 1, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + {typeToggleLabel(actionType)} + + ); + + const typeMenu = ( + + + + + Instant Action + + + Scheduled Action + + + Cron Action + + + + + ); + + const typeSelect = ( +
+ +
+ ); + + // Status filter setup + const [isStatusMenuOpen, setIsStatusMenuOpen] = React.useState(false); + const statusToggleRef = React.useRef(null); + const statusMenuRef = React.useRef(null); + const { containerRef: statusContainerRef, containerElement: statusContainerElement } = usePopperContainer(); + + const handleStatusMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleStatusClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleStatusMenuKeysRef.current = (event: KeyboardEvent) => { + if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsStatusMenuOpen(!isStatusMenuOpen); + statusToggleRef.current?.focus(); + } + } + }; + + handleStatusClickOutsideRef.current = (event: MouseEvent) => { + if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { + setIsStatusMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleStatusMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleStatusClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isStatusMenuOpen]); + + const onStatusMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (statusMenuRef.current) { + const firstElement = statusMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsStatusMenuOpen(!isStatusMenuOpen); + }; + + function onStatusMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const selectedStatus = itemId as ActionStatus | null; + // Toggle status selection + setStatus(status === selectedStatus ? null : selectedStatus); + setIsStatusMenuOpen(false); + } + + const statusToggle = ( + 1, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + {status || 'Filter by status'} + + ); + + const statusMenu = ( + + + + + {ActionStatus.Success} + + + {ActionStatus.Failed} + + + {ActionStatus.Pending} + + + {ActionStatus.Unknown} + + + + + ); + + const statusSelect = ( +
+ +
+ ); + + // Enabled filter setup + const [isEnabledMenuOpen, setIsEnabledMenuOpen] = React.useState(false); + const enabledToggleRef = React.useRef(null); + const enabledMenuRef = React.useRef(null); + const { containerRef: enabledContainerRef, containerElement: enabledContainerElement } = usePopperContainer(); + + const handleEnabledMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleEnabledClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleEnabledMenuKeysRef.current = (event: KeyboardEvent) => { + if (isEnabledMenuOpen && enabledMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsEnabledMenuOpen(false); + enabledToggleRef.current?.focus(); + } + } + }; + + handleEnabledClickOutsideRef.current = (event: MouseEvent) => { + if (isEnabledMenuOpen && !enabledMenuRef.current?.contains(event.target as Node)) { + setIsEnabledMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleEnabledMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleEnabledClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isEnabledMenuOpen]); + + const onEnabledMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (enabledMenuRef.current) { + const firstElement = enabledMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsEnabledMenuOpen(!isEnabledMenuOpen); + }; + + const onEnabledMenuSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + if (typeof itemId !== 'string') { + return; + } + + if (itemId === 'enabled') { + setEnabled(true); + } else if (itemId === 'disabled') { + setEnabled(false); + } else { + setEnabled(null); + } + }; + + const enabledToggleLabel = (v: boolean | null) => { + if (v === true) return 'Yes'; + if (v === false) return 'No'; + return 'Filter by enabled'; + }; + + const enabledToggle = ( + + {enabledToggleLabel(actionEnabled)} + + ); + + const enabledMenu = ( + + + + + Yes + + + No + + + + + ); + + const enabledSelect = ( +
+ +
+ ); + + // Attribute selector setup + const [activeAttributeMenu, setActiveAttributeMenu] = React.useState('Account'); + const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); + const attributeToggleRef = React.useRef(null); + const attributeMenuRef = React.useRef(null); + const { containerRef: attributeContainerRef, containerElement: attributeContainerElement } = usePopperContainer(); + + const handleAttributeMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleAttributeClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleAttributeMenuKeysRef.current = (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; + } + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }; + + handleAttributeClickOutsideRef.current = (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleAttributeMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleAttributeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isAttributeMenuOpen]); + + const onAttributeToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (attributeMenuRef.current) { + const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }; + + const attributeToggle = ( + } + > + {activeAttributeMenu} + + ); + + const attributeMenu = ( + { + const selected = itemId?.toString() as AttributeMenuOption; + setActiveAttributeMenu(selected); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }} + > + + + Account + Action + Type + Status + Enabled + + + + ); + + const attributeDropdown = ( +
+ +
+ ); + + return ( + { + setSearchValue(''); + setOperation(null); + setType(null); + setStatus(null); + setEnabled(null); + }} + > + + } breakpoint="xl"> + + {attributeDropdown} + setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} + categoryName="Account" + showToolbarItem={activeAttributeMenu === 'Account'} + > + {searchInput} + + onActionMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setOperation([])} + categoryName="Action" + showToolbarItem={activeAttributeMenu === 'Action'} + > + {actionSelect} + + setType(null)} + deleteLabelGroup={() => setType(null)} + categoryName="Type" + showToolbarItem={activeAttributeMenu === 'Type'} + > + {typeSelect} + + setStatus(null)} + deleteLabelGroup={() => setStatus(null)} + categoryName="Status" + showToolbarItem={activeAttributeMenu === 'Status'} + > + {statusSelect} + + setEnabled(null)} + deleteLabelGroup={() => setEnabled(null)} + categoryName="Enabled" + showToolbarItem={activeAttributeMenu === 'Enabled'} + > + {enabledSelect} + + + + + + ); +}; + +export default ScheduleActionsTableToolbar; diff --git a/console/src/app/Actions/Scheduler/components/ClusterSelector.tsx b/console/src/app/Actions/Scheduler/components/ClusterSelector.tsx new file mode 100644 index 00000000..0b637a84 --- /dev/null +++ b/console/src/app/Actions/Scheduler/components/ClusterSelector.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { + Button, + FormGroup, + Select, + SelectOption, + MenuToggle, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Tooltip, +} from '@patternfly/react-core'; +import { ClusterResponseApi } from '@api'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +interface ClusterTypeaheadSelectProps { + accountId: string | null; + clusters: ClusterResponseApi[]; + selectedCluster: ClusterResponseApi | null; + onSelectCluster: (cluster: ClusterResponseApi | null) => void; + onClearCluster: () => void; + isDisabled: boolean; +} + +export const ClusterTypeaheadSelect: React.FunctionComponent = ({ + clusters, + selectedCluster, + onClearCluster, + onSelectCluster, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + + const safeClusters = React.useMemo(() => (Array.isArray(clusters) ? clusters : []), [clusters]); + + const filteredClusters = React.useMemo(() => { + const q = inputValue.trim().toLowerCase(); + if (!q) return safeClusters; + + return safeClusters.filter(a => { + const haystack = `${a.clusterName ?? ''} ${a.clusterId ?? ''}`.toLowerCase(); + return haystack.includes(q); + }); + }, [safeClusters, inputValue]); + + const onSelect = (_event?: React.MouseEvent, value?: string | number) => { + const id = String(value ?? ''); + const acc = safeClusters.find(a => a.clusterId === id) ?? null; + + // Keep input in sync with selection for a predictable UX + setInputValue(acc ? `${acc.clusterName} (${acc.clusterId})` : ''); + onSelectCluster(acc); + setIsOpen(false); + }; + + return ( + + + + ); +}; diff --git a/console/src/app/Actions/Scheduler/components/DateTimePicker.tsx b/console/src/app/Actions/Scheduler/components/DateTimePicker.tsx new file mode 100644 index 00000000..164ad635 --- /dev/null +++ b/console/src/app/Actions/Scheduler/components/DateTimePicker.tsx @@ -0,0 +1,181 @@ +import { InputGroup, InputGroupItem, DatePicker, TimePicker, Button, Popover } from '@patternfly/react-core'; +import { GlobeAmericasIcon } from '@patternfly/react-icons'; +import React, { useState } from 'react'; +import { DateTime } from 'luxon'; + +interface DateTimePickerProps { + onChange: (formattedDateTime: string) => void; + width?: string; +} + +// Partil list of timezone +const timeZones = [ + // Universal + 'UTC', + + // Americas + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Toronto', + 'America/Vancouver', + 'America/Mexico_City', + 'America/Bogota', + 'America/Sao_Paulo', + 'America/Santiago', + 'America/Buenos_Aires', + + // Europe + 'Europe/London', + 'Europe/Dublin', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Madrid', + 'Europe/Rome', + 'Europe/Amsterdam', + 'Europe/Brussels', + 'Europe/Zurich', + 'Europe/Stockholm', + 'Europe/Vienna', + 'Europe/Warsaw', + 'Europe/Athens', + 'Europe/Istanbul', + 'Europe/Moscow', + 'Europe/Kiev', + + // Middle East + 'Asia/Jerusalem', + 'Asia/Amman', + 'Asia/Beirut', + 'Asia/Riyadh', + 'Asia/Dubai', + 'Asia/Baghdad', + 'Asia/Tehran', + + // Asia & Pacific + 'Asia/Kolkata', + 'Asia/Karachi', + 'Asia/Bangkok', + 'Asia/Jakarta', + 'Asia/Shanghai', + 'Asia/Hong_Kong', + 'Asia/Taipei', + 'Asia/Seoul', + 'Asia/Tokyo', + 'Asia/Singapore', + 'Asia/Manila', + + // Oceania + 'Australia/Sydney', + 'Australia/Melbourne', + 'Australia/Brisbane', + 'Australia/Perth', + 'Pacific/Auckland', + + // Africa + 'Africa/Cairo', + 'Africa/Lagos', + 'Africa/Nairobi', + 'Africa/Johannesburg', +]; + +const DateTimePicker: React.FunctionComponent = ({ onChange, width = '300px' }) => { + const [isTimeZoneOpen, setIsTimeZoneOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState(undefined); + const [selectedTimeZone, setSelectedTimeZone] = useState('UTC'); + + // Handle date selection + const onDateChange = (_event: React.FormEvent, _value: string, date?: Date) => { + if (date) { + setSelectedDate(date); + updateDateTime(date, selectedTimeZone); + } + }; + + // Handle time selection + const onTimeChange = (_event: React.FormEvent, _time: string, hour?: number, minute?: number) => { + if (selectedDate && hour !== undefined && minute !== undefined) { + const updatedDate = new Date(selectedDate); + updatedDate.setHours(hour, minute); + setSelectedDate(updatedDate); + updateDateTime(updatedDate, selectedTimeZone); + } + }; + + // Handle timezone selection + const onSelectTimeZone = (event: React.MouseEvent) => { + const newTimeZone = event.currentTarget.textContent as string; + setSelectedTimeZone(newTimeZone); + setIsTimeZoneOpen(false); + + if (selectedDate) { + updateDateTime(selectedDate, newTimeZone); + } + }; + + // Convert to ISO 8601 Format with offset + const updateDateTime = (date: Date, timeZone: string) => { + const zonedDateTime = DateTime.fromJSDate(date, { zone: timeZone }).toISO(); + if (zonedDateTime) onChange(zonedDateTime); + }; + + return ( +
+ + + + + + + + + + {timeZones.map(zone => ( +
+ {zone} +
+ ))} +
+ } + showClose={false} + isVisible={isTimeZoneOpen} + hasNoPadding + hasAutoWidth + appendTo={document.body} + zIndex={9999} + > + + + + + + ); +}; + +export default DateTimePicker; diff --git a/console/src/app/Actions/Scheduler/components/ModalPowerManagement.tsx b/console/src/app/Actions/Scheduler/components/ModalPowerManagement.tsx new file mode 100644 index 00000000..d6931404 --- /dev/null +++ b/console/src/app/Actions/Scheduler/components/ModalPowerManagement.tsx @@ -0,0 +1,357 @@ +import { + Button, + Checkbox, + FormHelperText, + FormGroup, + Form, + TextInput, + HelperText, + HelperTextItem, + Radio, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { Modal, ModalVariant } from '@patternfly/react-core/deprecated'; +import React from 'react'; +import { ActionOperations, ActionTypes } from '@app/types/types'; +import DateTimePicker from './DateTimePicker'; +import { AccountTypeaheadSelect } from './AccountSelector'; +import { ClusterTypeaheadSelect } from './ClusterSelector'; +import { ActionStatus } from '@app/types/types'; +import { useUser } from '@app/Contexts/UserContext.tsx'; +import { debug } from '@app/utils/debugLogs'; +import { api, startCluster, stopCluster, AccountResponseApi, ClusterResponseApi, ActionRequestApi } from '@api'; +import cronValidate from 'cron-validate'; + +interface ModalPowerManagementProps { + isOpen: boolean; + onClose: () => void; + onCreated: () => void; +} + +export const ModalPowerManagement: React.FunctionComponent = ({ + isOpen, + onClose, + onCreated, +}) => { + const { userEmail } = useUser(); + + // Modal From parameters + const [selectedAccount, setSelectedAccount] = React.useState(null); + const [selectedCluster, setSelectedCluster] = React.useState(null); + const [actionOperation, setActionOperation] = React.useState(''); + const [scheduledDateTime, setScheduledDateTime] = React.useState(''); + const [showSchedule, setShowSchedule] = React.useState(false); + const [cronExpression, setCronExpression] = React.useState(''); + const [cronTouched, setCronTouched] = React.useState(false); + const [description, setDescription] = React.useState(''); + const [showDescriptionField, setShowDescriptionField] = React.useState(false); + + // Account/Cluster typeahead vars + const [allAccounts, setAllAccounts] = React.useState([]); + const [allClusters, setAllClusters] = React.useState([]); + + // TODO: restore Loading spinner + //const [loading, setLoading] = React.useState(true); + + // Action type selection + const [actionType, setActionType] = React.useState(ActionTypes.INSTANT_ACTION); + + const isValidCronExpression = (expr: string): boolean => { + if (!expr.trim()) return false; + const result = cronValidate(expr, { preset: 'default' }); + return result.isValid(); + }; + + const isCommonValid = !!selectedAccount && !!selectedCluster && actionOperation.trim() !== ''; + + const isExecutionValid = + actionType === ActionTypes.INSTANT_ACTION || + (actionType === ActionTypes.SCHEDULED_ACTION && scheduledDateTime !== '') || + (actionType === ActionTypes.CRON_ACTION && cronExpression !== '' && isValidCronExpression(cronExpression)); + + const isFormValid = isCommonValid && isExecutionValid; + + // Typeahead clear button functions + const onAccountClearButtonClick = () => { + setSelectedAccount(null); + }; + + const onClusterClearButtonClick = () => { + setSelectedCluster(null); + }; + + // Load accounts when the modal opens. + React.useEffect(() => { + if (!isOpen) return; + + const controller = new AbortController(); + + const fetchAccounts = async () => { + try { + const { data } = await api.accounts.accountsList({ page: 1, page_size: 10000 }, { signal: controller.signal }); + if (!controller.signal.aborted) { + setAllAccounts(data.items || []); + } + } catch (error) { + if (!controller.signal.aborted) { + console.error('Error fetching accounts:', error); + setAllAccounts([]); + } + } + }; + + fetchAccounts(); + return () => controller.abort(); + }, [isOpen]); + + React.useEffect(() => { + if (!isOpen) return; + + setSelectedCluster(null); + setAllClusters([]); + + const accountId = selectedAccount?.accountId; + if (!accountId) return; + + const controller = new AbortController(); + + const fetchClusters = async () => { + try { + const { data } = await api.accounts.clustersList(accountId, { signal: controller.signal }); + if (!controller.signal.aborted) { + setAllClusters(data.items || []); + } + } catch (error) { + if (!controller.signal.aborted) { + console.error('Error fetching clusters:', error); + setAllClusters([]); + } + } + }; + + fetchClusters(); + return () => controller.abort(); + }, [isOpen, selectedAccount?.accountId]); + + // Reset modal state when closing to avoid leaking previous selections. + React.useEffect(() => { + if (isOpen) return; + + setShowDescriptionField(false); + setDescription(''); + setShowSchedule(false); + setActionType(ActionTypes.INSTANT_ACTION); + setScheduledDateTime(''); + setCronExpression(''); + setCronTouched(false); + setSelectedAccount(null); + setSelectedCluster(null); + setAllClusters([]); + }, [isOpen]); + + const handlerConfirmActionCreation = async () => { + // Ensure clusterID is not undefined before performing any action + if (!selectedCluster?.clusterId) { + console.error('ClusterID is undefined. Cannot perform scheduled action'); + return; + } + + const finalDescription = showDescriptionField ? description : 'Routine maintenance'; + + // Instant Action run + if (actionType === ActionTypes.INSTANT_ACTION) { + if (actionOperation === ActionOperations.POWER_ON) { + debug('Powering on the cluster'); + startCluster(selectedCluster?.clusterId, userEmail ?? undefined, finalDescription); + } else if (actionOperation === ActionOperations.POWER_OFF) { + debug('Powering off the cluster'); + stopCluster(selectedCluster?.clusterId, userEmail ?? undefined, finalDescription); + } + } else { + // Creating base action. Tunning depending on ActionType + const powerActionRequest = { + accountId: selectedAccount?.accountId, + clusterId: selectedCluster?.clusterId, + enabled: true, + operation: actionOperation, + region: selectedCluster?.region, + status: ActionStatus.Pending, + description: finalDescription, + } as ActionRequestApi; + + // Scheduled Action run + if (actionType === ActionTypes.SCHEDULED_ACTION) { + // TODO: Convert to data validation + if (!scheduledDateTime) { + console.error('Scheduled DateTime is required'); + return; + } + powerActionRequest.type = ActionTypes.SCHEDULED_ACTION; + powerActionRequest.time = scheduledDateTime; + // Cron Action run + } else if (actionType === ActionTypes.CRON_ACTION) { + // TODO: Convert to data validation + if (!cronExpression.trim()) { + console.error('Cron expression is empty'); + return; + } + + powerActionRequest.type = ActionTypes.CRON_ACTION; + powerActionRequest.cronExpression = cronExpression.trim(); + } + + const powerActionRequests: ActionRequestApi[] = [powerActionRequest]; + console.log(powerActionRequest); + await api.actions.actionsCreate(powerActionRequests); + onCreated(); + onClose(); + } + + onClose(); + }; + + // Do not render anything if there is no action + if (!isOpen || !actionType) { + return null; + } + + // Scheduling modal for Schedule action + return ( + + Confirm + , + , + ]} + appendTo={document.body} + > + {/* Account selection */} + +
+ { + setSelectedAccount(account); + }} + onClearAccount={onAccountClearButtonClick} + /> + { + setSelectedCluster(cluster); + }} + isDisabled={!selectedAccount} + onClearCluster={onClusterClearButtonClick} + /> + + {/* Action selection */} + + setActionOperation(ActionOperations.POWER_ON)} + /> + setActionOperation(ActionOperations.POWER_OFF)} + /> + + + {/* Schedule management */} + + { + setShowSchedule(checked); + setActionType(checked ? ActionTypes.SCHEDULED_ACTION : ActionTypes.INSTANT_ACTION); + }} + /> + + {showSchedule && ( + <> + + setActionType(ActionTypes.SCHEDULED_ACTION)} + /> + + {actionType === ActionTypes.SCHEDULED_ACTION && ( + + + + )} + + setActionType(ActionTypes.CRON_ACTION)} + /> + + {actionType === ActionTypes.CRON_ACTION && ( + + setCronExpression(value)} + onBlur={() => setCronTouched(true)} + validated={cronTouched && !isValidCronExpression(cronExpression) ? 'error' : 'default'} + placeholder="0 0 * * *" + /> + + + : undefined + } + > + {cronTouched && !isValidCronExpression(cronExpression) + ? 'Invalid cron expression' + : "Format: minute hour day-of-month month day-of-week (e.g., '0 0 * * *' for daily at midnight)"} + + + + + )} + + )} + + {/* Description management */} + + setDescription(value)} + placeholder="Enter reason" + aria-label="Reason for action" + /> + + +
+ ); +}; diff --git a/console/src/app/AppLayout/AboutModal.tsx b/console/src/app/AppLayout/AboutModal.tsx new file mode 100644 index 00000000..a67ddafa --- /dev/null +++ b/console/src/app/AppLayout/AboutModal.tsx @@ -0,0 +1,42 @@ +import { AboutModal, Content } from '@patternfly/react-core'; +import React from 'react'; +import brandImg from '../../assets/favicon.png'; +import modalBackground from '../../assets/modal_background.png'; +import { APP_VERSION, PRODUCT_NAME, MAINTAINER_NAME, REPOSITORY_URL } from '@app/constants'; + +interface AboutModalComponentProps { + isOpen: boolean; + onClose: () => void; +} + +const AboutModalComponent: React.FunctionComponent = ({ isOpen, onClose }) => { + return ( + + + + Version + {APP_VERSION} + + Maintainer + {MAINTAINER_NAME} + + Repository + + + GitHub + + + + + + ); +}; + +export default AboutModalComponent; diff --git a/console/src/app/AppLayout/AppLayout.tsx b/console/src/app/AppLayout/AppLayout.tsx new file mode 100644 index 00000000..0d9bb9a3 --- /dev/null +++ b/console/src/app/AppLayout/AppLayout.tsx @@ -0,0 +1,276 @@ +import * as React from 'react'; +import { + Page, + Masthead, + MastheadToggle, + MastheadMain, + MastheadLogo, + MastheadBrand, + MastheadContent, + PageSidebar, + PageSidebarBody, + PageToggleButton, + Toolbar, + ToolbarContent, + ToolbarItem, + Dropdown, + DropdownItem, + MenuToggle, + ToolbarGroup, + DropdownList, + Tooltip, +} from '@patternfly/react-core'; + +import { QuestionCircleIcon, ExternalLinkAltIcon, MoonIcon, SunIcon } from '@patternfly/react-icons'; +import logoImg from '../../assets/favicon.png'; +import SidebarNavigation from './SidebarNavigation'; +import { useUser } from '../Contexts/UserContext'; +import { Link } from 'react-router-dom'; +import AboutModalComponent from './AboutModal'; +import { REPOSITORY_URL } from '@app/constants'; +interface IAppLayout { + children: React.ReactNode; +} + +const PF_BREAKPOINT_XL = 1200; + +const AppLayout: React.FunctionComponent = ({ children }) => { + const { userEmail, setUserEmail } = useUser(); + const [isSidebarOpen, setIsSidebarOpen] = React.useState(false); + const [isHelpMenuOpen, setIsHelpMenuOpen] = React.useState(false); + const [isAboutModalOpen, setIsAboutModalOpen] = React.useState(false); + const [isUserDropdownOpen, setIsUserDropdownOpen] = React.useState(false); + const [isDarkTheme, setIsDarkTheme] = React.useState(() => { + const saved = localStorage.getItem('theme'); + if (saved) return saved === 'dark'; + + // Fallback to browser preference + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + const isDesktop = () => window.innerWidth >= PF_BREAKPOINT_XL; + const previousDesktopState = React.useRef(isDesktop()); + + const defaultHelpLinks = [ + { + label: 'Documentation', + onClick: () => window.open(REPOSITORY_URL, '_blank'), + isExternal: true, + }, + { + label: 'About', + onClick: () => setIsAboutModalOpen(true), + }, + ]; + + const helpDropdownItems = defaultHelpLinks.map(link => { + const content = ( + <> + {link.label} + {link.isExternal && ( + + {' '} + + + )} + + ); + return ( + + {content} + + ); + }); + // Reference https://github.com/openshift/oauth-proxy?tab=readme-ov-file#endpoint-documentation + const handleLogout = () => { + setUserEmail(null); + window.location.href = '/oauth/sign_in'; + }; + + const onUserDropdownToggle = () => { + setIsUserDropdownOpen(prev => !prev); + }; + + const onUserDropdownSelect = () => { + setIsUserDropdownOpen(false); + }; + + const userDropdownItems = [ + + Log out + , + ]; + + const onResize = React.useCallback(() => { + const desktop = isDesktop(); + if (desktop !== previousDesktopState.current) { + setIsSidebarOpen(false); + } else if (desktop) { + setIsSidebarOpen(true); + } + previousDesktopState.current = desktop; + }, []); + + const onSidebarToggle = () => { + setIsSidebarOpen(prev => !prev); + }; + + React.useEffect(() => { + window.addEventListener('resize', onResize); + if (isDesktop()) { + setIsSidebarOpen(true); + } + return () => { + window.removeEventListener('resize', onResize); + }; + }, [onResize]); + + React.useEffect(() => { + const htmlElement = document.documentElement; + if (isDarkTheme) { + htmlElement.classList.add('pf-v6-theme-dark'); + localStorage.setItem('theme', 'dark'); + } else { + htmlElement.classList.remove('pf-v6-theme-dark'); + localStorage.setItem('theme', 'light'); + } + }, [isDarkTheme]); + + React.useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + const saved = localStorage.getItem('theme'); + if (!saved) { + setIsDarkTheme(e.matches); + } + }; + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const toggleTheme = () => { + setIsDarkTheme(prev => !prev); + }; + + const headerToolbar = ( + + + + + setIsHelpMenuOpen(false)} + popperProps={{ + position: 'right', + }} + toggle={toggleRef => ( + + setIsHelpMenuOpen(!isHelpMenuOpen)} + isExpanded={isHelpMenuOpen} + > + + + + )} + > + {helpDropdownItems} + + + + + + {isDarkTheme ? : } + + + + + ( + + {userEmail || 'User'} + + )} + > + {userDropdownItems} + + + + + + ); + + const header = ( + + + + + + + + + } + > + ClusterIQ + + ClusterIQ + + + + + {headerToolbar} + + ); + + const sidebar = ( + + + + + + ); + + const pageId = 'primary-app-container'; + + return ( + <> + + {children} + + setIsAboutModalOpen(false)}> + + ); +}; + +export { AppLayout }; diff --git a/console/src/app/AppLayout/SidebarNavigation.tsx b/console/src/app/AppLayout/SidebarNavigation.tsx new file mode 100644 index 00000000..dac570a4 --- /dev/null +++ b/console/src/app/AppLayout/SidebarNavigation.tsx @@ -0,0 +1,65 @@ +import { Nav, NavExpandable, NavItem, NavList } from '@patternfly/react-core'; +import React from 'react'; +import { NavLink, useLocation } from 'react-router-dom'; + +const SidebarNavigation: React.FunctionComponent = () => { + const location = useLocation(); + + const isInventoryExpanded = + location.pathname.startsWith('/accounts') || + location.pathname.startsWith('/clusters') || + location.pathname.startsWith('/instances'); + + //const isScanExpanded = location.pathname.startsWith('/scan'); + // + // + // + // Schedule + // + // + // + const isActionsExpanded = location.pathname.startsWith('/actions'); + + return ( + + ); +}; + +export default SidebarNavigation; diff --git a/console/src/app/ClusterDetails/ClusterDetails.tsx b/console/src/app/ClusterDetails/ClusterDetails.tsx new file mode 100644 index 00000000..dcd393b2 --- /dev/null +++ b/console/src/app/ClusterDetails/ClusterDetails.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import ClusterDetailsOverview from './components/ClusterDetailsOverview'; + +const ClusterDetails: React.FunctionComponent = () => { + return ; +}; + +export default ClusterDetails; diff --git a/console/src/app/ClusterDetails/components/ClusterActionConfirm.tsx b/console/src/app/ClusterDetails/components/ClusterActionConfirm.tsx new file mode 100644 index 00000000..dd762fc8 --- /dev/null +++ b/console/src/app/ClusterDetails/components/ClusterActionConfirm.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Button, Content } from '@patternfly/react-core'; +import { Modal, ModalVariant } from '@patternfly/react-core/deprecated'; +import { ActionOperations } from '@app/types/types'; + +interface ClusterActionConfirmProps { + isOpen: boolean; + clusterId: string; + actionOperation: ActionOperations | null; + onConfirm: () => void; + onClose: () => void; +} + +export const ClusterActionConfirm: React.FunctionComponent = ({ + isOpen, + clusterId, + actionOperation, + onConfirm, + onClose, +}) => { + if (!isOpen || !actionOperation) { + return null; + } + + return ( + + Confirm + , + , + ]} + > + + Are you sure you want to {actionOperation} the cluster {clusterId}? + + + ); +}; diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsDropdown.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsDropdown.tsx new file mode 100644 index 00000000..1926639d --- /dev/null +++ b/console/src/app/ClusterDetails/components/ClusterDetailsDropdown.tsx @@ -0,0 +1,84 @@ +import { startCluster, stopCluster, ResourceStatusApi } from '@api'; +import { Dropdown, DropdownItem, DropdownList, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { ActionOperations } from '@app/types/types'; +import { ClusterActionConfirm } from './ClusterActionConfirm'; +import { useUser } from '@app/Contexts/UserContext.tsx'; + +interface ClusterDetailsDropdownProps { + clusterStatus: ResourceStatusApi | null; +} + +export const ClusterDetailsDropdown: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [actionOperation, setActionOperation] = React.useState(null); + + const { clusterID } = useParams(); + const { userEmail } = useUser(); + + const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + const operation = value as ActionOperations; + + if (operation === ActionOperations.POWER_ON || operation === ActionOperations.POWER_OFF) { + // Open modal with selected operation + setActionOperation(operation); + setIsModalOpen(true); + } + + setIsOpen(false); + }; + + const actionCreate = (clusterId: string, operation: string, userEmail: string, description: string) => { + if (operation === ActionOperations.POWER_ON) { + startCluster(clusterId, userEmail, description); + } else if (operation === ActionOperations.POWER_OFF) { + stopCluster(clusterId, userEmail, description); + } else { + console.error('Operation not supported for InstantAction'); + } + }; + + const resetModalState = () => { + setIsModalOpen(false); + setActionOperation(null); + }; + + return ( + <> + ) => ( + setIsOpen(v => !v)} isExpanded={isOpen}> + Actions + + )} + > + + + {ActionOperations.POWER_ON} + + + {ActionOperations.POWER_OFF} + + + + + { + if (!clusterID || !actionOperation) return; + actionCreate(clusterID, actionOperation, userEmail!, 'instant-action'); + resetModalState(); + }} + onClose={resetModalState} + actionOperation={actionOperation} + clusterId={clusterID!} + /> + + ); +}; diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx new file mode 100644 index 00000000..853fc2ad --- /dev/null +++ b/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx @@ -0,0 +1,112 @@ +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { ResultStatus } from '@app/types/types'; +import { api, SystemEventResponseApi } from '@api'; +import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import React, { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { getResultIcon } from '@app/utils/renderUtils'; +import { useTableSort } from '@app/hooks/useTableSort.tsx'; +import { EmptyState } from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; + +interface TableEventsProps { + data: SystemEventResponseApi[]; + getSortParams: (columnIndex: number) => ThProps['sort']; +} + +const columnNames = { + action: 'Action', + result: 'Result', + severity: 'Severity', + loggedBy: 'Logged by', + description: 'Description', + date: 'Date', +}; + +export const EmptyStateNoFound: React.FunctionComponent = () => ( + +); + +const TableEvents: React.FunctionComponent = ({ data, getSortParams }) => { + return ( + + + + + + + + + + + + + {data.map(event => ( + + + + + + + + + ))} + +
{columnNames.action}{columnNames.result}{columnNames.severity}{columnNames.loggedBy}{columnNames.description}{columnNames.date}
{event.action} + {getResultIcon(event.result as ResultStatus)} {event.result} + {event.severity}{event.triggeredBy}{event.description}{event.timestamp}
+ ); +}; + +export const ClusterDetailsEvents: React.FunctionComponent = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const { clusterID } = useParams(); + + useEffect(() => { + if (!clusterID) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchData = async () => { + try { + const { data: clusterEvents } = await api.clusters.eventsList(clusterID); + if (!cancelled) { + // TODO. Move to debug + console.log('Fetched events:', clusterEvents); + setData(clusterEvents.items || []); + } + } catch (error) { + if (!cancelled) { + // TODO. Move to debug + console.error('Error fetching events:', error); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + fetchData(); + + return () => { + cancelled = true; + }; + }, [clusterID]); + + // TODO. Move to debug + console.log('Rendered events data:', data); + + const getSortableRowValues = (event: SystemEventResponseApi): (string | number | null)[] => { + const { action, result, severity, triggeredBy, description: description, timestamp } = event; + return [action, result, severity, triggeredBy, description ?? null, timestamp]; + }; + + const { sortedData, getSortParams } = useTableSort(data, getSortableRowValues, 5, 'desc'); + if (loading) return ; + if (sortedData.length === 0) return ; + return ; +}; diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx new file mode 100644 index 00000000..02a77874 --- /dev/null +++ b/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx @@ -0,0 +1,100 @@ +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { renderStatusLabel } from '@app/utils/renderUtils'; +import { sortItems } from '@app/utils/tableFilters'; +import { api, InstanceResponseApi } from '@api'; +import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import React, { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; + +const ClusterDetailsInstances: React.FunctionComponent = () => { + const { clusterID } = useParams(); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + // Index of the currently active column + const [activeSortIndex, setActiveSortIndex] = useState(1); + const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('asc'); + + useEffect(() => { + const fetchData = async () => { + try { + console.log('Fetching data...'); + const { data: fetchedInstancesPerCluster } = await api.clusters.instancesList(clusterID!); + console.log('Fetched data:', fetchedInstancesPerCluster); + setData(fetchedInstancesPerCluster); + } catch (error) { + console.error('Error fetching data:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [clusterID]); + + if (!clusterID) { + return ; + } + + console.log('Rendered with data:', data); + + let sortedData = data; + if (activeSortIndex !== undefined && activeSortDirection) { + const sortFields: (keyof InstanceResponseApi)[] = [ + 'instanceId', + 'instanceName', + 'instanceType', + 'availabilityZone', + ]; + sortedData = sortItems(data, sortFields[activeSortIndex], activeSortDirection); + } + + // set table column properties + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc', // starting sort direction when first sorting a column. Defaults to 'asc' + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + + return ( + + {loading ? ( + + ) : ( + + + + + + + + + + + + {sortedData.map(instance => ( + + + + + + + + ))} + +
IDNameTypeStatusAvailabilityZone
+ {instance.instanceId} + {instance.instanceName}{instance.instanceType}{renderStatusLabel(instance.status)}{instance.availabilityZone}
+ )} +
+ ); +}; + +export default ClusterDetailsInstances; diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx new file mode 100644 index 00000000..f2f6db3e --- /dev/null +++ b/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx @@ -0,0 +1,224 @@ +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { parseNumberToCurrency, parseScanTimestamp } from '@app/utils/parseFuncs'; +import { renderStatusLabel } from '@app/utils/renderUtils'; +import { ClusterResponseApi, TagResponseApi } from '@api'; +import { + Flex, + FlexItem, + Title, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + TabContentBody, + PageSection, + Label, + Divider, + Tabs, + Tab, + TabTitleText, + TabContent, +} from '@patternfly/react-core'; +import React, { useState, useEffect, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { ClusterDetailsDropdown } from './ClusterDetailsDropdown'; +import { ClusterDetailsEvents } from './ClusterDetailsEvents'; +import { api } from '@api'; +import ClusterDetailsInstances from './ClusterDetailsInstances'; +import { LabelGroupOverflow } from '@app/components/common/LabelGroupOverflow'; + +const ClusterDetailsOverview: React.FunctionComponent = () => { + const { clusterID } = useParams(); + const [activeTabKey, setActiveTabKey] = React.useState(0); + const [tags, setTagData] = useState([]); + const [cluster, setClusterData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!clusterID) return; + + const fetchData = async () => { + try { + const { data: fetchedCluster } = await api.clusters.clustersDetail(clusterID!); + setClusterData(fetchedCluster); + const { data: fetchedTags } = await api.clusters.tagsList(clusterID!); + setTagData(fetchedTags); + } catch (error) { + console.error('Error fetching data:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [clusterID]); + + const filterTagsByKey = key => { + const result = tags.filter(tag => tag.key == key); + if (result[0] !== undefined && result[0] != null) { + return result[0].value; + } + return 'unknown'; + }; + + const ownerTag = filterTagsByKey('Owner'); + const partnerTag = filterTagsByKey('Partner'); + + const handleTabClick = (_, tabIndex) => { + setActiveTabKey(tabIndex); + }; + + const detailsTabContent = ( + + {loading ? ( + + ) : ( + + + + Cluster details + + + + + + + Name + {cluster?.clusterName} + Infrastructure ID + {cluster?.infraId} + Status + {renderStatusLabel(cluster?.status)} + + + + Web console + + + Console + + + Number of nodes + {String(cluster?.instanceCount)} + + + + Cloud Provider + {cluster?.provider} + Account + {cluster?.accountId || 'unknown'} + Region + {cluster?.region || 'unknown'} + + + + Created at + {parseScanTimestamp(cluster?.createdAt)} + Last scanned at + + {parseScanTimestamp(cluster?.lastScanTimestamp)} + + Age (days) + {cluster?.age} + + + + Labels + + Partner + {partnerTag} + Owner + {ownerTag} + + + + + Cluster Total Cost (Estimated since the cluster is being scanned) + + {parseNumberToCurrency(cluster?.totalCost)} + Cluster Total (Current month so far) + + {parseNumberToCurrency(cluster?.currentMonthSoFarCost)} + + Cluster Total (Last 15 days) + + {parseNumberToCurrency(cluster?.last15DaysCost)} + + Cluster Total (Last Month) + {parseNumberToCurrency(cluster?.lastMonthCost)} + + + + + )} + + ); + + const serversTabContent = useMemo( + () => ( + + + + ), + [] + ); + + const eventsTabContent = useMemo( + () => ( + + + + ), + [] + ); + + return ( + + + + + + + + + + {clusterID} + + + + + + + + {/* Page tabs */} + + + + + Details} tabContentId={`tabContent${0}`} /> + Servers} tabContentId={`tabContent${1}`} /> + Events} tabContentId={`tabContent${2}`} /> + + + + + + + + + ); +}; +export default ClusterDetailsOverview; diff --git a/console/src/app/ClusterDetails/components/types.ts b/console/src/app/ClusterDetails/components/types.ts new file mode 100644 index 00000000..e69de29b diff --git a/console/src/app/Clusters/Clusters.tsx b/console/src/app/Clusters/Clusters.tsx new file mode 100644 index 00000000..99a8a147 --- /dev/null +++ b/console/src/app/Clusters/Clusters.tsx @@ -0,0 +1,56 @@ +import { PageSection, Panel, Content } from '@patternfly/react-core'; +import React from 'react'; +import ClustersTable from './components/ClustersTable'; +import ClustersTableToolbar from './components/ClustersTableToolbar'; +import { parseAsArrayOf, parseAsString, parseAsStringEnum, parseAsBoolean, useQueryStates } from 'nuqs'; +import { ResourceStatusApi, ProviderApi } from '@api'; + +const filterParams = { + status: { + ...parseAsStringEnum(Object.values(ResourceStatusApi)), + defaultValue: null as ResourceStatusApi | null, + }, + provider: parseAsArrayOf(parseAsStringEnum(Object.values(ProviderApi))).withDefault([]), + clusterName: parseAsString.withDefault(''), + accountName: parseAsString.withDefault(''), + showTerminated: parseAsBoolean.withDefault(false), +}; + +const Clusters: React.FunctionComponent = () => { + const [{ status, provider, clusterName, accountName, showTerminated }, setQuery] = useQueryStates(filterParams); + + return ( + + + + Clusters + + + + + setQuery({ clusterName: value })} + accountNameSearch={accountName} + setAccountNameSearch={value => setQuery({ accountName: value })} + statusSelection={status} + setStatusSelection={value => setQuery({ status: value })} + providerSelections={provider} + setProviderSelections={value => setQuery({ provider: value || [] })} + showTerminated={showTerminated} + setShowTerminated={value => setQuery({ showTerminated: value })} + /> + + + + + ); +}; + +export default Clusters; diff --git a/console/src/app/Clusters/components/ClustersTable.tsx b/console/src/app/Clusters/components/ClustersTable.tsx new file mode 100644 index 00000000..c73c4b7e --- /dev/null +++ b/console/src/app/Clusters/components/ClustersTable.tsx @@ -0,0 +1,179 @@ +import { renderStatusLabel } from '@app/utils/renderUtils'; +import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import React, { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { ClusterResponseApi } from '@api'; +import { ClustersTableProps } from '../types'; +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TablePagination } from '@app/components/common/TablesPagination'; +import { searchItems, filterByStatus, filterByProvider, sortItems } from '@app/utils/tableFilters'; +import { EmptyState, EmptyStateVariant, EmptyStateBody, Title } from '@patternfly/react-core'; +import { CubesIcon } from '@patternfly/react-icons'; +import { useClusters } from '@app/hooks/useClusters'; +import { useTablePagination } from '@app/hooks/useTablePagination'; + +export const ClustersTable: React.FunctionComponent = ({ + clusterNameSearch, + accountNameSearch, + statusFilter, + providerSelections, + showTerminated, +}) => { + const { data: allClusters = [], isLoading } = useClusters(); + + const [activeSortIndex, setActiveSortIndex] = useState(0); + const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('asc'); + + const filtered = useMemo(() => { + let processed = allClusters; + + if (!showTerminated) { + processed = processed.filter(cluster => cluster.status !== 'Terminated'); + } + + if (clusterNameSearch) { + processed = searchItems(processed, clusterNameSearch, ['clusterName']); + } + + if (accountNameSearch) { + processed = searchItems(processed, accountNameSearch, ['accountName']); + } + + processed = filterByStatus(processed, statusFilter); + processed = filterByProvider(processed, providerSelections); + + if (activeSortIndex !== undefined && activeSortDirection) { + const sortFields: (keyof ClusterResponseApi)[] = [ + 'clusterId', + 'clusterName', + 'status', + 'accountId', + 'provider', + 'region', + 'instanceCount', + 'consoleLink', + ]; + processed = sortItems(processed, sortFields[activeSortIndex], activeSortDirection); + } + + return processed; + }, [ + allClusters, + showTerminated, + clusterNameSearch, + accountNameSearch, + statusFilter, + providerSelections, + activeSortIndex, + activeSortDirection, + ]); + + const { page, perPage, setPage, setPerPage, paginatedData, totalItems } = useTablePagination({ + data: filtered, + filterDeps: [clusterNameSearch, accountNameSearch, statusFilter, providerSelections, showTerminated], + }); + + const columnNames = { + id: 'ID', + name: 'Name', + status: 'Status', + account: 'Account', + cloudProvider: 'Cloud Provider', + region: 'Region', + nodes: 'Nodes', + console: 'Web console', + }; + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + + if (isLoading) { + return ; + } + + if (filtered.length === 0) { + return ( + + No clusters found + + } + icon={CubesIcon} + variant={EmptyStateVariant.sm} + > + + {!showTerminated ? ( + <> + There are no active clusters. +
+ Toggle 'Show terminated clusters' to view all clusters. + + ) : ( + 'No clusters found.' + )} +
+
+ ); + } + + return ( + + + + + + + + + + + + + + + + {paginatedData.map(cluster => ( + + + + + + + + + + + ))} + +
{columnNames.id}{columnNames.name}{columnNames.status}{columnNames.account}{columnNames.cloudProvider}{columnNames.region}{columnNames.nodes}{columnNames.console}
+ {cluster.clusterId} + {cluster.clusterName}{renderStatusLabel(cluster.status)} + {cluster.accountName} + {cluster.provider}{cluster.region}{cluster.instanceCount} + + Console + +
+ +
+ ); +}; + +export default ClustersTable; diff --git a/console/src/app/Clusters/components/ClustersTableToolbar.tsx b/console/src/app/Clusters/components/ClustersTableToolbar.tsx new file mode 100644 index 00000000..bb6ee5b5 --- /dev/null +++ b/console/src/app/Clusters/components/ClustersTableToolbar.tsx @@ -0,0 +1,464 @@ +import { + SearchInput, + MenuToggle, + Menu, + MenuContent, + MenuList, + MenuItem, + Popper, + Badge, + Toolbar, + ToolbarContent, + ToolbarToggleGroup, + ToolbarGroup, + ToolbarItem, + ToolbarFilter, + Switch, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { ClustersTableToolbarProps } from '../types'; +import debounce from 'lodash.debounce'; +import { ResourceStatusApi, ProviderApi } from '@api'; +import { usePopperContainer } from '@app/hooks/usePopperContainer'; + +export const ClustersTableToolbar: React.FunctionComponent = ({ + clusterNameSearch, + setClusterNameSearch, + accountNameSearch, + setAccountNameSearch, + statusSelection, + setStatusSelection, + providerSelections, + setProviderSelections, + showTerminated, + setShowTerminated, +}) => { + const debouncedClusterSearch = React.useMemo(() => debounce(setClusterNameSearch, 300), [setClusterNameSearch]); + const debouncedAccountSearch = React.useMemo(() => debounce(setAccountNameSearch, 300), [setAccountNameSearch]); + + React.useEffect(() => { + return () => { + debouncedClusterSearch.cancel(); + debouncedAccountSearch.cancel(); + }; + }, [debouncedClusterSearch, debouncedAccountSearch]); + + const [activeAttributeMenu, setActiveAttributeMenu] = React.useState< + 'Cluster Name' | 'Account Name' | 'Status' | 'Provider' + >('Cluster Name'); + + const clusterNameInput = ( + debouncedClusterSearch(value)} + onClear={() => debouncedClusterSearch('')} + /> + ); + + const accountNameInput = ( + debouncedAccountSearch(value)} + onClear={() => debouncedAccountSearch('')} + /> + ); + + // Set up status filter (only for active view) + const [isStatusMenuOpen, setIsStatusMenuOpen] = React.useState(false); + const statusToggleRef = React.useRef(null); + const statusMenuRef = React.useRef(null); + const { containerRef: statusContainerRef, containerElement: statusContainerElement } = usePopperContainer(); + + const handleStatusMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleStatusClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleStatusMenuKeysRef.current = (event: KeyboardEvent) => { + if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsStatusMenuOpen(!isStatusMenuOpen); + statusToggleRef.current?.focus(); + } + } + }; + + handleStatusClickOutsideRef.current = (event: MouseEvent) => { + if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { + setIsStatusMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleStatusMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleStatusClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isStatusMenuOpen]); + + const onStatusToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (statusMenuRef.current) { + const firstElement = statusMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsStatusMenuOpen(!isStatusMenuOpen); + }; + + function onStatusSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + setStatusSelection(itemId as ResourceStatusApi); + setIsStatusMenuOpen(!isStatusMenuOpen); + } + + const statusToggle = ( + + Filter by status + + ); + + const statusMenu = ( + + + + {ResourceStatusApi.Running} + {ResourceStatusApi.Stopped} + {ResourceStatusApi.Terminated} + + + + ); + + const statusSelect = ( +
+ +
+ ); + + // Provider filter setup + const [isProviderMenuOpen, setIsProviderMenuOpen] = React.useState(false); + const providerToggleRef = React.useRef(null); + const providerMenuRef = React.useRef(null); + const { containerRef: providerContainerRef, containerElement: providerContainerElement } = usePopperContainer(); + + const handleProviderMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleProviderClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleProviderMenuKeysRef.current = (event: KeyboardEvent) => { + if (isProviderMenuOpen && providerMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsProviderMenuOpen(!isProviderMenuOpen); + providerToggleRef.current?.focus(); + } + } + }; + + handleProviderClickOutsideRef.current = (event: MouseEvent) => { + if (isProviderMenuOpen && !providerMenuRef.current?.contains(event.target as Node)) { + setIsProviderMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleProviderMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleProviderClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isProviderMenuOpen]); + + const onProviderMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (providerMenuRef.current) { + const firstElement = providerMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsProviderMenuOpen(!isProviderMenuOpen); + }; + + function onProviderMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const provider = itemId as ProviderApi; + setProviderSelections( + providerSelections && providerSelections.includes(provider) + ? providerSelections.filter(selection => selection !== provider) + : provider + ? [provider, ...(providerSelections || [])] + : [] + ); + } + + const providerToggle = ( + 0 && { + badge: {providerSelections.length}, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + Filter by provider + + ); + + const providerMenu = ( + + + + + AWS + + + Google Cloud + + + Azure + + + + + ); + + const providerSelect = ( +
+ +
+ ); + + const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); + const attributeToggleRef = React.useRef(null); + const attributeMenuRef = React.useRef(null); + const { containerRef: attributeContainerRef, containerElement: attributeContainerElement } = usePopperContainer(); + + const handleAttributeMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleAttributeClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleAttributeMenuKeysRef.current = (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; + } + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }; + + handleAttributeClickOutsideRef.current = (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleAttributeMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleAttributeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isAttributeMenuOpen]); + + const onAttributeToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (attributeMenuRef.current) { + const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }; + + const onAttributeSelect = (_ev: React.MouseEvent | undefined, itemId: string | number | undefined) => { + const selected = itemId as 'Cluster Name' | 'Account Name' | 'Status' | 'Provider'; + setActiveAttributeMenu(selected); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }; + + const attributeToggle = ( + } + > + {activeAttributeMenu} + + ); + + const attributeMenu = ( + + + + Cluster Name + Account Name + Status + Provider + + + + ); + + const attributeDropdown = ( +
+ +
+ ); + + return ( + { + setClusterNameSearch(''); + setAccountNameSearch(''); + setStatusSelection(null); + setProviderSelections(null); + setActiveAttributeMenu('Cluster Name'); + }} + > + + } breakpoint="xl"> + + {attributeDropdown} + setClusterNameSearch('')} + deleteLabelGroup={() => setClusterNameSearch('')} + categoryName="Cluster Name" + showToolbarItem={activeAttributeMenu === 'Cluster Name'} + > + {clusterNameInput} + + setAccountNameSearch('')} + deleteLabelGroup={() => setAccountNameSearch('')} + categoryName="Account Name" + showToolbarItem={activeAttributeMenu === 'Account Name'} + > + {accountNameInput} + + setStatusSelection(null)} + deleteLabelGroup={() => setStatusSelection(null)} + categoryName="Status" + showToolbarItem={activeAttributeMenu === 'Status'} + > + {statusSelect} + + onProviderMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setProviderSelections([])} + categoryName="Provider" + showToolbarItem={activeAttributeMenu === 'Provider'} + > + {providerSelect} + + + + + setShowTerminated(checked)} + /> + + + + ); +}; + +export default ClustersTableToolbar; diff --git a/console/src/app/Clusters/types.ts b/console/src/app/Clusters/types.ts new file mode 100644 index 00000000..63a84a06 --- /dev/null +++ b/console/src/app/Clusters/types.ts @@ -0,0 +1,22 @@ +import { ResourceStatusApi, ProviderApi } from '@api'; + +export interface ClustersTableToolbarProps { + clusterNameSearch: string; + setClusterNameSearch: (value: string) => void; + accountNameSearch: string; + setAccountNameSearch: (value: string) => void; + statusSelection: ResourceStatusApi | null; + setStatusSelection: (value: ResourceStatusApi | null) => void; + providerSelections: ProviderApi[] | null; + setProviderSelections: (value: ProviderApi[] | null) => void; + showTerminated: boolean; + setShowTerminated: (value: boolean) => void; +} + +export interface ClustersTableProps { + clusterNameSearch: string; + accountNameSearch: string; + statusFilter: string | null; + providerSelections: ProviderApi[] | null; + showTerminated: boolean; +} diff --git a/console/src/app/Contexts/UserContext.tsx b/console/src/app/Contexts/UserContext.tsx new file mode 100644 index 00000000..a3c2cffe --- /dev/null +++ b/console/src/app/Contexts/UserContext.tsx @@ -0,0 +1,38 @@ +/* eslint-disable react-refresh/only-export-components */ +import * as React from 'react'; + +interface UserContextType { + userEmail: string | null; + setUserEmail: (email: string | null) => void; +} + +export const UserContext = React.createContext({ + userEmail: null, + setUserEmail: () => {}, +}); + +export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [userEmail, setUserEmail] = React.useState(null); + + React.useEffect(() => { + fetch(window.location.href) + .then(response => { + const email = response.headers.get('gap-auth'); + if (email) { + setUserEmail(email); + console.log('User email:', email); + } + }) + .catch(error => console.error('Error fetching headers:', error)); + }, []); + + return {children}; +}; + +export const useUser = () => { + const context = React.useContext(UserContext); + if (context === undefined) { + throw new Error('useUser must be used within a UserProvider'); + } + return context; +}; diff --git a/console/src/app/Dashboard/Dashboard.tsx b/console/src/app/Dashboard/Dashboard.tsx new file mode 100644 index 00000000..6a8a9e89 --- /dev/null +++ b/console/src/app/Dashboard/Dashboard.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { PageSection, Title } from '@patternfly/react-core'; + +const Dashboard: React.FunctionComponent = () => ( + + + Dashboard Page Title! + + +); + +export { Dashboard }; diff --git a/console/src/app/Overview/Overview.tsx b/console/src/app/Overview/Overview.tsx new file mode 100644 index 00000000..0f6ae04f --- /dev/null +++ b/console/src/app/Overview/Overview.tsx @@ -0,0 +1,143 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { + Card, + CardBody, + CardTitle, + Gallery, + Grid, + GridItem, + PageSection, + Content, + Alert, + Button, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateActions, +} from '@patternfly/react-core'; +import { CubesIcon } from '@patternfly/react-icons'; +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { generateCards } from './components/CardData'; +import { ProviderApi } from '@api'; +import { renderContent } from './utils/cardRendererUtils.tsx'; +import { useDashboardData } from './hooks/useDashboardData'; +import { useEventsData } from './hooks/useEventsData'; +import { DashboardState } from './types'; + +const AggregateStatusCards: React.FunctionComponent = () => { + const { inventoryData, loading, error } = useDashboardData(); + const { events, loading: eventsLoading, error: eventsError } = useEventsData(); + + if (loading) { + return ; + } + + if (error || !inventoryData) { + return ( + + + Dashboard unavailable. Refresh to try again. + + + + + + + + ); + } + + const dashboardState: DashboardState = { + clustersByStatus: { + running: inventoryData?.clusters?.running || 0, + stopped: inventoryData?.clusters?.stopped || 0, + terminated: inventoryData?.clusters?.archived || 0, + }, + instancesByStatus: { + running: inventoryData?.instances?.running || 0, + stopped: inventoryData?.instances?.stopped || 0, + terminated: inventoryData?.instances?.archived || 0, + }, + clustersByProvider: { + [ProviderApi.AWSProvider]: inventoryData.providers?.aws?.clusterCount || 0, + [ProviderApi.GCPProvider]: inventoryData.providers?.gcp?.clusterCount || 0, + [ProviderApi.AzureProvider]: inventoryData.providers?.azure?.clusterCount || 0, + [ProviderApi.UnknownProvider]: 0, + }, + accountsByProvider: { + [ProviderApi.AWSProvider]: inventoryData.providers?.aws?.accountCount || 0, + [ProviderApi.GCPProvider]: inventoryData.providers?.gcp?.accountCount || 0, + [ProviderApi.AzureProvider]: inventoryData.providers?.azure?.accountCount || 0, + [ProviderApi.UnknownProvider]: 0, + }, + instances: (inventoryData?.instances?.running || 0) + (inventoryData?.instances?.stopped || 0), + lastScanTimestamp: inventoryData?.scanner?.lastScanTimestamp, + }; + + const cardData = generateCards(dashboardState, events); + + return ( + + + + Overview + + + + + {Object.entries(cardData).map(([groupName, cards], groupIndex) => ( + + {groupName === 'activityCards' ? ( + // Full width Activity card with double height + + {cards[0].title} + + {eventsLoading ? ( + + ) : eventsError ? ( + +

{eventsError}

+

Check the console for more details or try refreshing the page.

+
+ ) : cards[0].customComponent ? ( + cards[0].customComponent + ) : ( + renderContent(cards[0].content, cards[0].layout, cards[0].totalCount) + )} +
+
+ ) : ( + // Regular cards in Gallery + + {cards.map((card, cardIndex) => ( + + + {card.title} + + {renderContent(card.content, card.layout, card.totalCount)} + + ))} + + )} +
+ ))} +
+
+
+ ); +}; + +export default AggregateStatusCards; diff --git a/console/src/app/Overview/components/ActivityTable.tsx b/console/src/app/Overview/components/ActivityTable.tsx new file mode 100644 index 00000000..743d3fe4 --- /dev/null +++ b/console/src/app/Overview/components/ActivityTable.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import { EmptyState } from '@patternfly/react-core'; +import { SystemEventResponseApi } from '@api'; +import { Link } from 'react-router-dom'; +import { resolveResourcePath } from '@app/utils/parseFuncs'; +import { InboxIcon } from '@patternfly/react-icons'; +import { getResultIcon } from '@app/utils/renderUtils'; +import { ResultStatus } from '@app/types/types'; + +interface ActivityTableProps { + events: SystemEventResponseApi[]; +} + +export const ActivityTable: React.FunctionComponent = ({ events }) => { + if (events.length === 0) { + return ; + } + + return ( + + + + + + + + + + + + {events.map(event => ( + + + + + + + + ))} + +
TimeActionResultResourceTriggered By
{event.timestamp ? new Date(event.timestamp).toLocaleString('es-ES') : '-'}{event.action} + {getResultIcon(event.result as ResultStatus)} {event.result} + + + {event.resourceId} + + {event.triggeredBy}
+ ); +}; diff --git a/console/src/app/Overview/components/CardData.tsx b/console/src/app/Overview/components/CardData.tsx new file mode 100644 index 00000000..21cefb19 --- /dev/null +++ b/console/src/app/Overview/components/CardData.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { CardDefinition, CardLayout, DashboardState } from '../types'; +import { CLOUD_PROVIDERS, STATUSES, TOTAL_COUNT_ICONS } from '../constants'; +import { SystemEventResponseApi } from '@api'; +import { ActivityTable } from './ActivityTable'; + +export const generateCards = ( + state: DashboardState, + events: SystemEventResponseApi[] = [] +): Record => { + const isValidTimestamp = state.lastScanTimestamp && state.lastScanTimestamp !== '0001-01-01T00:00:00Z'; + const scannerContent = isValidTimestamp + ? `${new Date(state.lastScanTimestamp!).toLocaleString()}` + : 'No scan data available'; + const totalClusters = (state.clustersByStatus.running || 0) + (state.clustersByStatus.stopped || 0); + const totalInstances = state.instances || 0; + + const statusCards = [ + { + title: 'Clusters', + content: Object.entries(STATUSES).map(([key, status]) => ({ + icon: status.icon, + value: state.clustersByStatus[key] || 0, + ref: status.route, + })), + layout: CardLayout.MULTI_ICON, + totalCount: { + icon: TOTAL_COUNT_ICONS.clusters, + value: totalClusters, + label: 'Total', + }, + }, + { + title: 'Instances', + content: Object.entries(STATUSES).map(([key, status]) => ({ + icon: status.icon, + value: state.instancesByStatus[key] || 0, + ref: status.route, + })), + layout: CardLayout.MULTI_ICON, + totalCount: { + icon: TOTAL_COUNT_ICONS.instances, + value: totalInstances, + label: 'Total', + }, + }, + { + title: 'Last Scan Timestamp', + content: [{ value: scannerContent }], + layout: CardLayout.MULTI_ICON, + }, + ]; + + const providerCards = Object.values(CLOUD_PROVIDERS).map(provider => ({ + title: provider.title, + content: [ + { + value: `${state.clustersByProvider[provider.key] ?? 0} Cluster(s)`, + icon: provider.icon, + ref: `/clusters?provider=${provider.key}`, + }, + { + value: `${state.accountsByProvider[provider.key] ?? 0} Account(s)`, + icon: provider.providerIcon, + ref: `/accounts?provider=${provider.key}`, + }, + ], + layout: CardLayout.MULTI_ICON, + })); + + const activityCards = [ + { + title: 'Recent events', + content: [], // Empty content since we're using customComponent + layout: CardLayout.MULTI_ICON, + customComponent: , + }, + ]; + + return { + statusCards, + providerCards, + activityCards, + }; +}; diff --git a/console/src/app/Overview/components/CardRenderer.tsx b/console/src/app/Overview/components/CardRenderer.tsx new file mode 100644 index 00000000..1aaf2b06 --- /dev/null +++ b/console/src/app/Overview/components/CardRenderer.tsx @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { CardTotalCount } from '@app/Overview/types.ts'; +import { Divider, Flex, FlexItem, Stack } from '@patternfly/react-core'; + +// TODO Avoid any +export const RenderSingleIcon: React.FunctionComponent<{ content: any[] }> = ({ content }) => content[0]?.icon; +// TODO Avoid any +export const RenderMultiIcon: React.FunctionComponent<{ content: any[]; totalCount?: CardTotalCount }> = ({ + content, + totalCount, +}) => ( + + + {content.map(({ icon, value, ref }, index) => ( + + + {icon} + {ref ? {value} : {value}} + + {content.length > 1 && index < content.length - 1 && } + + ))} + + {totalCount && ( + + {totalCount.icon} + + + {totalCount.label}: {totalCount.value} + + + + )} + +); +// TODO Avoid any +export const RenderWithSubtitle: React.FC<{ content: any[] }> = ({ content }) => ( + + {content.map(({ icon, status, subtitle }, index) => ( + + {icon} + + {status} + {subtitle} + + + ))} + +); diff --git a/console/src/app/Overview/constants.tsx b/console/src/app/Overview/constants.tsx new file mode 100644 index 00000000..6082293f --- /dev/null +++ b/console/src/app/Overview/constants.tsx @@ -0,0 +1,73 @@ +/* eslint-disable react-refresh/only-export-components */ +import React from 'react'; +import { + CheckCircleIcon, + ErrorCircleOIcon, + OpenshiftIcon, + AwsIcon, + GoogleIcon, + AzureIcon, + ArchiveIcon, + DatabaseIcon, + RegistryIcon, +} from '@patternfly/react-icons'; +import { ResourceStatusApi, ProviderApi } from '@api'; + +const PATTERNFLY_COLORS = { + success: 'var(--pf-t--global--color--status--success--default)', + danger: 'var(--pf-t--global--color--status--danger--default)', + warning: 'var(--pf-t--global--color--status--warning--default)', + disabled: 'var(--pf-t--global--text--color--disabled)', +} as const; + +const CLUSTER_ICON = ; + +const PROVIDER_ICONS = { + [ProviderApi.AWSProvider]: , + [ProviderApi.GCPProvider]: , + [ProviderApi.AzureProvider]: , +} as const; + +export const STATUSES = { + running: { + key: ResourceStatusApi.Running, + icon: , + route: '/clusters?status=Running', + }, + stopped: { + key: ResourceStatusApi.Stopped, + icon: , + route: '/clusters?status=Stopped', + }, + terminated: { + key: ResourceStatusApi.Terminated, + icon: , + route: '/clusters?status=Terminated', + }, +} as const; + +export const CLOUD_PROVIDERS = { + [ProviderApi.AWSProvider]: { + key: ProviderApi.AWSProvider, + title: 'AWS Clusters', + icon: CLUSTER_ICON, + providerIcon: PROVIDER_ICONS[ProviderApi.AWSProvider], + }, + [ProviderApi.GCPProvider]: { + key: ProviderApi.GCPProvider, + title: 'GCP Clusters', + icon: CLUSTER_ICON, + providerIcon: PROVIDER_ICONS[ProviderApi.GCPProvider], + }, + [ProviderApi.AzureProvider]: { + key: ProviderApi.AzureProvider, + title: 'Azure Clusters', + icon: CLUSTER_ICON, + providerIcon: PROVIDER_ICONS[ProviderApi.AzureProvider], + }, +} as const; + +export const TOTAL_COUNT_ICONS = { + clusters: , + instances: , +} as const; diff --git a/console/src/app/Overview/hooks/useDashboardData.ts b/console/src/app/Overview/hooks/useDashboardData.ts new file mode 100644 index 00000000..e13b5447 --- /dev/null +++ b/console/src/app/Overview/hooks/useDashboardData.ts @@ -0,0 +1,27 @@ +import { useState, useEffect } from 'react'; +import { api, OverviewSummaryApi } from '@api'; + +export const useDashboardData = () => { + const [inventoryData, setInventoryData] = useState(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const inventoryOverview = async () => { + try { + setLoading(true); + setError(null); + const { data } = await api.overview.overviewList(); + setInventoryData(data); + } catch { + setError('Failed to fetch inventory data'); + console.error('Failed to fetch inventory data.'); + } finally { + setLoading(false); + } + }; + inventoryOverview(); + }, []); + + return { inventoryData, loading, error }; +}; diff --git a/console/src/app/Overview/hooks/useEventsData.ts b/console/src/app/Overview/hooks/useEventsData.ts new file mode 100644 index 00000000..ee0a5486 --- /dev/null +++ b/console/src/app/Overview/hooks/useEventsData.ts @@ -0,0 +1,30 @@ +import { useState, useEffect } from 'react'; +import { api, SystemEventResponseApi } from '@api'; + +export const useEventsData = () => { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchEvents = async () => { + try { + setLoading(true); + setError(null); + console.log('Fetching recent events...'); + const { data } = await api.events.eventsList({ page: 1, page_size: 10 }); + console.log('Events data received:', data); + setEvents(data.items || []); + } catch (err) { + setError('Failed to fetch events'); + console.error('Error fetching events:', err); + } finally { + setLoading(false); + } + }; + + fetchEvents(); + }, []); + + return { events, loading, error }; +}; diff --git a/console/src/app/Overview/types.ts b/console/src/app/Overview/types.ts new file mode 100644 index 00000000..80653c87 --- /dev/null +++ b/console/src/app/Overview/types.ts @@ -0,0 +1,37 @@ +import React from 'react'; +import { ProviderApi } from '@api'; + +export enum CardLayout { + SINGLE_ICON = 'icon', + MULTI_ICON = 'multiIcon', + WITH_SUBTITLE = 'withSubtitle', +} + +export interface CardContentItem { + icon?: React.ReactNode; + value: string | number; + ref?: string; +} + +export interface CardTotalCount { + icon: React.ReactNode; + value: number; + label: string; +} + +export interface CardDefinition { + title: string; + content: CardContentItem[]; + layout: CardLayout; + customComponent?: React.ReactNode; + totalCount?: CardTotalCount; +} + +export interface DashboardState { + clustersByStatus: Record; + instancesByStatus: Record; + clustersByProvider: Record; + accountsByProvider: Record; + instances: number; + lastScanTimestamp?: string; +} diff --git a/console/src/app/Overview/utils/cardRendererUtils.tsx b/console/src/app/Overview/utils/cardRendererUtils.tsx new file mode 100644 index 00000000..8b411b0e --- /dev/null +++ b/console/src/app/Overview/utils/cardRendererUtils.tsx @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { CardLayout, CardTotalCount } from '@app/Overview/types.ts'; +import { RenderSingleIcon, RenderMultiIcon, RenderWithSubtitle } from '../components/CardRenderer'; + +export const renderContent = (content: any[], layout: CardLayout, totalCount?: CardTotalCount) => { + switch (layout) { + case CardLayout.SINGLE_ICON: + return ; + case CardLayout.MULTI_ICON: + return ; + case CardLayout.WITH_SUBTITLE: + return ; + } +}; diff --git a/console/src/app/ServerDetails/ServerDetails.tsx b/console/src/app/ServerDetails/ServerDetails.tsx new file mode 100644 index 00000000..98ac78d8 --- /dev/null +++ b/console/src/app/ServerDetails/ServerDetails.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useState } from 'react'; +import { renderStatusLabel } from '@app/utils/renderUtils'; +import { parseScanTimestamp, parseNumberToCurrency } from 'src/app/utils/parseFuncs'; +import { useParams } from 'react-router-dom'; +import { + PageSection, + Tabs, + Tab, + TabContent, + TabContentBody, + TabTitleText, + Title, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + Label, + Flex, + FlexItem, + LabelGroup, + Bullseye, + Spinner, +} from '@patternfly/react-core'; +import { api, InstanceResponseApi, TagResponseApi } from '@api'; +import { Link } from 'react-router-dom'; + +interface LabelGroupOverflowProps { + labels: Array; +} + +const LabelGroupOverflow: React.FunctionComponent = ({ labels }) => ( + + {labels.map(label => ( + + ))} + +); + +const ServerDetails: React.FunctionComponent = () => { + const { instanceID } = useParams(); + const [activeTabKey, setActiveTabKey] = React.useState(0); + const [instanceData, setInstanceData] = useState(null); + const [loading, setLoading] = useState(true); + useEffect(() => { + const fetchData = async () => { + try { + console.log('Fetching Account Clusters ', instanceID); + if (!instanceID) return; + const { data: fetchedInstance } = await api.instances.instancesDetail(instanceID); + setInstanceData(fetchedInstance); + console.log('Fetched Account Clusters data:', instanceID); + } catch (error) { + console.error('Error fetching data:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [instanceID]); + + const handleTabClick = (_event, tabIndex) => { + setActiveTabKey(tabIndex); + }; + + const detailsTabContent = ( + + {loading ? ( + + + + ) : ( + + + + Server details + + + + + + + Name + {instanceID} + Status + {renderStatusLabel(instanceData?.status)} + Cluster ID + + {instanceData?.clusterId} + + Cloud Provider + {instanceData?.provider} + + + + Labels + + Last scanned at + + {parseScanTimestamp(instanceData?.lastScanTimestamp)} + + Created at + + {parseScanTimestamp(instanceData?.creationTimestamp)} + + + + + + + Total Cost (aprox) + + {parseNumberToCurrency(instanceData?.totalCost)} + + + + + + + + )} + + ); + + return ( + + {/* Page header */} + + + + + + + + {instanceID} + + + + {/* Page tabs */} + + + + Details} tabContentId={`tabContent${0}`} /> + + + + + + + + ); +}; + +export default ServerDetails; diff --git a/console/src/app/Servers/Servers.tsx b/console/src/app/Servers/Servers.tsx new file mode 100644 index 00000000..07c8261a --- /dev/null +++ b/console/src/app/Servers/Servers.tsx @@ -0,0 +1,52 @@ +import { PageSection, Panel, Content } from '@patternfly/react-core'; +import React from 'react'; +import ServersTableToolbar from './components/ServersTableToolbar'; +import ServersTable from './components/ServersTable'; +import { parseAsArrayOf, parseAsString, parseAsStringEnum, parseAsBoolean, useQueryStates } from 'nuqs'; +import { ResourceStatusApi, ProviderApi } from '@api'; + +const filterParams = { + status: { + ...parseAsStringEnum(Object.values(ResourceStatusApi)), + defaultValue: null as ResourceStatusApi | null, + }, + provider: parseAsArrayOf(parseAsStringEnum(Object.values(ProviderApi))).withDefault([]), + serverName: parseAsString.withDefault(''), + showTerminated: parseAsBoolean.withDefault(false), +}; + +const Servers: React.FunctionComponent = () => { + const [{ status, provider, serverName, showTerminated }, setQuery] = useQueryStates(filterParams); + + return ( + + + + Servers + + + + + setQuery({ serverName: value })} + statusSelection={status} + setStatusSelection={value => setQuery({ status: value })} + providerSelections={provider} + setProviderSelections={value => setQuery({ provider: value || [] })} + showTerminated={showTerminated} + setShowTerminated={value => setQuery({ showTerminated: value })} + /> + + + + + ); +}; + +export default Servers; diff --git a/console/src/app/Servers/components/ServersTable.tsx b/console/src/app/Servers/components/ServersTable.tsx new file mode 100644 index 00000000..8046ed9f --- /dev/null +++ b/console/src/app/Servers/components/ServersTable.tsx @@ -0,0 +1,160 @@ +import { renderStatusLabel } from '@app/utils/renderUtils'; +import { EmptyState, EmptyStateVariant, EmptyStateBody, Title } from '@patternfly/react-core'; +import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import React, { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { ServersTableProps } from '../types'; +import { InstanceResponseApi } from '@api'; +import { TablePagination } from '@app/components/common/TablesPagination'; +import { searchItems, filterByStatus, filterByProvider, sortItems } from '@app/utils/tableFilters'; +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { ServerIcon } from '@patternfly/react-icons'; +import { useInstances } from '@app/hooks/useInstances'; +import { useTablePagination } from '@app/hooks/useTablePagination'; + +export const ServersTable: React.FunctionComponent = ({ + searchValue, + statusSelection, + providerSelections, + showTerminated, +}) => { + const { data: allInstances = [], isLoading } = useInstances(); + + const [activeSortIndex, setActiveSortIndex] = useState(1); + const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('asc'); + + const filtered = useMemo(() => { + let result = allInstances; + + if (!showTerminated) { + result = result.filter(instance => instance.status !== 'Terminated'); + } + + result = searchItems(result, searchValue, ['instanceName']); + result = filterByStatus(result, statusSelection); + result = filterByProvider(result, providerSelections); + + if (activeSortIndex !== undefined && activeSortDirection) { + const sortFields: (keyof InstanceResponseApi)[] = [ + 'instanceId', + 'instanceName', + 'status', + 'provider', + 'availabilityZone', + 'instanceType', + ]; + if (activeSortIndex !== 2) { + result = sortItems(result, sortFields[activeSortIndex], activeSortDirection); + } + } + + return result; + }, [ + allInstances, + showTerminated, + searchValue, + statusSelection, + providerSelections, + activeSortIndex, + activeSortDirection, + ]); + + const { page, perPage, setPage, setPerPage, paginatedData, totalItems } = useTablePagination({ + data: filtered, + filterDeps: [searchValue, statusSelection, providerSelections, showTerminated], + }); + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + + const columnNames = { + id: 'ID', + name: 'Name', + status: 'Status', + provider: 'Provider', + availabilityZone: 'AZ', + instanceType: 'Type', + }; + + if (isLoading) { + return ; + } + + if (filtered.length === 0) { + return ( + + No instances found + + } + icon={ServerIcon} + variant={EmptyStateVariant.sm} + > + + {!showTerminated ? ( + <> + There are no active instances. +
+ Toggle 'Show terminated instances' to view all instances. + + ) : ( + 'No instances found.' + )} +
+
+ ); + } + + return ( + + + + + + + + + + + + + + {paginatedData.map(instance => ( + + + + + + + + + ))} + +
{columnNames.id}{columnNames.name}{columnNames.status}{columnNames.provider}{columnNames.availabilityZone}{columnNames.instanceType}
+ {instance.instanceId} + + {instance.instanceName} + {renderStatusLabel(instance.status)}{instance.provider}{instance.availabilityZone}{instance.instanceType}
+ +
+ ); +}; + +export default ServersTable; diff --git a/console/src/app/Servers/components/ServersTableToolbar.tsx b/console/src/app/Servers/components/ServersTableToolbar.tsx new file mode 100644 index 00000000..9895538a --- /dev/null +++ b/console/src/app/Servers/components/ServersTableToolbar.tsx @@ -0,0 +1,436 @@ +import { + SearchInput, + MenuToggle, + Menu, + MenuContent, + MenuList, + MenuItem, + Popper, + Badge, + Toolbar, + ToolbarContent, + ToolbarToggleGroup, + ToolbarGroup, + ToolbarItem, + ToolbarFilter, + Switch, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { ServersTableToolbarProps } from '../types'; +import { ResourceStatusApi, ProviderApi } from '@api'; +import debounce from 'lodash.debounce'; +import { usePopperContainer } from '@app/hooks/usePopperContainer'; + +export const ServersTableToolbar: React.FunctionComponent = ({ + searchValue, + setSearchValue, + setStatusSelection, + setProviderSelections, + providerSelections, + statusSelection, + showTerminated, + setShowTerminated, +}) => { + const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); + + React.useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + // Set up name search input + const searchInput = ( + debouncedSearch(value)} + onClear={() => debouncedSearch('')} + /> + ); + + // Set up name input + const [isStatusMenuOpen, setIsStatusMenuOpen] = React.useState(false); + const statusToggleRef = React.useRef(null); + const statusMenuRef = React.useRef(null); + const { containerRef: statusContainerRef, containerElement: statusContainerElement } = usePopperContainer(); + const handleStatusMenuKeysRef = React.useRef<((event: KeyboardEvent) => void) | undefined>(undefined); + const handleStatusClickOutsideRef = React.useRef<((event: MouseEvent) => void) | undefined>(undefined); + + React.useEffect(() => { + handleStatusMenuKeysRef.current = (event: KeyboardEvent) => { + if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsStatusMenuOpen(!isStatusMenuOpen); + statusToggleRef.current?.focus(); + } + } + }; + + handleStatusClickOutsideRef.current = (event: MouseEvent) => { + if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { + setIsStatusMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleStatusMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleStatusClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isStatusMenuOpen]); + + const onStatusToggleClick = (_ev: React.MouseEvent) => { + _ev.stopPropagation(); + setTimeout(() => { + if (statusMenuRef.current) { + const firstElement = statusMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsStatusMenuOpen(!isStatusMenuOpen); + }; + + function onStatusSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + setStatusSelection(itemId as ResourceStatusApi); + setIsStatusMenuOpen(!isStatusMenuOpen); + } + + const statusToggle = ( + + Filter by status + + ); + + const statusMenu = ( + + + + {ResourceStatusApi.Running} + {ResourceStatusApi.Stopped} + {ResourceStatusApi.Terminated} + + + + ); + + const statusSelect = ( +
+ +
+ ); + + // Set up provider input + const [isProviderMenuOpen, setIsProviderMenuOpen] = React.useState(false); + const providerToggleRef = React.useRef(null); + const providerMenuRef = React.useRef(null); + const { containerRef: providerContainerRef, containerElement: providerContainerElement } = usePopperContainer(); + + const handleProviderMenuKeysRef = React.useRef<((event: KeyboardEvent) => void) | undefined>(undefined); + const handleProviderClickOutsideRef = React.useRef<((event: MouseEvent) => void) | undefined>(undefined); + + React.useEffect(() => { + handleProviderMenuKeysRef.current = (event: KeyboardEvent) => { + if (isProviderMenuOpen && providerMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsProviderMenuOpen(!isProviderMenuOpen); + providerToggleRef.current?.focus(); + } + } + }; + + handleProviderClickOutsideRef.current = (event: MouseEvent) => { + if (isProviderMenuOpen && !providerMenuRef.current?.contains(event.target as Node)) { + setIsProviderMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleProviderMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleProviderClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isProviderMenuOpen]); + + const onProviderMenuToggleClick = (_ev: React.MouseEvent) => { + _ev.stopPropagation(); + setTimeout(() => { + if (providerMenuRef.current) { + const firstElement = providerMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsProviderMenuOpen(!isProviderMenuOpen); + }; + + function onProviderMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const provider = itemId as ProviderApi; + setProviderSelections( + providerSelections && providerSelections.includes(provider) + ? providerSelections.filter(selection => selection !== provider) + : provider + ? [provider, ...(providerSelections || [])] + : [] + ); + } + + const providerToggle = ( + 0 && { + badge: {providerSelections.length}, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + Filter by provider + + ); + + const providerMenu = ( + + + + + AWS + + + Google Cloud + + + Azure + + + + + ); + + const providerSelect = ( +
+ +
+ ); + + // Set up attribute selector + const [activeAttributeMenu, setActiveAttributeMenu] = React.useState<'Servers' | 'Status' | 'Provider'>('Servers'); + const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); + const attributeToggleRef = React.useRef(null); + const attributeMenuRef = React.useRef(null); + const { containerRef: attributeContainerRef, containerElement: attributeContainerElement } = usePopperContainer(); + + const handleAttribueMenuKeysRef = React.useRef<((event: KeyboardEvent) => void) | undefined>(undefined); + const handleAttributeClickOutsideRef = React.useRef<((event: MouseEvent) => void) | undefined>(undefined); + + React.useEffect(() => { + handleAttribueMenuKeysRef.current = (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; + } + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }; + + handleAttributeClickOutsideRef.current = (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleAttribueMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleAttributeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isAttributeMenuOpen]); + + const onAttributeToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (attributeMenuRef.current) { + const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }; + + const attributeToggle = ( + } + > + {activeAttributeMenu} + + ); + const attributeMenu = ( + { + setActiveAttributeMenu(itemId?.toString() as 'Servers' | 'Status' | 'Provider'); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }} + > + + + Servers + Status + Provider + + + + ); + + const attributeDropdown = ( +
+ +
+ ); + + return ( + { + setSearchValue(''); + setStatusSelection(null); + setProviderSelections(null); + }} + > + + } breakpoint="xl"> + + {attributeDropdown} + setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} + categoryName="Name" + showToolbarItem={activeAttributeMenu === 'Servers'} + > + {searchInput} + + setStatusSelection(null)} + deleteLabelGroup={() => setStatusSelection(null)} + categoryName="Status" + showToolbarItem={activeAttributeMenu === 'Status'} + > + {statusSelect} + + onProviderMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setProviderSelections([])} + categoryName="Provider" + showToolbarItem={activeAttributeMenu === 'Provider'} + > + {providerSelect} + + + + + setShowTerminated(checked)} + /> + + + + ); +}; + +export default ServersTableToolbar; diff --git a/console/src/app/Servers/types.ts b/console/src/app/Servers/types.ts new file mode 100644 index 00000000..45fd8c93 --- /dev/null +++ b/console/src/app/Servers/types.ts @@ -0,0 +1,19 @@ +import { ResourceStatusApi, ProviderApi } from '@api'; + +export interface ServersTableProps { + searchValue: string; + statusSelection: string | null; + providerSelections: ProviderApi[] | null; + showTerminated: boolean; +} + +export interface ServersTableToolbarProps { + searchValue: string; + setSearchValue: (value: string) => void; + statusSelection: ResourceStatusApi | null; + setStatusSelection: (value: ResourceStatusApi | null) => void; + providerSelections: ProviderApi[] | null; + setProviderSelections: (value: ProviderApi[] | null) => void; + showTerminated: boolean; + setShowTerminated: (value: boolean) => void; +} diff --git a/console/src/app/components/common/LabelGroupOverflow.tsx b/console/src/app/components/common/LabelGroupOverflow.tsx new file mode 100644 index 00000000..f1813a7a --- /dev/null +++ b/console/src/app/components/common/LabelGroupOverflow.tsx @@ -0,0 +1,17 @@ +import { TagResponseApi } from '@api'; +import { LabelGroup, Label } from '@patternfly/react-core'; +import React from 'react'; + +interface LabelGroupOverflowProps { + labels: Array; +} + +export const LabelGroupOverflow: React.FunctionComponent = ({ labels }) => ( + + {labels.map(label => ( + + ))} + +); diff --git a/console/src/app/components/common/LoadingSpinner.tsx b/console/src/app/components/common/LoadingSpinner.tsx new file mode 100644 index 00000000..fffdadf7 --- /dev/null +++ b/console/src/app/components/common/LoadingSpinner.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Spinner } from '@patternfly/react-core'; + +export const LoadingSpinner: React.FunctionComponent = () => ( +
+ +
+); diff --git a/console/src/app/components/common/TablesPagination.tsx b/console/src/app/components/common/TablesPagination.tsx new file mode 100644 index 00000000..4cbc6862 --- /dev/null +++ b/console/src/app/components/common/TablesPagination.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Pagination } from '@patternfly/react-core'; + +interface PaginationProps { + itemCount: number; + page: number; + perPage: number; + onSetPage: (page: number) => void; + onPerPageSelect: (perPage: number) => void; +} + +export const TablePagination: React.FC = ({ + itemCount, + page, + perPage, + onSetPage, + onPerPageSelect, +}) => { + return ( + onSetPage(newPage)} + onPerPageSelect={(_evt, newPerPage) => onPerPageSelect(newPerPage)} + isLastFullPageShown + perPageOptions={[ + { title: '10', value: 10 }, + { title: '20', value: 20 }, + { title: '50', value: 50 }, + ]} + /> + ); +}; diff --git a/console/src/app/constants.ts b/console/src/app/constants.ts new file mode 100644 index 00000000..d6a5a74b --- /dev/null +++ b/console/src/app/constants.ts @@ -0,0 +1,4 @@ +export const APP_VERSION: string = __APP_VERSION__; +export const REPOSITORY_URL = 'https://github.com/RHEcosystemAppEng/cluster-iq'; +export const MAINTAINER_NAME = 'Red Hat Ecosystem App Eng'; +export const PRODUCT_NAME = 'ClusterIQ Console'; diff --git a/console/src/app/hooks/useAccounts.ts b/console/src/app/hooks/useAccounts.ts new file mode 100644 index 00000000..b30a8e01 --- /dev/null +++ b/console/src/app/hooks/useAccounts.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, AccountResponseApi } from '@api'; + +export function useAccounts() { + return useQuery({ + queryKey: ['accounts'], + queryFn: async ({ signal }) => { + const { data } = await api.accounts.accountsList({ page: 1, page_size: 10000 }, { signal }); + return data.items || []; + }, + }); +} diff --git a/console/src/app/hooks/useClusters.ts b/console/src/app/hooks/useClusters.ts new file mode 100644 index 00000000..b330e521 --- /dev/null +++ b/console/src/app/hooks/useClusters.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, ClusterResponseApi } from '@api'; + +export function useClusters() { + return useQuery({ + queryKey: ['clusters'], + queryFn: async ({ signal }) => { + const { data } = await api.clusters.clustersList({ page: 1, page_size: 100000 }, { signal }); + return data.items || []; + }, + }); +} diff --git a/console/src/app/hooks/useEvents.ts b/console/src/app/hooks/useEvents.ts new file mode 100644 index 00000000..15aa36f1 --- /dev/null +++ b/console/src/app/hooks/useEvents.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, SystemEventResponseApi } from '@api'; + +export function useEvents() { + return useQuery({ + queryKey: ['events'], + queryFn: async ({ signal }) => { + const { data } = await api.events.eventsList({}, { signal }); + return data.items || []; + }, + }); +} diff --git a/console/src/app/hooks/useInstances.ts b/console/src/app/hooks/useInstances.ts new file mode 100644 index 00000000..f474b82b --- /dev/null +++ b/console/src/app/hooks/useInstances.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, InstanceResponseApi } from '@api'; + +export function useInstances() { + return useQuery({ + queryKey: ['instances'], + queryFn: async ({ signal }) => { + const { data } = await api.instances.instancesList({ page: 1, page_size: 100000 }, { signal }); + return data.items || []; + }, + }); +} diff --git a/console/src/app/hooks/usePopperContainer.ts b/console/src/app/hooks/usePopperContainer.ts new file mode 100644 index 00000000..adfd939e --- /dev/null +++ b/console/src/app/hooks/usePopperContainer.ts @@ -0,0 +1,11 @@ +import { useState, useCallback } from 'react'; + +export const usePopperContainer = () => { + const [containerElement, setContainerElement] = useState(null); + + const containerRef = useCallback((node: HTMLElement | null) => { + setContainerElement(node); + }, []); + + return { containerRef, containerElement }; +}; diff --git a/console/src/app/hooks/useScheduleActions.ts b/console/src/app/hooks/useScheduleActions.ts new file mode 100644 index 00000000..98a95498 --- /dev/null +++ b/console/src/app/hooks/useScheduleActions.ts @@ -0,0 +1,19 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { api, ActionResponseApi } from '@api'; + +export const SCHEDULE_ACTIONS_QUERY_KEY = ['scheduleActions'] as const; + +export function useScheduleActions() { + return useQuery({ + queryKey: SCHEDULE_ACTIONS_QUERY_KEY, + queryFn: async ({ signal }) => { + const { data } = await api.schedule.scheduleList({ page: 1, page_size: 10000 }, { signal }); + return data.items || []; + }, + }); +} + +export function useInvalidateScheduleActions() { + const queryClient = useQueryClient(); + return () => queryClient.invalidateQueries({ queryKey: SCHEDULE_ACTIONS_QUERY_KEY }); +} diff --git a/console/src/app/hooks/useTablePagination.ts b/console/src/app/hooks/useTablePagination.ts new file mode 100644 index 00000000..98d5fbb5 --- /dev/null +++ b/console/src/app/hooks/useTablePagination.ts @@ -0,0 +1,53 @@ +import { useState, useMemo, useEffect } from 'react'; + +interface UseTablePaginationOptions { + data: T[]; + initialPage?: number; + initialPerPage?: number; + filterDeps?: unknown[]; +} + +interface UseTablePaginationResult { + page: number; + perPage: number; + setPage: (page: number) => void; + setPerPage: (perPage: number) => void; + paginatedData: T[]; + totalItems: number; +} + +export function useTablePagination({ + data, + initialPage = 1, + initialPerPage = 10, + filterDeps = [], +}: UseTablePaginationOptions): UseTablePaginationResult { + const [page, setPage] = useState(initialPage); + const [perPage, setPerPage] = useState(initialPerPage); + + useEffect(() => { + setPage(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, filterDeps); + + const { paginatedData, effectivePage } = useMemo(() => { + const maxPage = Math.max(1, Math.ceil(data.length / perPage)); + const safePage = Math.min(page, maxPage); + const startIndex = (safePage - 1) * perPage; + const endIndex = startIndex + perPage; + + return { + paginatedData: data.slice(startIndex, endIndex), + effectivePage: safePage, + }; + }, [data, page, perPage]); + + return { + page: effectivePage, + perPage, + setPage, + setPerPage, + paginatedData, + totalItems: data.length, + }; +} diff --git a/console/src/app/hooks/useTableSort.tsx b/console/src/app/hooks/useTableSort.tsx new file mode 100644 index 00000000..7cb1a6a7 --- /dev/null +++ b/console/src/app/hooks/useTableSort.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { ThProps } from '@patternfly/react-table'; + +export function useTableSort( + filteredData: T[], + getSortableRowValues: (item: T) => (string | number | null)[], + defaultSortIndex: number = 0, // Default to column 0 + defaultSortDirection: 'asc' | 'desc' = 'asc' // Default to ascending order +) { + const [activeSortIndex, setActiveSortIndex] = React.useState( + typeof defaultSortIndex === 'number' && defaultSortIndex !== null ? defaultSortIndex : 0 + ); + const [activeSortDirection, setActiveSortDirection] = React.useState<'asc' | 'desc' | undefined>( + defaultSortDirection + ); + + let sortedData = filteredData; + if (typeof activeSortIndex === 'number' && activeSortIndex !== null) { + sortedData = filteredData.sort((a, b) => { + const aValue = getSortableRowValues(a)[activeSortIndex]; + const bValue = getSortableRowValues(b)[activeSortIndex]; + + if (typeof aValue === 'number') { + return activeSortDirection === 'asc' + ? (aValue as number) - (bValue as number) + : (bValue as number) - (aValue as number); + } else { + return activeSortDirection === 'asc' + ? (aValue as string).localeCompare(bValue as string) + : (bValue as string).localeCompare(aValue as string); + } + }); + } + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc' as const, + }, + onSort: (_event: unknown, index: number, direction: 'asc' | 'desc') => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + + return { sortedData, getSortParams }; +} diff --git a/console/src/app/index.tsx b/console/src/app/index.tsx new file mode 100644 index 00000000..0dd0be40 --- /dev/null +++ b/console/src/app/index.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import '@patternfly/react-core/dist/styles/base.css'; +import { Route, BrowserRouter as Router, Routes, useLocation } from 'react-router-dom'; +import { AppLayout } from './AppLayout/AppLayout'; +import Overview from './Overview/Overview'; +import Clusters from './Clusters/Clusters'; +import ClusterDetails from './ClusterDetails/ClusterDetails'; +import AccountDetails from './AccountDetails/AccountDetails'; +import ServerDetails from './ServerDetails/ServerDetails'; +import AuditLogs from './Actions/AuditLogs/AuditLogs'; +import Scheduler from './Actions/Scheduler/Schedule'; +import Servers from './Servers/Servers'; +import Accounts from './Accounts/Accounts'; +import { NuqsAdapter } from 'nuqs/adapters/react'; +import { UserProvider } from './Contexts/UserContext'; + +const RouteDebugWrapper = ({ children }: { children: React.ReactNode }) => { + const location = useLocation(); + + React.useEffect(() => { + console.log('Route changed:', { + pathname: location.pathname, + search: location.search, + hash: location.hash, + }); + }, [location]); + + return <>{children}; +}; + +const AppRoutes = (): React.ReactElement => ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +); + +const App: React.FunctionComponent = () => ( + + + + + + + + + +); + +export default App; diff --git a/console/src/app/types/events.ts b/console/src/app/types/events.ts new file mode 100644 index 00000000..e69de29b diff --git a/console/src/app/types/types.tsx b/console/src/app/types/types.tsx new file mode 100644 index 00000000..67fc5323 --- /dev/null +++ b/console/src/app/types/types.tsx @@ -0,0 +1,51 @@ +export enum ResultStatus { + Pending = 'Pending', + Running = 'Running', + Success = 'Success', + Failed = 'Failed', + Warning = 'Warning', + Unknown = 'Unknown', +} + +export enum ActionStatus { + Running = 'Running', + Success = 'Success', + Failed = 'Failed', + Pending = 'Pending', + Unknown = 'Unknown', +} + +export enum ActionOperations { + POWER_ON = 'PowerOn', + POWER_OFF = 'PowerOff', +} + +export enum ActionTypes { + INSTANT_ACTION = 'instant_action', + SCHEDULED_ACTION = 'scheduled_action', + CRON_ACTION = 'cron_action', +} + +export interface BaseAction { + type: 'instant_action' | 'scheduled_action' | 'cron_action'; + operation: 'PowerOff' | 'PowerOn'; + target: { + clusterID: string; + }; + status: 'Pending'; + enabled: boolean; +} + +export interface ScheduledAction extends BaseAction { + type: 'scheduled_action'; + time: string; +} + +export interface CronAction extends BaseAction { + type: 'cron_action'; + cronExp: string; +} + +export interface InstantAction { + description?: string; +} diff --git a/console/src/app/utils/debugLogs.ts b/console/src/app/utils/debugLogs.ts new file mode 100644 index 00000000..5e4978d5 --- /dev/null +++ b/console/src/app/utils/debugLogs.ts @@ -0,0 +1,5 @@ +export const debug = (...args: unknown[]) => { + if (import.meta.env.DEV) { + console.log(...args); + } +}; diff --git a/console/src/app/utils/parseFuncs.tsx b/console/src/app/utils/parseFuncs.tsx new file mode 100644 index 00000000..c8f35ad0 --- /dev/null +++ b/console/src/app/utils/parseFuncs.tsx @@ -0,0 +1,42 @@ +import { parseISO, format } from 'date-fns'; +import { createParser } from 'nuqs'; + +export function parseScanTimestamp(ts: string | undefined) { + if (!ts) return 'N/A'; + return format(parseISO(ts), 'HH:mm:ss - dd/MM/yyyy'); +} + +export function parseNumberToCurrency(value: number | undefined) { + if (value === undefined || value === null) return '$0.00'; + return value.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + }); +} + +export function resolveResourcePath(resourceType: string, resourceName: string): string { + if (resourceType === 'cluster') { + return `/clusters/${resourceName}`; + } + + if (resourceType === 'instance') { + return `/instances/${resourceName}`; + } + + // Fallback / defensive default + return '#'; +} + +// Nullable boolean: "true" -> true, "false" -> false, missing/other -> null +export const parseAsBooleanNullable = createParser({ + parse: value => { + if (value === 'true') return true; + if (value === 'false') return false; + return null; + }, + serialize: value => { + // nuqs expects a string; return empty string to represent "unset" + if (value === null) return ''; + return value ? 'true' : 'false'; + }, +}); diff --git a/console/src/app/utils/renderUtils.tsx b/console/src/app/utils/renderUtils.tsx new file mode 100644 index 00000000..c574cc62 --- /dev/null +++ b/console/src/app/utils/renderUtils.tsx @@ -0,0 +1,86 @@ +import { ActionTypes, ActionStatus, ActionOperations, ResultStatus } from '@app/types/types'; +import { ResourceStatusApi } from '@api'; +import { Label } from '@patternfly/react-core'; +import { + PendingIcon, + OnRunningIcon, + InfoCircleIcon, + ExclamationTriangleIcon, + ExclamationCircleIcon, + UnknownIcon, +} from '@patternfly/react-icons'; + +export function renderActionStatusLabel(labelText: string | null | undefined) { + switch (labelText) { + case ActionStatus.Running: + return ; + case ActionStatus.Success: + return ; + case ActionStatus.Failed: + return ; + case ActionStatus.Pending: + return ; + default: + return ; + } +} + +export function renderStatusLabel(labelText: string | null | undefined) { + switch (labelText) { + case ResourceStatusApi.Running: + return ; + case ResourceStatusApi.Stopped: + return ; + case ResourceStatusApi.Terminated: + return ; + default: + return ; + } +} + +export function renderActionTypeLabel(labelText: string | null | undefined) { + switch (labelText) { + case ActionTypes.INSTANT_ACTION: + return ; + case ActionTypes.SCHEDULED_ACTION: + return ; + case ActionTypes.CRON_ACTION: + return ; + default: + return ; + } +} + +export function renderOperationLabel(labelText: string | null | undefined) { + switch (labelText) { + case ActionOperations.POWER_ON: + return ; + case ActionOperations.POWER_OFF: + return ; + default: + return ; + } +} + +export const getResultIcon = (result: ResultStatus) => { + return ( + { + [ResultStatus.Success]: ( + + ), + [ResultStatus.Running]: ( + + ), + [ResultStatus.Pending]: ( + + ), + [ResultStatus.Failed]: ( + + ), + [ResultStatus.Warning]: ( + + ), + [ResultStatus.Unknown]: , + }[result] || + ); +}; diff --git a/console/src/app/utils/tableFilters.ts b/console/src/app/utils/tableFilters.ts new file mode 100644 index 00000000..dc58a007 --- /dev/null +++ b/console/src/app/utils/tableFilters.ts @@ -0,0 +1,58 @@ +export function searchItems(items: T[], query: string, fields: (keyof T)[]): T[] { + if (!query || query.trim() === '') { + return items; + } + + const lowerQuery = query.toLowerCase(); + return items.filter(item => + fields.some(field => { + const value = item[field]; + return value != null && String(value).toLowerCase().includes(lowerQuery); + }) + ); +} + +export function filterByStatus(items: T[], status?: string | null): T[] { + if (!status) { + return items; + } + return items.filter(item => item.status === status); +} + +export function filterByActionType( + items: T[], + actionTypes?: string[] | null +): T[] { + if (!actionTypes || actionTypes.length === 0) { + return items; + } + return items.filter(item => item.actionType && actionTypes.includes(item.actionType)); +} + +export function filterByProvider(items: T[], providers?: string[] | null): T[] { + if (!providers || providers.length === 0) { + return items; + } + return items.filter(item => item.provider && providers.includes(item.provider)); +} + +export function paginateItems(items: T[], page: number, perPage: number): T[] { + const startIndex = (page - 1) * perPage; + const endIndex = startIndex + perPage; + return items.slice(startIndex, endIndex); +} + +export function sortItems(items: T[], field: keyof T, direction: 'asc' | 'desc'): T[] { + return [...items].sort((a, b) => { + const aVal = a[field]; + const bVal = b[field]; + + if (typeof aVal === 'number' && typeof bVal === 'number') { + return direction === 'asc' ? aVal - bVal : bVal - aVal; + } + + const aStr = String(aVal || ''); + const bStr = String(bVal || ''); + return direction === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr); + }); +} diff --git a/console/src/app/utils/useDocumentTitle.ts b/console/src/app/utils/useDocumentTitle.ts new file mode 100644 index 00000000..0442ab4a --- /dev/null +++ b/console/src/app/utils/useDocumentTitle.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; + +// a custom hook for setting the page title +export function useDocumentTitle(title: string) { + React.useEffect(() => { + const originalTitle = document.title; + document.title = title; + + return () => { + document.title = originalTitle; + }; + }, [title]); +} diff --git a/console/src/assets/favicon.png b/console/src/assets/favicon.png new file mode 100644 index 00000000..11c5cd26 Binary files /dev/null and b/console/src/assets/favicon.png differ diff --git a/console/src/assets/modal_background.png b/console/src/assets/modal_background.png new file mode 100644 index 00000000..d722d4a4 Binary files /dev/null and b/console/src/assets/modal_background.png differ diff --git a/console/src/index.tsx b/console/src/index.tsx new file mode 100644 index 00000000..219d43e4 --- /dev/null +++ b/console/src/index.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import '@patternfly/react-core/dist/styles/base.css'; +import App from './app'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + refetchOnWindowFocus: false, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + +); diff --git a/console/src/typings.d.ts b/console/src/typings.d.ts new file mode 100644 index 00000000..16df0f51 --- /dev/null +++ b/console/src/typings.d.ts @@ -0,0 +1,12 @@ +declare module '*.png'; +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.gif'; +declare module '*.svg'; +declare module '*.css'; +declare module '*.wav'; +declare module '*.mp3'; +declare module '*.m4a'; +declare module '*.rdf'; +declare module '*.ttl'; +declare module '*.pdf'; diff --git a/console/src/vite-env.d.ts b/console/src/vite-env.d.ts new file mode 100644 index 00000000..41fad5b5 --- /dev/null +++ b/console/src/vite-env.d.ts @@ -0,0 +1 @@ +declare const __APP_VERSION__: string; diff --git a/console/tsconfig.app.json b/console/tsconfig.app.json new file mode 100644 index 00000000..9b9e4089 --- /dev/null +++ b/console/tsconfig.app.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "types": ["vite/client"], + "paths": { + "@api": ["src/api"], + "@app": ["src/app"], + "@app/*": ["src/app/*"], + "@assets/*": ["node_modules/@patternfly/react-core/dist/styles/assets/*"], + "src/*": ["src/*"] + }, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "noImplicitAny": false // Disable noImplicitAny (temp) + }, + "include": ["src"] +} diff --git a/console/tsconfig.json b/console/tsconfig.json new file mode 100644 index 00000000..d32ff682 --- /dev/null +++ b/console/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/console/tsconfig.node.json b/console/tsconfig.node.json new file mode 100644 index 00000000..db0becc8 --- /dev/null +++ b/console/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/console/vite.config.ts b/console/vite.config.ts new file mode 100644 index 00000000..83fbcd2a --- /dev/null +++ b/console/vite.config.ts @@ -0,0 +1,63 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +// Custom plugin to inject headers +const injectHeaders = () => ({ + name: 'inject-headers', + configureServer(server) { + server.middlewares.use((req, res, next) => { + // Simulate authenticated user + res.setHeader('gap-auth', 'dev@cluster-iq.io'); + next(); + }); + }, +}); + +export default defineConfig({ + plugins: [react(), injectHeaders()], + define: { + __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'Development'), + }, + resolve: { + alias: { + '@api': path.resolve(__dirname, 'src/api'), + '@app': path.resolve(__dirname, 'src/app'), + '@assets': path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets'), + src: path.resolve(__dirname, 'src'), + }, + }, + build: { + sourcemap: true, + outDir: 'dist', + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom', '@patternfly/react-core'], + }, + }, + }, + }, + server: { + port: 3000, + open: true, + proxy: { + '/api': { + target: process.env.VITE_CIQ_API_URL || 'http://localhost:8081', + changeOrigin: true, + rewrite: path => path.replace(/^\/api/, '/api/v1'), + configure: proxy => { + proxy.on('error', err => { + console.log('proxy error', err); + }); + proxy.on('proxyReq', (proxyReq, req) => { + console.log('sending request to the target:', req.method, req.url); + }); + proxy.on('proxyRes', (proxyRes, req) => { + console.log('received response from the target:', proxyRes.statusCode, req.url); + }); + }, + }, + }, + }, +}); diff --git a/deployments/compose/compose-devel.yaml b/deployments/compose/compose-devel.yaml index 905b3e73..6f190c92 100644 --- a/deployments/compose/compose-devel.yaml +++ b/deployments/compose/compose-devel.yaml @@ -72,6 +72,26 @@ services: networks: - cluster_iq + console: + image: quay.io/ecosystem-appeng/cluster-iq-console:latest + container_name: console + restart: "always" + depends_on: + api: + condition: service_healthy + ports: + - 8080:8080 + environment: + BACKEND_URL: "http://api:8080" + healthcheck: + test: ["CMD-SHELL", "curl --silent -f http://localhost:8080 >/dev/null || exit 1"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - cluster_iq + pgsql: image: quay.io/ecosystem-appeng/cluster-iq-pgsql:latest container_name: pgsql diff --git a/deployments/helm/cluster-iq/Chart.yaml b/deployments/helm/cluster-iq/Chart.yaml index 68c70e9d..b5728309 100644 --- a/deployments/helm/cluster-iq/Chart.yaml +++ b/deployments/helm/cluster-iq/Chart.yaml @@ -21,4 +21,4 @@ version: 1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.5" +appVersion: "v0.6" diff --git a/doc/developers/db-backup.md b/doc/developers/db-backup.md index 42628890..20a2041f 100644 --- a/doc/developers/db-backup.md +++ b/doc/developers/db-backup.md @@ -12,9 +12,9 @@ To create a database backup follow this steps: export NAMESPACE="" ``` -2. Stop the Scanner Cronjob to prevent DB changes +2. Stop the Scanner to prevent DB changes ```sh - oc patch cronjob scanner -p '{"spec" : {"suspend" : true }}' --type=merge -n $NAMESPACE + oc scale deployment scanner --replicas=0 -n $NAMESPACE ``` 3. Run a port-forward command to have access to the DB without exposing it @@ -37,7 +37,7 @@ To create a database backup follow this steps: 5. Resume Scanner execution ```sh - oc patch cronjob scanner -p '{"spec" : {"suspend" : false }}' --type=merge -n $NAMESPACE + oc scale deployment scanner --replicas=1 -n $NAMESPACE ``` 6. Stop port-forward process and check the backup file looks good. @@ -53,9 +53,9 @@ To restore a database backup follow this steps: export NAMESPACE="" ``` -2. Stop the Scanner Cronjob to prevent DB changes +2. Stop the Scanner to prevent DB changes ```sh - oc patch cronjob scanner -p '{"spec" : {"suspend" : true }}' --type=merge -n $NAMESPACE + oc scale deployment scanner --replicas=0 -n $NAMESPACE ``` 3. Run a port-forward command to have access to the DB without exposing it @@ -77,9 +77,9 @@ To restore a database backup follow this steps: --backup ``` -5. Resume scanner CronJob +5. Resume Scanner execution ```sh - oc patch cronjob scanner -p '{"spec" : {"suspend" : false }}' --type=merge -n $NAMESPACE + oc scale deployment scanner --replicas=1 -n $NAMESPACE ``` 6. Stop port-forward process and check the database was correctly restored diff --git a/doc/developers/development-setup.md b/doc/developers/development-setup.md index f6e6dd84..7cdab2e1 100644 --- a/doc/developers/development-setup.md +++ b/doc/developers/development-setup.md @@ -2,12 +2,7 @@ This guide describes how to build and deploy [ClusterIQ](https://github.com/RHEcosystemAppEng/cluster-iq) in a development environment. The setup uses container compose files and is intended for development purposes only. -ClusterIQ consists of two repositories: - -* [Console Repo](https://github.com/RHEcosystemAppEng/cluster-iq-console) contains the web user interface. -* [Main Repo](https://github.com/RHEcosystemAppEng/cluster-iq-console) contains the API and Scanner components. - -Each repository requires separate configuration and management. +ClusterIQ is a monorepo containing both the backend (Go) and the web console (React/TypeScript) under the `console/` directory. ## Prerequisites @@ -23,11 +18,12 @@ To temporarily disable SELinux: sudo setenforce 0 ``` -[!NOTE] Use this command with caution and only in development environments. +> [!NOTE] Use this command with caution and only in development environments. ## Build dependencies -* [go v1.24](https://go.dev/dl/) +* [Go v1.25](https://go.dev/dl/) +* [Node.js 18.x](https://nodejs.org/) and npm * [podman](https://podman.io/docs/installation) or [docker](https://docs.docker.com/engine/install) * [podman-compose](https://github.com/containers/podman-compose?tab=readme-ov-file#installation) or [docker-compose](https://docs.docker.com/compose/install/) * [swag](https://github.com/swaggo/swag?tab=readme-ov-file#getting-started) @@ -36,45 +32,30 @@ sudo setenforce 0 Follow these steps to build the ClusterIQ components: -1. Create and navigate to a common folder for both repos: - - ```sh - WORKDIR=$(pwd)/cluster-iq-repos - mkdir -p $WORKDIR && cd $WORKDIR - ``` - -2. Clone the repositories: +1. Clone the repository: ```sh git clone git@github.com:RHEcosystemAppEng/cluster-iq.git - git clone git@github.com:RHEcosystemAppEng/cluster-iq-console.git + cd cluster-iq ``` -3. Validate required dependencies: +2. Validate required dependencies: If you encounter an error, please ensure that you have installed all the necessary dependencies before proceeding. ```sh - cd ${WORKDIR}/cluster-iq make check-dependencies ``` -4. Build the container images: - - ```sh - git checkout main - make build - ``` +3. Build the container images (backend + console): ```sh - cd ${WORKDIR}/cluster-iq-console - git checkout main make build ``` -5. Verify the container images: +4. Verify the container images: - You should see the following images `cluster-iq-api`, `cluster-iq-scanner`, `cluster-iq-console` + You should see `cluster-iq-api`, `cluster-iq-scanner`, `cluster-iq-agent`, `cluster-iq-pgsql`, and `cluster-iq-console`. ```sh CONTAINER_ENGINE=$(which podman >/dev/null 2>&1 && echo podman || echo docker) @@ -85,25 +66,36 @@ Follow these steps to build the ClusterIQ components: To manage your development environment: -1. Change the working directory to `cluster-iq` repo - - ```sh - cd ${WORKDIR}/cluster-iq - ``` +1. Configure your [cloud account credentials](../../README.md#accounts-configuration). -2. Configure your [cloud account credentials](../README.md#accounts-configuration). -3. Start the environment: +2. Start the environment: ```sh make start-dev ``` -4. Stop the environment: + This starts all services (API, Scanner, Agent, Console, PostgreSQL) via compose. + - API: http://localhost:8081/api/v1/healthcheck + - Console: http://localhost:8080 + +3. Stop the environment: ```sh make stop-dev ``` +## Console Development + +For working on the console frontend locally (with hot-reload): + +```sh +make console-install # Install npm dependencies +make console-start-dev # Start Vite dev server (port 3000, proxies API to localhost:8081) +make console-lint # Run prettier + eslint + tsc +``` + +See `console/README.md` for more details. + ## API Documentation ### Generating Swagger Documentation diff --git a/doc/developers/publish-new-release-checklist.md b/doc/developers/publish-new-release-checklist.md index 2d20c810..e106c683 100644 --- a/doc/developers/publish-new-release-checklist.md +++ b/doc/developers/publish-new-release-checklist.md @@ -15,6 +15,7 @@ This procedure does **NOT**: - Local Git repository is clean (`git status` shows no pending changes) - User has push permissions to the ClusterIQ repository - Go toolchain installed and configured +- Node.js 18.x and npm installed (for console) - Helm CLI installed - GitHub Actions service available @@ -39,14 +40,15 @@ This procedure does **NOT**: **Expected result:** no pending PRs or commits expected for this release -* [ ] **P4** — Run unit tests and linters. +* [ ] **P4** — Run unit tests and linters (backend + console). ```sh make clean build go-tests + make console-lint ``` - **Expected result:** command exits with status `0` + **Expected result:** all commands exit with status `0` **DO NOT CONTINUE** if any error is reported -* [ ] **P5** — Update the `./VERSION` file with the release version. +* [ ] **P5** — Update the `./VERSION` file and `console/package.json` version with the release version. **Expected result:** `./VERSION` contains exactly `vX.Y.Z` diff --git a/doc/vault/README.md b/doc/vault/README.md index 21863f5e..67bf42b0 100644 --- a/doc/vault/README.md +++ b/doc/vault/README.md @@ -74,11 +74,11 @@ helm upgrade --install cluster-iq deployments/helm/cluster-iq -n $APP_NS -f vaul **3. Verification** ```bash -# Trigger a test job -oc create job --from=cronjob/scanner scanner-test -n $APP_NS +# Check scanner logs +oc logs deployment/scanner -n $APP_NS -# Check logs for success -oc logs job/scanner-test -n $APP_NS +# Verify credentials mount in scanner pod +oc exec -n $APP_NS deployment/scanner -- ls -la /credentials # Verify credentials mount in agent pod oc exec -n $APP_NS deployment/agent -- ls -la /credentials diff --git a/test/integration/api_instances_integration_test.go b/test/integration/api_instances_integration_test.go index 126d878c..3761bce5 100644 --- a/test/integration/api_instances_integration_test.go +++ b/test/integration/api_instances_integration_test.go @@ -451,7 +451,7 @@ func testPostInstancesWithTags(t *testing.T) { func testPostInstancesWrongValues(t *testing.T) { expectedHTTPCode := http.StatusInternalServerError - expectedMsg := "Failed to create instances: create instances: named-exec INSERT error: pq: invalid input value for enum cloud_provider: \"PROVIDER\"" + expectedMsg := "Failed to create instances: create instances: failed to insert instances: pq: invalid input value for enum cloud_provider: \"PROVIDER\"" payload := []dto.InstanceDTORequest{ { InstanceID: "error-instance",