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..fb95b70d 100644 --- a/Makefile +++ b/Makefile @@ -45,9 +45,14 @@ AGENT_IMG_NAME ?= $(PROJECT_NAME)-agent AGENT_IMAGE ?= $(REGISTRY)/$(REGISTRY_REPO)/$(AGENT_IMG_NAME) AGENT_CONTAINERFILE ?= ./$(DEPLOYMENTS_DIR)/containerfiles/Containerfile-agent AGENT_PROTO_PATH ?= ./cmd/agent/proto/agent.proto +SCANNER_PROTO_PATH ?= ./cmd/scanner/proto/scanner.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 @@ -77,6 +82,8 @@ local-build-api: generate-converters swagger-doc ## Build the API binary local-build-scanner: ## Build the scanner binary @echo "### [Building Scanner] ###" + @[ ! -d $(GENERATED_DIR) ] && { mkdir $(GENERATED_DIR); } || { exit 0; } + @$(PROTOC) --go_out=$(GENERATED_DIR) --go-grpc_out=$(GENERATED_DIR) $(SCANNER_PROTO_PATH) @$(GO) build -o $(BIN_DIR)/scanners/scanner $(LDFLAGS) ./cmd/scanner local-build-agent: ## Build the agent binary @@ -89,10 +96,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 +136,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 +224,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/cmd/agent/agent.go b/cmd/agent/agent.go index 4adea821..bdbdf7bb 100644 --- a/cmd/agent/agent.go +++ b/cmd/agent/agent.go @@ -18,7 +18,6 @@ package main import ( "context" "fmt" - "log" "os" "os/signal" "sync" @@ -194,14 +193,14 @@ func LoggingInterceptor( logger.Info("Client connected", zap.String("ip", p.Addr.String())) } - log.Printf("Invoked method: %s", info.FullMethod) + logger.Info("Invoked method", zap.String("method", info.FullMethod)) resp, err := handler(ctx, req) if err != nil { - log.Printf("Error in method %s: %v", info.FullMethod, err) + logger.Error("Method failed", zap.String("method", info.FullMethod), zap.Error(err)) } else { - log.Printf("Method %s executed successfully", info.FullMethod) + logger.Info("Method executed successfully", zap.String("method", info.FullMethod)) } return resp, err diff --git a/cmd/agent/executor_agent_service.go b/cmd/agent/executor_agent_service.go index 5ba8b830..2f6b07c4 100644 --- a/cmd/agent/executor_agent_service.go +++ b/cmd/agent/executor_agent_service.go @@ -6,9 +6,11 @@ import ( "crypto/tls" "fmt" "net/http" + "strconv" "sync" "github.com/RHEcosystemAppEng/cluster-iq/internal/actions" + "github.com/RHEcosystemAppEng/cluster-iq/internal/clients" cexec "github.com/RHEcosystemAppEng/cluster-iq/internal/cloud_executors" "github.com/RHEcosystemAppEng/cluster-iq/internal/config" "github.com/RHEcosystemAppEng/cluster-iq/internal/credentials" @@ -28,6 +30,8 @@ type ExecutorAgentService struct { client http.Client // HTTP Client for retrieving the schedule from API eventService *eventservice.EventService // Service for handling audit logs actionRepo repositories.ActionRepository + scannerClient *clients.ScannerGRPCClient // gRPC client for the Scanner service + actionRunRepo repositories.ActionRunRepository } // NewExecutorAgentService creates and initializes a new AgentCron instance for managing the scheduled actions @@ -57,6 +61,13 @@ func NewExecutorAgentService(cfg *config.ExecutorAgentServiceConfig, actionsChan eventService := eventservice.NewEventService(db, logger) actionRepo := repositories.NewActionRepository(db) + actionRunRepo := repositories.NewActionRunRepository(db) + + scannerClient, err := clients.NewScannerGRPCClient(cfg.ScannerURL, logger) + if err != nil { + logger.Error("Failed to create Scanner gRPC client", zap.Error(err)) + return nil + } eas := ExecutorAgentService{ cfg: cfg, @@ -66,9 +77,11 @@ func NewExecutorAgentService(cfg *config.ExecutorAgentServiceConfig, actionsChan logger: logger, wg: wg, }, - client: client, - eventService: eventService, - actionRepo: actionRepo, + client: client, + eventService: eventService, + actionRepo: actionRepo, + scannerClient: scannerClient, + actionRunRepo: actionRunRepo, } // Reading credentials file and creating executors per account @@ -90,7 +103,10 @@ func NewExecutorAgentService(cfg *config.ExecutorAgentServiceConfig, actionsChan func (e *ExecutorAgentService) readCloudProviderAccounts() ([]credentials.AccountConfig, error) { accounts, err := credentials.ReadCloudAccounts(e.cfg.Credentials.CredentialsFile) if err != nil { - return nil, err + if len(accounts) == 0 { + return nil, err + } + e.logger.Warn("Some accounts were skipped due to invalid credentials", zap.Error(err)) } return accounts, nil @@ -198,25 +214,47 @@ func (e *ExecutorAgentService) processAction(action actions.Action) { zap.Any("requester", action.GetRequester()), ) - // Initialize event tracker + target := action.GetTarget() + + resourceType := inventory.ClusterResourceType + resourceID := target.ClusterID + if action.GetActionOperation() == actions.Scan { + resourceType = inventory.AccountResourceType + if len(target.TargetAccountIDs) > 0 { + resourceID = target.TargetAccountIDs[0] + } + } + + var scheduleID *int64 + if sid, err := strconv.ParseInt(action.GetID(), 10, 64); err == nil { + scheduleID = &sid + } + tracker := e.eventService.StartTracking(&eventservice.EventOptions{ Action: action.GetActionOperation(), Description: action.GetDescription(), - ResourceID: action.GetTarget().ClusterID, - ResourceType: inventory.ClusterResourceType, + ResourceID: resourceID, + ResourceType: resourceType, Result: eventservice.ResultPending, Severity: eventservice.SeverityInfo, - TriggeredBy: action.GetRequester(), + Requester: action.GetRequester(), + ScheduleID: scheduleID, }) + if action.GetActionOperation() == actions.Scan { + e.processScanAction(action, tracker) + return + } + // Mark as running if !e.setActionStatus(action, actions.StatusRunning) { tracker.Failed() return } + tracker.Running() // Get executor - executor := e.GetExecutor(action.GetTarget().AccountID) + executor := e.GetExecutor(target.AccountID) if executor == nil { e.handleMissingExecutor(action, tracker) return @@ -235,6 +273,60 @@ func (e *ExecutorAgentService) processAction(action actions.Action) { e.resetCronActionStatus(action) } +// processScanAction dispatches a Scan action to the Scanner gRPC service. +func (e *ExecutorAgentService) processScanAction(action actions.Action, tracker *eventservice.EventTracker) { + if !e.setActionStatus(action, actions.StatusRunning) { + tracker.Failed() + return + } + tracker.Running() + + target := action.GetTarget() + + runID, err := e.actionRunRepo.Create(context.Background(), action.GetID()) + if err != nil { + e.logger.Error("Failed to create action run for scan", + zap.String("action_id", action.GetID()), zap.Error(err)) + e.setActionStatus(action, actions.StatusFailed) + tracker.Failed() + return + } + runIDStr := strconv.FormatInt(runID, 10) + + resp, err := e.scannerClient.Scan( + context.Background(), + runID, + target.TargetAccountIDs, + target.SelectAll, + ) + if err != nil { + e.logger.Error("Scanner gRPC call failed", + zap.String("action_id", action.GetID()), zap.Error(err)) + e.setActionStatus(action, actions.StatusFailed) + _ = e.actionRunRepo.Update(context.Background(), runIDStr, "Failed", err.Error()) + tracker.Failed() + return + } + + if resp.Error != 0 { + e.logger.Error("Scanner returned error", + zap.String("action_id", action.GetID()), + zap.String("message", resp.Message)) + e.setActionStatus(action, actions.StatusFailed) + _ = e.actionRunRepo.Update(context.Background(), runIDStr, "Failed", resp.Message) + tracker.Failed() + return + } + + e.logger.Info("Scan completed successfully", + zap.String("action_id", action.GetID()), + zap.Int32("accounts_scanned", resp.AccountsScanned)) + e.setActionStatus(action, actions.StatusSuccess) + _ = e.actionRunRepo.Update(context.Background(), runIDStr, "Success", "") + tracker.Success() + e.resetCronActionStatus(action) +} + // setActionStatus safely updates action status with type assertion. // Returns false if update failed (caller should abort). func (e *ExecutorAgentService) setActionStatus(action actions.Action, status actions.ActionStatus) bool { diff --git a/cmd/agent/schedule_agent_service.go b/cmd/agent/schedule_agent_service.go index f2b23382..15eb6db9 100644 --- a/cmd/agent/schedule_agent_service.go +++ b/cmd/agent/schedule_agent_service.go @@ -271,26 +271,45 @@ func (a *ScheduleAgentService) ScheduleNewActions(newSchedule []actions.Action) // Checking the entire new schedule to schedule or reschedule actions for _, action := range newSchedule { - var scheduledFunc func(*actions.ScheduledAction) - var cronFunc func(*actions.CronAction) - - if _, exists := a.schedule[action.GetID()]; !exists { // Schedule new actions - scheduledFunc = a.scheduleNewScheduledAction - cronFunc = a.scheduleNewCronAction - } else { // Reschedule actions - scheduledFunc = a.rescheduleScheduledAction - cronFunc = a.rescheduleCronAction - } + a.dispatchActionLocked(action) + } +} - // managing actions based on type - switch t := action.(type) { - case *actions.ScheduledAction: - scheduledFunc(t) - case *actions.CronAction: - cronFunc(t) - default: - a.logger.Error("Unknown action type", zap.String("action_id", action.GetID())) +// dispatchActionLocked schedules, reschedules, or dispatches a single action. +// Must be called with a.mutex held. +func (a *ScheduleAgentService) dispatchActionLocked(action actions.Action) { + _, exists := a.schedule[action.GetID()] + + switch t := action.(type) { + case *actions.InstantAction: + if exists { + return + } + a.logger.Info("Dispatching InstantAction for immediate execution", zap.String("action_id", t.GetID())) + a.schedule[t.GetID()] = scheduleItem{ + cancel: func() {}, + action: t, + } + go func() { + a.actionsChannel <- t + a.mutex.Lock() + delete(a.schedule, t.GetID()) + a.mutex.Unlock() + }() + case *actions.ScheduledAction: + if !exists { + a.scheduleNewScheduledAction(t) + } else { + a.rescheduleScheduledAction(t) + } + case *actions.CronAction: + if !exists { + a.scheduleNewCronAction(t) + } else { + a.rescheduleCronAction(t) } + default: + a.logger.Error("Unknown action type", zap.String("action_id", action.GetID())) } } diff --git a/cmd/api/router.go b/cmd/api/router.go index 7900e2c7..f1dea478 100644 --- a/cmd/api/router.go +++ b/cmd/api/router.go @@ -14,6 +14,7 @@ type APIHandlers struct { ExpenseHandler *handlers.ExpenseHandler EventHandler *handlers.EventHandler ActionHandler *handlers.ActionHandler + ActionRunHandler *handlers.ActionRunHandler OverviewHandler *handlers.OverviewHandler HealthCheckHandler *handlers.HealthCheckHandler } @@ -30,6 +31,7 @@ func Setup(engine *gin.Engine, handlers APIHandlers) { setupExpenseRoutes(baseGroup, handlers.ExpenseHandler) setupEventRoutes(baseGroup, handlers.EventHandler) setupActionRoutes(baseGroup, handlers.ActionHandler) + setupActionRunRoutes(baseGroup, handlers.ActionRunHandler) setupOverviewRoutes(baseGroup, handlers.OverviewHandler) } } @@ -50,6 +52,7 @@ func setupAccountRoutes(group *gin.RouterGroup, handler *handlers.AccountHandler accounts.GET("/:id", handler.GetByID) accounts.GET("/:id/clusters", handler.GetAccountClustersByID) accounts.GET("/:id/expense_update", handler.GetExpensesUpdateInstances) + accounts.GET("/:id/daily-costs", handler.GetDailyCosts) accounts.PATCH("/:id", handler.Update) accounts.DELETE("/:id", handler.Delete) } @@ -110,6 +113,16 @@ func setupActionRoutes(group *gin.RouterGroup, handler *handlers.ActionHandler) } } +func setupActionRunRoutes(group *gin.RouterGroup, handler *handlers.ActionRunHandler) { + actionRuns := group.Group("/action-runs") + { + actionRuns.GET("", handler.List) + actionRuns.GET("/:id", handler.Get) + actionRuns.POST("", handler.Create) + actionRuns.PATCH("/:id", handler.Update) + } +} + func setupOverviewRoutes(group *gin.RouterGroup, handler *handlers.OverviewHandler) { overview := group.Group("/overview") { diff --git a/cmd/api/server.go b/cmd/api/server.go index e5bd8b3d..bb33aec7 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -200,6 +200,7 @@ func main() { expenseRepo := repositories.NewExpenseRepository(dbClient) eventRepo := repositories.NewEventRepository(dbClient) actionRepo := repositories.NewActionRepository(dbClient) + actionRunRepo := repositories.NewActionRunRepository(dbClient) // Initializing services inventoryService := services.NewInventoryService(inventoryRepo) @@ -212,6 +213,7 @@ func main() { expenseService := services.NewExpenseService(expenseRepo) eventService := services.NewEventService(eventRepo) actionService := services.NewActionService(actionRepo) + actionRunService := services.NewActionRunService(actionRunRepo) overviewService := services.NewOverviewService(clusterRepo, instanceRepo, accountRepo) // Initializing handlers @@ -223,6 +225,7 @@ func main() { ExpenseHandler: handlers.NewExpenseHandler(expenseService, logger), EventHandler: handlers.NewEventHandler(eventService, logger), ActionHandler: handlers.NewActionHandler(actionService, logger), + ActionRunHandler: handlers.NewActionRunHandler(actionRunService, logger), OverviewHandler: handlers.NewOverviewHandler(overviewService, logger), HealthCheckHandler: handlers.NewHealthCheckHandler(dbClient, logger), } diff --git a/cmd/scanner/proto/scanner.proto b/cmd/scanner/proto/scanner.proto new file mode 100644 index 00000000..6940f2a7 --- /dev/null +++ b/cmd/scanner/proto/scanner.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package scanner; + +option go_package = "./scanner"; + +// gRPC service for the Scanner +service ScannerService { + rpc Scan (ScanRequest) returns (ScanResponse); + rpc Health (HealthRequest) returns (HealthResponse); +} + +// Message for requesting a scan +message ScanRequest { + int64 run_id = 1; + repeated string account_ids = 2; + bool select_all = 3; +} + +// Message for answering to ScanRequests +message ScanResponse { + int32 error = 1; + string message = 2; + int32 accounts_scanned = 3; +} + +// Message for health check requests +message HealthRequest {} + +// Message for health check responses +message HealthResponse { + bool ready = 1; +} diff --git a/cmd/scanner/scanner.go b/cmd/scanner/scanner.go index e7c5ba16..48d4e57f 100644 --- a/cmd/scanner/scanner.go +++ b/cmd/scanner/scanner.go @@ -6,8 +6,10 @@ import ( "crypto/md5" "crypto/tls" "encoding/json" + "errors" "fmt" "io" + "net" "net/http" "os" "os/signal" @@ -15,6 +17,7 @@ import ( "syscall" "time" + pb "github.com/RHEcosystemAppEng/cluster-iq/generated/scanner" responsetypes "github.com/RHEcosystemAppEng/cluster-iq/internal/api/response_types" "github.com/RHEcosystemAppEng/cluster-iq/internal/config" "github.com/RHEcosystemAppEng/cluster-iq/internal/credentials" @@ -23,6 +26,8 @@ import ( "github.com/RHEcosystemAppEng/cluster-iq/internal/models/dto" "github.com/RHEcosystemAppEng/cluster-iq/internal/stocker" "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" ) const ( @@ -31,9 +36,12 @@ const ( apiClusterEndpoint = "/clusters" apiInstanceEndpoint = "/instances" apiExpenseEndpoint = "/expenses" - // apiRequestTimeout defines the timeout for HTTP POST requests to the API apiRequestTimeout = 60 * time.Second + // apiHealthcheckTimeout defines the timeout for each healthcheck attempt + apiHealthcheckTimeout = 5 * time.Second + // apiHealthcheckRetryInterval defines how long to wait between healthcheck retries + apiHealthcheckRetryInterval = 3 * time.Second ) var ( @@ -57,16 +65,17 @@ var ( // Scanner models the cloud agnostic Scanner for looking up OCP deployments type Scanner struct { - inventory inventory.Inventory - stockers []stocker.Stocker - billingStockers []stocker.Stocker - cfg *config.ScannerConfig - logger *zap.Logger + pb.UnimplementedScannerServiceServer + allAccounts map[string]credentials.AccountConfig + cfg *config.ScannerConfig + logger *zap.Logger + grpcServer *grpc.Server + mu sync.Mutex + scanning bool } // NewScanner creates and returns a new Scanner instance func NewScanner(cfg *config.ScannerConfig, logger *zap.Logger) *Scanner { - // Calculate Credentials file MD5 checksum for checking on runtime hash := md5.Sum([]byte(cfg.CredentialsFile)) credsFileHash = hash[:] @@ -77,81 +86,134 @@ func NewScanner(cfg *config.ScannerConfig, logger *zap.Logger) *Scanner { APIURL = cfg.APIURL return &Scanner{ - inventory: *inventory.NewInventory(), - stockers: make([]stocker.Stocker, 0), - cfg: cfg, - logger: logger, + allAccounts: make(map[string]credentials.AccountConfig), + cfg: cfg, + logger: logger, } } func init() { - // Initialize logging configuration. logger = ciqLogger.NewLogger() } -// readCloudProviderAccounts reads and loads cloud provider accounts from a credentials file. -func (s *Scanner) readCloudProviderAccounts() error { - // Load cloud accounts credentials file. +// loadAccounts reads the credentials file and caches all account configs. +func (s *Scanner) loadAccounts() error { accountConfigs, err := credentials.ReadCloudAccounts(s.cfg.CredentialsFile) if err != nil { - return err + if len(accountConfigs) == 0 { + return fmt.Errorf("failed to read cloud accounts: %w", err) + } + s.logger.Warn("Some accounts were skipped due to invalid credentials", zap.Error(err)) } - // Read INI file content. - for _, accountConfig := range accountConfigs { - newAccount, err := inventory.NewAccount( - accountConfig.ID, - accountConfig.Name, - accountConfig.Provider, - accountConfig.User, - accountConfig.Key, - ) - if err != nil { - return err - } + for _, ac := range accountConfigs { + s.allAccounts[ac.ID] = ac + } - // Getting billing enabled flag from config - if accountConfig.BillingEnabled { - newAccount.EnableBilling() - } + s.logger.Info("Loaded cloud accounts from credentials file", + zap.Int("count", len(s.allAccounts))) - // Adding account to Inventory for scanning - if err := s.inventory.AddAccount(newAccount); err != nil { - return err + return nil +} + +// waitForAPI blocks until the API server responds to /healthcheck. +func (s *Scanner) waitForAPI() { + url := fmt.Sprintf("%s/healthcheck", APIURL) + for { + ctx, cancel := context.WithTimeout(context.Background(), apiHealthcheckTimeout) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err == nil { + resp, err := client.Do(req) + if resp != nil { + resp.Body.Close() + } + if err == nil && resp.StatusCode == http.StatusOK { + cancel() + s.logger.Info("API server is ready") + return + } } + cancel() + s.logger.Info("Waiting for API server...", zap.String("url", url)) + time.Sleep(apiHealthcheckRetryInterval) } +} +// seedAccounts posts account records from the credentials file to the API +// so they are available in the console before the first scan runs. +func (s *Scanner) seedAccounts() error { + var accounts []dto.AccountDTORequest + for _, ac := range s.allAccounts { + accounts = append(accounts, dto.AccountDTORequest{ + AccountID: ac.ID, + AccountName: ac.Name, + Provider: ac.Provider, + }) + } + + b, err := json.Marshal(accounts) + if err != nil { + return fmt.Errorf("failed to marshal seed accounts: %w", err) + } + + if err := postData(apiAccountEndpoint, b); err != nil { + return fmt.Errorf("failed to seed accounts: %w", err) + } + + if err := refreshInventory(s.logger); err != nil { + return fmt.Errorf("failed to refresh materialized views after seed: %w", err) + } + + s.logger.Info("Seeded accounts into database", zap.Int("count", len(accounts))) return nil } -// nolint:cyclop // createStockers creates and configures stocker instances for each provided account to be inventoried. -func (s *Scanner) createStockers() error { - for _, account := range s.inventory.Accounts { +// buildInventory creates an Inventory from the given account configs. +func (s *Scanner) buildInventory(configs []credentials.AccountConfig) (*inventory.Inventory, error) { + inv := inventory.NewInventory() + for _, ac := range configs { + newAccount, err := inventory.NewAccount(ac.ID, ac.Name, ac.Provider, ac.User, ac.Key) + if err != nil { + return nil, fmt.Errorf("failed to create account %s: %w", ac.ID, err) + } + if ac.BillingEnabled { + newAccount.EnableBilling() + } + if err := inv.AddAccount(newAccount); err != nil { + return nil, fmt.Errorf("failed to add account %s: %w", ac.ID, err) + } + } + return inv, nil +} + +// nolint:cyclop +func (s *Scanner) createStockers(inv *inventory.Inventory) ([]stocker.Stocker, []stocker.Stocker, []error) { + var stockers []stocker.Stocker + var billingStockers []stocker.Stocker + var errs []error + + for _, account := range inv.Accounts { switch account.Provider { case inventory.AWSProvider: s.logger.Info("Processing AWS account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) - - // AWS API Stoker awsStocker, err := stocker.NewAWSStocker(account, s.cfg.SkipNoOpenShiftInstances, s.logger) if err != nil { s.logger.Error("Failed to create AWS stocker; skipping this account", - zap.String("account", account.AccountName), - zap.Error(err)) + zap.String("account", account.AccountName), zap.Error(err)) + errs = append(errs, fmt.Errorf("account %s: %w", account.AccountName, err)) continue } - s.stockers = append(s.stockers, awsStocker) + stockers = append(stockers, awsStocker) - // AWS Billing API Stoker if account.IsBillingEnabled() { - s.logger.Warn("Enabled AWS Billing Stocker", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) - instancesToScan, err := s.getInstancesForBillingUpdate(account.AccountID) + s.logger.Info("Enabled AWS Billing Stocker", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) + instancesToScan, err := getInstancesForBillingUpdate(s.cfg.APIURL, account.AccountID, s.logger) if err != nil { - s.logger.Error("Failed to retrieve the list of instances required for billing information from AWS Cost Explorer.", - zap.String("account_name", account.AccountName), - zap.Error(err)) + s.logger.Error("Failed to retrieve instances for billing", + zap.String("account_name", account.AccountName), zap.Error(err)) } else { if bs := stocker.NewAWSBillingStocker(account, s.logger, instancesToScan); bs != nil { - s.billingStockers = append(s.billingStockers, bs) + billingStockers = append(billingStockers, bs) } } } @@ -159,18 +221,12 @@ func (s *Scanner) createStockers() error { s.logger.Warn("Failed to scan GCP account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName), - zap.String("reason", "not implemented"), - ) - // TODO: Uncomment line below when GCP Stocker is implemented - // gcpStocker = stocker.NewGCPStocker(account, s.cfg.SkipNoOpenShiftInstances, s.logger)) + zap.String("reason", "not implemented")) case inventory.AzureProvider: s.logger.Warn("Failed to scan Azure account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName), - zap.String("reason", "not implemented"), - ) - // TODO: Uncomment line below when Azure Stocker is implemented - // azureStocker = stocker.NewAzureStocker(account, s.cfg.SkipNoOpenShiftInstances, s.logger)) + zap.String("reason", "not implemented")) case inventory.UnknownProvider: s.logger.Warn("Unknown cloud provider, skipping account", zap.String("account_id", account.AccountID), @@ -185,120 +241,207 @@ func (s *Scanner) createStockers() error { } s.logger.Info("Account registration complete", - zap.Int("registeredAccounts", len(s.inventory.Accounts)), - zap.Int("registeredStockers", len(s.stockers)), - zap.Int("skippedAccounts", len(s.inventory.Accounts)-len(s.stockers))) + zap.Int("registeredAccounts", len(inv.Accounts)), + zap.Int("registeredStockers", len(stockers)), + zap.Int("skippedAccounts", len(inv.Accounts)-len(stockers))) - // If there are no stockers, nothing to do - if len(s.stockers) == 0 { - return fmt.Errorf("no valid accounts found for scanning") - } - - // Checking the logLevel before entering on the For loop for optimization - if s.logger.Core().Enabled(zap.DebugLevel) { - s.logger.Debug("Total Stockers created", zap.Int("count", len(s.stockers))) - for i, stocker := range s.stockers { - s.logger.Debug("Stocker", zap.Int("id", i), zap.String("account_id", stocker.GetAccount().AccountID), zap.String("account_name", stocker.GetAccount().AccountName)) - } - } - - return nil + return stockers, billingStockers, errs } -// startStockers runs every stocker instance -func (s *Scanner) startStockers() error { +func runStockers(stockers []stocker.Stocker, billingStockers []stocker.Stocker, l *zap.Logger) error { var wg sync.WaitGroup - errChan := make(chan error, len(s.stockers)+len(s.billingStockers)) + errChan := make(chan error, len(stockers)+len(billingStockers)) - // First iteration for infrastructure stockers - s.logger.Warn("Running Infrastructure Stockers!", zap.Int("stockers_count", len(s.stockers))) - for _, stockerInstance := range s.stockers { + l.Warn("Running Infrastructure Stockers!", zap.Int("stockers_count", len(stockers))) + for _, st := range stockers { wg.Add(1) go func() { defer wg.Done() - if err := stockerInstance.MakeStock(); err != nil { + if err := st.MakeStock(); err != nil { errChan <- err } }() } - - // Waiting for every Stock wg.Wait() - // Second iteration for billing stockers - s.logger.Warn("Running Billing Stockers!", zap.Int("stockers_count", len(s.billingStockers))) - for _, stockerInstance := range s.billingStockers { + l.Warn("Running Billing Stockers!", zap.Int("stockers_count", len(billingStockers))) + for _, st := range billingStockers { wg.Add(1) go func() { defer wg.Done() - if err := stockerInstance.MakeStock(); err != nil { + if err := st.MakeStock(); err != nil { errChan <- err } }() } - // Waiting for every Stock go func() { wg.Wait() close(errChan) }() - // Collecting stockers errors var errorList []error for err := range errChan { errorList = append(errorList, err) } - // Processing errors when every stocker has finished if len(errorList) > 0 { for _, err := range errorList { - s.logger.Error("Stocker Error", zap.Error(err)) + l.Error("Stocker Error", zap.Error(err)) } return fmt.Errorf("error when running Scanner stockers. Failed Stockers: (%d)", len(errorList)) } - s.logger.Info("Stockers executed correctly") + l.Info("Stockers executed correctly") return nil } -// postNewAccount posts into the API an account, its clusters, instances and expenses -func (s *Scanner) postNewAccount(account inventory.Account) error { - s.logger.Debug("Posting new Account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) +// selectAccounts returns account configs matching the given IDs, or all accounts if selectAll is true or no IDs are provided. +func (s *Scanner) selectAccounts(accountIDs []string, selectAll bool) []credentials.AccountConfig { + var configs []credentials.AccountConfig + if selectAll || len(accountIDs) == 0 { + for _, ac := range s.allAccounts { + configs = append(configs, ac) + } + return configs + } + + for _, id := range accountIDs { + ac, ok := s.allAccounts[id] + if !ok { + s.logger.Warn("Account not found in credentials, skipping", zap.String("account_id", id)) + continue + } + configs = append(configs, ac) + } + return configs +} + +// ExecuteScan runs the full scan pipeline for the given accounts. +func (s *Scanner) ExecuteScan(accountIDs []string, selectAll bool) (int, error) { + s.mu.Lock() + if s.scanning { + s.mu.Unlock() + return 0, fmt.Errorf("a scan is already in progress") + } + s.scanning = true + s.mu.Unlock() + + defer func() { + s.mu.Lock() + s.scanning = false + s.mu.Unlock() + }() + + configs := s.selectAccounts(accountIDs, selectAll) + if len(configs) == 0 { + return 0, fmt.Errorf("no valid accounts found for scanning") + } + + inv, err := s.buildInventory(configs) + if err != nil { + return 0, fmt.Errorf("failed to build inventory: %w", err) + } + + stockers, billingStockers, stockerErrors := s.createStockers(inv) + if len(stockers) == 0 { + return 0, fmt.Errorf("no valid stockers created: %w", errors.Join(stockerErrors...)) + } + + if err := runStockers(stockers, billingStockers, s.logger); err != nil { + return 0, fmt.Errorf("failed to run stockers: %w", err) + } + + s.logger.Info("Inventory scan complete", + zap.Int("accounts", len(inv.Accounts)), + zap.Int("clusters", inv.TotalClusters()), + zap.Int("instances", inv.TotalInstances()), + ) + if err := postScannerInventory(inv, s.logger); err != nil { + return 0, fmt.Errorf("failed to post inventory: %w", err) + } + + return len(configs), nil +} + +// Scan handles a gRPC scan request. +func (s *Scanner) Scan(_ context.Context, req *pb.ScanRequest) (*pb.ScanResponse, error) { + s.logger.Info("Received scan request", + zap.Int64("run_id", req.RunId), + zap.Strings("account_ids", req.AccountIds), + zap.Bool("select_all", req.SelectAll)) + + scanned, err := s.ExecuteScan(req.AccountIds, req.SelectAll) + if err != nil { + s.logger.Error("Scan failed", zap.Error(err)) + return &pb.ScanResponse{ + Error: 1, + Message: err.Error(), + AccountsScanned: 0, + }, nil + } + + s.logger.Info("Scan completed successfully", zap.Int("accounts_scanned", scanned)) + return &pb.ScanResponse{ + Error: 0, + Message: "Scan completed successfully", + AccountsScanned: int32(scanned), + }, nil +} + +// Health reports the scanner's readiness. +func (s *Scanner) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) { + return &pb.HealthResponse{Ready: true}, nil +} + +// startGRPCServer initializes and starts the gRPC server. +func (s *Scanner) startGRPCServer() error { + lc := net.ListenConfig{} + lis, err := lc.Listen(context.Background(), "tcp", s.cfg.ListenURL) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", s.cfg.ListenURL, err) + } + + s.grpcServer = grpc.NewServer() + pb.RegisterScannerServiceServer(s.grpcServer, s) + reflection.Register(s.grpcServer) + + s.logger.Info("Scanner gRPC server listening", zap.String("address", s.cfg.ListenURL)) + + return s.grpcServer.Serve(lis) +} + +func postNewAccount(account inventory.Account, l *zap.Logger) error { + l.Debug("Posting new Account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) - // Converting to Array because API handler assumes a list of accounts var accounts []dto.AccountDTORequest accounts = append(accounts, *dto.ToAccountDTORequest(account)) b, err := json.Marshal(accounts) if err != nil { - s.logger.Error("Failed to marshal account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName), zap.Error(err)) + l.Error("Failed to marshal account", zap.String("account_id", account.AccountID), zap.Error(err)) return err } - // Posting Account data if err := postData(apiAccountEndpoint, b); err != nil { return err } - // Flattering account for posting its elements clusters, instances, expenses := flatternAccount(account) - // Posting Clusters if len(clusters) > 0 { if err := postClusters(clusters); err != nil { return err } } - // Posting Instances if len(instances) > 0 { if err := postInstances(instances); err != nil { return err } } - // Posting Expenses if len(expenses) > 0 { - s.logger.Info("Posting expenses", zap.Int("expenses_count", len(expenses))) + l.Info("Posting expenses", zap.Int("expenses_count", len(expenses))) if err := postExpenses(expenses); err != nil { return err } @@ -306,7 +449,6 @@ func (s *Scanner) postNewAccount(account inventory.Account) error { return nil } -// flatternAccount extracts every Cluster, Instance and Expense from an Account for posting func flatternAccount(account inventory.Account) ([]inventory.Cluster, []inventory.Instance, []inventory.Expense) { var clusters []inventory.Cluster var instances []inventory.Instance @@ -315,95 +457,86 @@ func flatternAccount(account inventory.Account) ([]inventory.Cluster, []inventor for _, instance := range cluster.Instances { expenses = append(expenses, instance.Expenses...) instances = append(instances, instance) - } clusters = append(clusters, *cluster) } - return clusters, instances, expenses } -// postClusters posts into the API, the new instances obtained after scanning func postClusters(clusters []inventory.Cluster) error { b, err := json.Marshal(dto.ToClusterDTORequestList(clusters)) if err != nil { return err } - return postData(apiClusterEndpoint, b) } -// postInstances posts into the API, the instances obtained after scanning func postInstances(instances []inventory.Instance) error { b, err := json.Marshal(dto.ToInstanceDTORequestList(instances)) if err != nil { return err } - return postData(apiInstanceEndpoint, b) } -// postExpenses posts into the API, the expenses obtained after scanning func postExpenses(expenses []inventory.Expense) error { b, err := json.Marshal(dto.ToExpenseDTORequestList(expenses)) if err != nil { return err } - return postData(apiExpenseEndpoint, b) } -// postScannerInventory posts to ClusterIQ API the information obtained of the scanning process -// This function parallelizes the post operations creating a thread by account(or stocker) -func (s *Scanner) postScannerInventory() error { +func postScannerInventory(inv *inventory.Inventory, l *zap.Logger) error { var wg sync.WaitGroup - errChan := make(chan error, len(s.inventory.Accounts)) + errChan := make(chan error, len(inv.Accounts)) - for _, account := range s.inventory.Accounts { + for _, account := range inv.Accounts { wg.Add(1) go func() { defer wg.Done() - if err := s.postNewAccount(*account); err != nil { + if err := postNewAccount(*account, l); err != nil { errChan <- err } }() - } - // Waiting for every Stock + go func() { wg.Wait() close(errChan) }() - // Collecting account posting errors var errorList []error for err := range errChan { errorList = append(errorList, err) } - // Processing errors when every post account operation has finished if len(errorList) > 0 { for _, err := range errorList { - s.logger.Error("Post Account Error", zap.Error(err)) + l.Error("Post Account Error", zap.Error(err)) } return fmt.Errorf("error when posting Scanner inventory") } - s.logger.Info("Inventory posted correctly") + l.Info("Inventory posted correctly") - // HTTP post to /inventory to refresh views - if err := postData(apiInventoryEndpoint, []byte{}); err != nil { + if err := refreshInventory(l); err != nil { return err } - s.logger.Info("Inventory refreshed correctly") + return nil +} + +func refreshInventory(l *zap.Logger) error { + if err := postData(apiInventoryEndpoint, []byte{}); err != nil { + return err + } + l.Info("Inventory refreshed") return nil } func postData(path string, b []byte) error { url := fmt.Sprintf("%s%s", APIURL, path) - - // Create context with timeout for API requests ctx, cancel := context.WithTimeout(context.Background(), apiRequestTimeout) defer cancel() @@ -420,77 +553,54 @@ func postData(path string, b []byte) error { return err } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("API returned HTTP %d for %s", response.StatusCode, path) + } return nil } -// getInstances fetches instances from the backend API -func (s *Scanner) getInstancesForBillingUpdate(accountID string) ([]inventory.Instance, error) { - s.logger.Debug("Fetching instances for update billing from backend") - - requestURL := s.cfg.APIURL + apiAccountEndpoint + "/" + accountID + "/expense_update" +func getInstancesForBillingUpdate(apiURL string, accountID string, l *zap.Logger) ([]string, error) { + l.Debug("Fetching instances for update billing from backend") + requestURL := apiURL + apiAccountEndpoint + "/" + accountID + "/expense_update" req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, requestURL, nil) if err != nil { - s.logger.Error("Failed preparing last expenses list request", zap.Error(err)) + l.Error("Failed preparing last expenses list request", zap.Error(err)) return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { - s.logger.Error("Failed to get last expenses from API", zap.Error(err)) + l.Error("Failed to get last expenses from API", zap.Error(err)) return nil, err } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - s.logger.Error("Failed to get last expenses from API", zap.Int("status_code", resp.StatusCode)) + l.Error("Failed to get last expenses from API", zap.Int("status_code", resp.StatusCode)) return nil, fmt.Errorf("failed to get last expenses, status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { - s.logger.Error("Failed to read response body", zap.Error(err)) + l.Error("Failed to read response body", zap.Error(err)) return nil, err } - var response responsetypes.ListResponse[dto.InstanceDTOResponse] + var response responsetypes.ListResponse[string] err = json.Unmarshal(body, &response) if err != nil { - s.logger.Error("Failed to unmarshal instances JSON", zap.Error(err)) + l.Error("Failed to unmarshal instance IDs JSON", zap.Error(err)) return nil, err } - if response.Count == 0 { - return nil, fmt.Errorf("no instances for billing update") - } - - s.logger.Debug("Successfully fetched instances from backend", zap.Int("instances_num", response.Count)) - - var instances []inventory.Instance - for _, instance := range response.Items { - instances = append(instances, *instance.ToInventoryInstance()) - } - return instances, nil -} - -// signalHandler for managing incoming OS signals -func signalHandler(sig os.Signal) { - if sig == syscall.SIGTERM { - logger.Fatal("SIGTERM signal received. Stopping ClusterIQ Scanner") - os.Exit(0) - } - - logger.Warn("Ignoring signal: ", zap.String("signal_id", sig.String())) + l.Debug("Successfully fetched instance IDs from backend", zap.Int("instances_num", response.Count)) + return response.Items, nil } -// Main method func main() { - // Ignore Logger sync error defer func() { _ = logger.Sync() }() - var err error - cfg, err := config.LoadScannerConfig() if err != nil { logger.Fatal("Failed to load config", zap.Error(err)) @@ -503,44 +613,33 @@ func main() { zap.String("commit", commit), zap.String("credentials_file_path", cfg.CredentialsFile), zap.ByteString("credentials_file_hash", credsFileHash), + zap.String("listen_url", cfg.ListenURL), ) - // Listen Signals block for receive OS signals. This is used by K8s/OCP for - // interacting with this software when it's deployed on a Pod - go func() { - quitChan := make(chan os.Signal, 1) - signal.Notify(quitChan, syscall.SIGTERM) - s := <-quitChan - signalHandler(s) - logger.Info("Scanner stopped") - }() - - // Get Cloud Accounts from credentials file - err = scan.readCloudProviderAccounts() - if err != nil { - logger.Error("Failed to get cloud provider accounts", zap.Error(err)) - return + if err := scan.loadAccounts(); err != nil { + logger.Fatal("Failed to load cloud accounts", zap.Error(err)) } - // Run Stockers - err = scan.createStockers() - if err != nil { - logger.Error("Failed to create stockers", zap.Error(err)) - return + scan.waitForAPI() + if err := scan.seedAccounts(); err != nil { + logger.Warn("Failed to seed accounts", zap.Error(err)) } - err = scan.startStockers() - if err != nil { - logger.Error("Failed to start up stocker instances", zap.Error(err)) - return - } + // Signal handling for graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + s := <-quit + logger.Warn("Received signal, shutting down...", zap.String("signal", s.String())) + if scan.grpcServer != nil { + scan.grpcServer.GracefulStop() + } + }() - // Writing into DB - scan.inventory.PrintInventory() - if err := scan.postScannerInventory(); err != nil { - logger.Error("Can't post scanned results", zap.Error(err)) - return + if err := scan.startGRPCServer(); err != nil { + logger.Fatal("gRPC server failed", zap.Error(err)) } - logger.Info("Scanner finished successfully") + logger.Info("Scanner stopped") } 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: + // , + , + ]} + > + + Are you sure you want to trigger a scan on the account {accountName}? + + + ); +}; diff --git a/console/src/app/AccountDetails/components/AccountTabs.tsx b/console/src/app/AccountDetails/components/AccountTabs.tsx new file mode 100644 index 00000000..db2bacbf --- /dev/null +++ b/console/src/app/AccountDetails/components/AccountTabs.tsx @@ -0,0 +1,39 @@ +import { PageSection, Tab, TabContent, TabContentBody, Tabs, TabTitleText } from '@patternfly/react-core'; +import React from 'react'; +import { AccountsTabsProps } from './types'; + +export const AccountsTabs: React.FunctionComponent = ({ + detailsTabContent, + clustersTabContent, + costsTabContent, +}) => { + const [activeTabKey, setActiveTabKey] = React.useState(0); + const handleTabClick = (_event: React.MouseEvent, eventKey: string | number) => { + setActiveTabKey(eventKey as number); + }; + + return ( + <> + + + Details} tabContentId={`tabContent${0}`} /> + Clusters} tabContentId={`tabContent${1}`} /> + Cost Evolution} tabContentId={`tabContent${2}`} /> + + + + + + + + + ); +}; + +export default AccountsTabs; diff --git a/console/src/app/AccountDetails/components/ClustersTable.tsx b/console/src/app/AccountDetails/components/ClustersTable.tsx new file mode 100644 index 00000000..d0a2d162 --- /dev/null +++ b/console/src/app/AccountDetails/components/ClustersTable.tsx @@ -0,0 +1,75 @@ +import { renderStatusLabel } from '@app/utils/renderUtils'; +import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ClustersTableProps } from './types'; +import { sortItems } from '@app/utils/tableFilters'; +import { ClusterResponseApi } from '@api'; + +const columnNames = { + id: 'ID', + name: 'Name', + status: 'Status', + cloudProvider: 'Cloud Provider', + instanceCount: 'Instance Count', +}; + +export const ClustersTable: React.FunctionComponent = ({ clusters }) => { + const [activeSortIndex, setActiveSortIndex] = useState(1); + const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('asc'); + + let sortedClusters = clusters; + if (activeSortIndex !== undefined && activeSortDirection) { + const sortFields: (keyof ClusterResponseApi)[] = [ + 'clusterId', + 'clusterName', + 'status', + 'provider', + 'instanceCount', + ]; + // status column (index 2) is not sortable + if (activeSortIndex !== 2) { + sortedClusters = sortItems(clusters, sortFields[activeSortIndex], activeSortDirection); + } + } + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + + return ( + + + + + + + + + + + + {sortedClusters.map(cluster => ( + + + + + + + + ))} + +
{columnNames.id}{columnNames.name}{columnNames.status}{columnNames.cloudProvider}{columnNames.instanceCount}
+ {cluster.clusterId} + {cluster.clusterName}{renderStatusLabel(cluster.status)}{cluster.provider}{cluster.instanceCount}
+ ); +}; diff --git a/console/src/app/AccountDetails/components/types.ts b/console/src/app/AccountDetails/components/types.ts new file mode 100644 index 00000000..48e79f38 --- /dev/null +++ b/console/src/app/AccountDetails/components/types.ts @@ -0,0 +1,26 @@ +import { AccountResponseApi, ClusterResponseApi } from '@api'; +import React from 'react'; + +export interface AccountsHeaderProps { + accountName: string; + accountId: string; +} + +export interface AccountsTabsProps { + detailsTabContent: React.ReactNode; + clustersTabContent: React.ReactNode; + costsTabContent: React.ReactNode; +} + +export interface AccountDetailsContentProps { + loading: boolean; + accountData: AccountResponseApi | null; +} + +export interface AccountDescriptionListProps { + account: AccountResponseApi; +} + +export interface ClustersTableProps { + clusters: ClusterResponseApi[]; +} diff --git a/console/src/app/Accounts/Accounts.tsx b/console/src/app/Accounts/Accounts.tsx new file mode 100644 index 00000000..a0d40f8e --- /dev/null +++ b/console/src/app/Accounts/Accounts.tsx @@ -0,0 +1,40 @@ +import { PageSection, Panel, Content } from '@patternfly/react-core'; +import React from 'react'; +import AccountsToolbar from './components/AccountsToolbar'; +import AccountsTable from './components/AccountsTable'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; +import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; +import { ProviderApi } from '@api'; + +const filterParams = { + provider: parseAsArrayOf(parseAsStringEnum(Object.values(ProviderApi))).withDefault([]), + accountName: parseAsString.withDefault(''), +}; + +const Accounts: React.FunctionComponent = () => { + useDocumentTitle('Accounts — ClusterIQ'); + const [{ provider, accountName }, setQuery] = useQueryStates(filterParams); + + return ( + + + + Accounts + + + + + setQuery({ accountName: value })} + providerSelections={provider} + setProviderSelections={value => setQuery({ provider: value || null })} + /> + + + + + ); +}; + +export default Accounts; diff --git a/console/src/app/Accounts/components/AccountsTable.tsx b/console/src/app/Accounts/components/AccountsTable.tsx new file mode 100644 index 00000000..17cfb230 --- /dev/null +++ b/console/src/app/Accounts/components/AccountsTable.tsx @@ -0,0 +1,110 @@ +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 { AccountResponseApi, ProviderApi } from '@api'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; +import { TablePagination } from '@app/components/common/TablesPagination'; +import { searchItems, filterByProvider, sortItems } from '@app/utils/tableFilters'; +import { ResourceBadge, renderProviderIcon } from '@app/utils/renderUtils'; +import { parseNumberToCurrency, parseScanTimestamp } from '@app/utils/parseFuncs'; +import { useAccounts } from '@app/hooks/useAccounts'; +import { useTablePagination } from '@app/hooks/useTablePagination'; + +export const AccountsTable: React.FunctionComponent<{ + searchValue: string; + providerSelections: ProviderApi[] | null; +}> = ({ searchValue, providerSelections }) => { + const { data: allAccounts = [], isLoading } = useAccounts(); + + const [activeSortIndex, setActiveSortIndex] = useState(0); + const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('asc'); + + const filtered = useMemo(() => { + let result = allAccounts; + result = searchItems(result, searchValue, ['accountName']); + result = filterByProvider(result, providerSelections); + + if (activeSortIndex !== undefined && activeSortDirection) { + const sortFields: (keyof AccountResponseApi)[] = [ + 'accountName', + 'provider', + 'clusterCount', + 'last15DaysCost', + 'lastScanTimestamp', + ]; + result = sortItems(result, sortFields[activeSortIndex], activeSortDirection); + } + + return result; + }, [allAccounts, searchValue, providerSelections, activeSortIndex, activeSortDirection]); + + const { page, perPage, setPage, setPerPage, paginatedData, totalItems } = useTablePagination({ + data: filtered, + initialPerPage: 20, + filterDeps: [searchValue, providerSelections], + }); + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + + const columnNames = { + name: 'Name', + provider: 'Provider', + clusterCount: 'Cluster Count', + cost15d: 'Cost (15d)', + lastScan: 'Last Scan', + }; + + return ( + <> + {isLoading ? ( + + ) : ( + + + + + + + + + + + + {paginatedData.map(account => ( + + + + + + + + ))} + +
{columnNames.name}{columnNames.provider}{columnNames.clusterCount}{columnNames.cost15d}{columnNames.lastScan}
+ {' '} + {account.accountName} + {renderProviderIcon(account.provider)}{account.clusterCount}{parseNumberToCurrency(account.last15DaysCost)}{parseScanTimestamp(account.lastScanTimestamp)}
+ )} + + + ); +}; + +export default AccountsTable; diff --git a/console/src/app/Accounts/components/AccountsToolbar.tsx b/console/src/app/Accounts/components/AccountsToolbar.tsx new file mode 100644 index 00000000..2f0c0ae6 --- /dev/null +++ b/console/src/app/Accounts/components/AccountsToolbar.tsx @@ -0,0 +1,313 @@ +import { + SearchInput, + MenuToggle, + Badge, + Menu, + MenuContent, + MenuList, + MenuItem, + Popper, + Toolbar, + ToolbarContent, + ToolbarToggleGroup, + ToolbarGroup, + ToolbarItem, + ToolbarFilter, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { AccountsToolbarProps } from '../types'; +import { ProviderApi } from '@api'; +import debounce from 'lodash.debounce'; +import { usePopperContainer } from '@app/hooks/usePopperContainer'; + +export const AccountsToolbar: React.FunctionComponent = ({ + searchValue, + setSearchValue, + providerSelections, + setProviderSelections, +}) => { + const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); + + React.useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + const searchInput = ( + debouncedSearch(value)} + onClear={() => debouncedSearch('')} + /> + ); + + 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 = ( +
+ +
+ ); + + // Set up attribute selector + const [activeAttributeMenu, setActiveAttributeMenu] = React.useState<'Account' | 'Provider'>('Account'); + 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>(); + const handleAttributeClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + 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 'Account' | 'Provider'); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }} + > + + + Account + Provider + + + + ); + + const attributeDropdown = ( +
+ +
+ ); + + return ( + { + setSearchValue(''); + setProviderSelections([]); + }} + > + + } breakpoint="xl"> + + {attributeDropdown} + setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} + categoryName="Name" + showToolbarItem={activeAttributeMenu === 'Account'} + > + {searchInput} + + + onProviderMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setProviderSelections([])} + categoryName="Provider" + showToolbarItem={activeAttributeMenu === 'Provider'} + > + {providerSelect} + + + + + + ); +}; + +export default AccountsToolbar; diff --git a/console/src/app/Accounts/types.ts b/console/src/app/Accounts/types.ts new file mode 100644 index 00000000..0ae1edd9 --- /dev/null +++ b/console/src/app/Accounts/types.ts @@ -0,0 +1,8 @@ +import { ProviderApi } from '@api'; + +export interface AccountsToolbarProps { + searchValue: string; + setSearchValue: (value: string) => void; + providerSelections: ProviderApi[] | null; + setProviderSelections: (value: ProviderApi[] | null) => void; +} diff --git a/console/src/app/Actions/AuditLogs/AuditLogs.tsx b/console/src/app/Actions/AuditLogs/AuditLogs.tsx new file mode 100644 index 00000000..98730040 --- /dev/null +++ b/console/src/app/Actions/AuditLogs/AuditLogs.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { PageSection, Panel, Content } from '@patternfly/react-core'; +import AuditLogsTableToolbar from './AuditLogsTableToolbar'; +import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; +import { ActionOperations, ResultStatus } from '@app/types/types.tsx'; +import { ProviderApi } from '@api'; +import { AuditLogsTable } from './AuditLogsTable.tsx'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; + +const filterParams = { + accountName: parseAsString.withDefault(''), + action: parseAsArrayOf(parseAsStringEnum(Object.values(ActionOperations))).withDefault([]), + provider: parseAsArrayOf(parseAsStringEnum(Object.values(ProviderApi))).withDefault([]), + result: parseAsArrayOf(parseAsStringEnum(Object.values(ResultStatus))).withDefault([]), + requester: parseAsString.withDefault(''), +}; + +const AuditLogs: React.FunctionComponent = () => { + useDocumentTitle('Audit Logs — ClusterIQ'); + const [{ accountName, action, provider, result, requester }, setQuery] = useQueryStates(filterParams); + + return ( + + + + Audit logs + + + + + setQuery({ accountName: value })} + action={action} + setAction={value => setQuery({ action: value || [] })} + result={result} + setResult={value => setQuery({ result: value })} + requester={requester} + setRequester={value => setQuery({ requester: value })} + providerSelections={provider} + setProviderSelections={value => setQuery({ provider: value || [] })} + /> + + + + + ); +}; + +export default AuditLogs; diff --git a/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx b/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx new file mode 100644 index 00000000..8f7b653a --- /dev/null +++ b/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx @@ -0,0 +1,157 @@ +import { TableSkeleton } from '@app/components/common/TableSkeleton'; +import { ActionOperations, ResultStatus } from '@app/types/types'; +import { SystemEventResponseApi } from '@api'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import React, { useMemo } from 'react'; +import { + renderOperationLabel, + renderActionStatusLabel, + renderResourceBadge, + ResourceBadge, +} from '@app/utils/renderUtils'; +import { parseScanTimestamp, resolveResourcePath } from '@app/utils/parseFuncs'; +import { useTableSort } from '@app/hooks/useTableSort.tsx'; +import { EmptyState } from '@patternfly/react-core'; +import { TablePagination } from '@app/components/common/TablesPagination'; +import { SearchIcon } from '@patternfly/react-icons'; +import { AuditLogsTableProps } from './types'; +import { Link } from 'react-router-dom'; +import { useEvents } from '@app/hooks/useEvents'; +import { useTablePagination } from '@app/hooks/useTablePagination'; + +const columnNames = { + scheduledAction: 'Action', + operation: 'Operation', + resource: 'Resource', + description: 'Description', + status: 'Status', + requester: 'Requester', + date: 'Date', +}; + +const EmptyStateNoFound: React.FunctionComponent = () => ( + +); + +export const AuditLogsTable: React.FunctionComponent = ({ + accountName, + action, + provider, + result, + requester, +}) => { + const { data: allEvents = [], isLoading } = useEvents(); + + const filtered = useMemo(() => { + let filteredResult = allEvents; + + if (accountName) { + filteredResult = filteredResult.filter(event => + event.accountId?.toLowerCase().includes(accountName.toLowerCase()) + ); + } + + if (action?.length) { + filteredResult = filteredResult.filter(event => action.includes(event.action as ActionOperations)); + } + + if (provider?.length) { + filteredResult = filteredResult.filter(event => event.provider && provider.some(p => p === event.provider)); + } + + if (result?.length) { + filteredResult = filteredResult.filter(event => result.includes(event.result as ResultStatus)); + } + + if (requester) { + filteredResult = filteredResult.filter(event => event.requester?.toLowerCase().includes(requester.toLowerCase())); + } + + return filteredResult; + }, [allEvents, accountName, action, provider, result, requester]); + + const { page, perPage, setPage, setPerPage, paginatedData, totalItems } = useTablePagination({ + data: filtered, + filterDeps: [accountName, action, provider, result, requester], + }); + + const getSortableRowValues = (event: SystemEventResponseApi): (string | number | null)[] => { + const { timestamp, action, resourceId, result, requester, scheduleId, description } = event; + return [ + timestamp ?? null, + action ?? null, + resourceId ?? null, + result ?? null, + requester ?? null, + scheduleId ?? null, + description ?? null, + ]; + }; + + const { sortedData, getSortParams } = useTableSort( + paginatedData, + getSortableRowValues, + 0, + 'desc' + ); + + if (isLoading) return ; + if (totalItems === 0) return ; + + return ( + + + + + + + + + + + + + + + {sortedData.map(event => ( + + + + + + + + + + ))} + +
{columnNames.date}{columnNames.operation}{columnNames.resource}{columnNames.status}{columnNames.requester}{columnNames.scheduledAction}{columnNames.description}
{parseScanTimestamp(event.timestamp)}{renderOperationLabel(event.action)} + {event.resourceId ? ( + <> + {renderResourceBadge(event.resourceType)}{' '} + + {event.resourceName || event.resourceId} + + + ) : event.action === ActionOperations.SCAN ? ( + <> + All Accounts + + ) : ( + '-' + )} + {renderActionStatusLabel(event.result)}{event.requester} + {event.scheduleId ? #{event.scheduleId} : '-'} + {event.description}
+ +
+ ); +}; + +export default AuditLogsTable; diff --git a/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx b/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx new file mode 100644 index 00000000..bfbfdf3d --- /dev/null +++ b/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx @@ -0,0 +1,609 @@ +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 { AuditLogsTableToolbarProps } from './types'; +import debounce from 'lodash.debounce'; +import { ActionOperations, ResultStatus } from '@app/types/types'; +import { usePopperContainer } from '@app/hooks/usePopperContainer'; +import { ProviderApi } from '@api'; + +type AttributeMenuOption = 'Account' | 'Provider' | 'Action' | 'Result' | 'Requester'; + +export const AuditLogsTableToolbar: React.FunctionComponent = ({ + searchValue, + setSearchValue, + action, + setAction, + result, + setResult, + requester, + setRequester, + providerSelections, + setProviderSelections, +}) => { + const debouncedSearchRef = React.useRef(debounce(setSearchValue, 300)); + React.useEffect(() => { + debouncedSearchRef.current = debounce(setSearchValue, 300); + return () => debouncedSearchRef.current.cancel(); + }, [setSearchValue]); + const debouncedSearch = React.useCallback((v: string) => debouncedSearchRef.current(v), []); + + const debouncedRequesterRef = React.useRef(debounce(setRequester, 300)); + React.useEffect(() => { + debouncedRequesterRef.current = debounce(setRequester, 300); + return () => debouncedRequesterRef.current.cancel(); + }, [setRequester]); + const debouncedRequester = React.useCallback((v: string) => debouncedRequesterRef.current(v), []); + + // Set up name search input + const searchInput = ( + debouncedSearch(value)} + onClear={() => debouncedSearch('')} + /> + ); + // Set up triggered by search input + const requesterInput = ( + debouncedRequester(value)} + onClear={() => debouncedRequester('')} + /> + ); + + // 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 = ( +
+ +
+ ); + + // 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; + setAction( + action && action.includes(selectedAction) + ? action.filter(item => item !== selectedAction) + : selectedAction + ? [selectedAction, ...(action || [])] + : [] + ); + } + + const actionToggle = ( + 0 && { + badge: {action.length}, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + Filter by action + + ); + + const actionMenu = ( + + + + + {ActionOperations.POWER_ON} + + + {ActionOperations.POWER_OFF} + + + + + ); + + const actionSelect = ( +
+ +
+ ); + + // Result filter setup + const [isResultMenuOpen, setIsResultMenuOpen] = React.useState(false); + const resultToggleRef = React.useRef(null); + const resultMenuRef = React.useRef(null); + const { containerRef: resultContainerRef, containerElement: resultContainerElement } = usePopperContainer(); + + const handleResultMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleResultClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleResultMenuKeysRef.current = (event: KeyboardEvent) => { + if (isResultMenuOpen && resultMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsResultMenuOpen(!isResultMenuOpen); + resultToggleRef.current?.focus(); + } + } + }; + + handleResultClickOutsideRef.current = (event: MouseEvent) => { + if (isResultMenuOpen && !resultMenuRef.current?.contains(event.target as Node)) { + setIsResultMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleResultMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleResultClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isResultMenuOpen]); + + const onResultMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (resultMenuRef.current) { + const firstElement = resultMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsResultMenuOpen(!isResultMenuOpen); + }; + + function onResultMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const selectedResult = itemId as ResultStatus; + setResult( + result && result.includes(selectedResult) + ? result.filter(item => item !== selectedResult) + : selectedResult + ? [selectedResult, ...(result || [])] + : [] + ); + } + + const resultToggle = ( + 0 && { + badge: {result.length}, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + Filter by result + + ); + + const resultMenu = ( + + + + + {ResultStatus.Success} + + + {ResultStatus.Failed} + + + {ResultStatus.Warning} + + + + + ); + + const resultSelect = ( +
+ +
+ ); + + // 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} + + ); + + // Only show Status option in attribute menu for active view + const attributeMenu = ( + { + const selected = itemId?.toString() as AttributeMenuOption; + setActiveAttributeMenu(selected); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }} + > + + + Account + Action + Result + Provider + Requester + + + + ); + + const attributeDropdown = ( +
+ +
+ ); + + return ( + { + setSearchValue(''); + setProviderSelections(null); + setAction(null); + setResult(null); + setRequester(''); + }} + > + + } breakpoint="xl"> + + {attributeDropdown} + setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} + categoryName="Account" + showToolbarItem={activeAttributeMenu === 'Account'} + > + {searchInput} + + setRequester('')} + deleteLabelGroup={() => setRequester('')} + categoryName="Requester" + showToolbarItem={activeAttributeMenu === 'Requester'} + > + {requesterInput} + + onResultMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setResult([])} + categoryName="Result" + showToolbarItem={activeAttributeMenu === 'Result'} + > + {resultSelect} + + onProviderMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setProviderSelections([])} + categoryName="Provider" + showToolbarItem={activeAttributeMenu === 'Provider'} + > + {providerSelect} + + onActionMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setAction([])} + categoryName="Action" + showToolbarItem={activeAttributeMenu === 'Action'} + > + {actionSelect} + + + + + + ); +}; + +export default AuditLogsTableToolbar; diff --git a/console/src/app/Actions/AuditLogs/types.ts b/console/src/app/Actions/AuditLogs/types.ts new file mode 100644 index 00000000..4144ab12 --- /dev/null +++ b/console/src/app/Actions/AuditLogs/types.ts @@ -0,0 +1,23 @@ +import { ActionOperations, ResultStatus } from '@app/types/types'; +import { ProviderApi } from '@api'; + +export interface AuditLogsTableToolbarProps { + searchValue: string; + setSearchValue: (value: string) => void; + action: ActionOperations[] | null; + setAction: (value: ActionOperations[] | null) => void; + result: ResultStatus[] | null; + setResult: (value: ResultStatus[] | null) => void; + providerSelections: ProviderApi[] | null; + setProviderSelections: (value: ProviderApi[] | null) => void; + requester: string; + setRequester: (value: string) => void; +} + +export interface AuditLogsTableProps { + accountName?: string; + action?: ActionOperations[]; + provider?: ProviderApi[]; + result?: ResultStatus[]; + requester?: string; +} diff --git a/console/src/app/Actions/Scheduler/Schedule.tsx b/console/src/app/Actions/Scheduler/Schedule.tsx new file mode 100644 index 00000000..7230c02d --- /dev/null +++ b/console/src/app/Actions/Scheduler/Schedule.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { ModalCreateAction } from './components/ModalCreateAction'; +import { Flex, FlexItem, Button, PageSection, Panel, Content } from '@patternfly/react-core'; +import ScheduleActionsTable from './components/ActionsTable'; +import ScheduleActionsTableToolbar from './components/ActionsToolBar'; +import { ActionOperations, ActionTypes, ActionStatus } from '@app/types/types'; +import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; + +import { parseAsBooleanNullable } from '@app/utils/parseFuncs'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; + +// Nullable boolean: "true" -> true, "false" -> false, missing/other -> null + +const filterParams = { + accountId: parseAsString.withDefault(''), + action: parseAsArrayOf(parseAsStringEnum(Object.values(ActionOperations))).withDefault([]), + type: parseAsStringEnum(Object.values(ActionTypes)), + status: parseAsStringEnum(Object.values(ActionStatus)), + enabled: parseAsBooleanNullable, +}; + +const Scheduler: React.FunctionComponent = () => { + useDocumentTitle('Scheduler — ClusterIQ'); + const [{ accountId, action, type, status, enabled }, setQuery] = useQueryStates(filterParams); + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [reloadFlag, setReloadFlag] = React.useState(0); + + const onClick = () => { + setIsModalOpen(true); + }; + + const resetModalState = () => { + setIsModalOpen(false); + }; + + return ( + + + + + + Scheduled Actions + + + + + + + + + + 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..b947f4f7 --- /dev/null +++ b/console/src/app/Actions/Scheduler/components/AccountSelector.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { + Button, + FormGroup, + Popover, + Select, + SelectOption, + MenuToggle, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Tooltip, +} from '@patternfly/react-core'; +import { HelpIcon } from '@patternfly/react-icons'; +import { AccountResponseApi } from '@api'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export const ALL_ACCOUNTS_ID = '__all__'; + +interface AccountTypeaheadSelectProps { + accounts: AccountResponseApi[]; + selectedAccount: AccountResponseApi | null; + onSelectAccount: (account: AccountResponseApi | null) => void; + onClearAccount: () => void; + showAllOption?: boolean; +} + +export const AccountTypeaheadSelect: React.FunctionComponent = ({ + accounts, + selectedAccount, + onSelectAccount, + onClearAccount, + showAllOption = false, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + + const safeAccounts = React.useMemo(() => (Array.isArray(accounts) ? accounts : []), [accounts]); + + const allAccountsEntry: AccountResponseApi = React.useMemo( + () => ({ accountId: ALL_ACCOUNTS_ID, accountName: 'All Accounts' }), + [] + ); + + const filteredAccounts = React.useMemo(() => { + const q = inputValue.trim().toLowerCase(); + const filtered = q + ? safeAccounts.filter(a => { + const haystack = `${a.accountName ?? ''} ${a.accountId ?? ''}`.toLowerCase(); + return haystack.includes(q); + }) + : safeAccounts; + + if (showAllOption) { + const allMatches = !q || 'all accounts'.includes(q); + return allMatches ? [allAccountsEntry, ...filtered] : filtered; + } + return filtered; + }, [safeAccounts, inputValue, showAllOption, allAccountsEntry]); + + const onSelect = (_event?: React.MouseEvent, value?: string | number) => { + const id = String(value ?? ''); + const acc = + (showAllOption && id === ALL_ACCOUNTS_ID ? allAccountsEntry : null) ?? + safeAccounts.find(a => a.accountId === id) ?? + null; + + setInputValue( + acc ? (acc.accountId === ALL_ACCOUNTS_ID ? (acc.accountName ?? '') : `${acc.accountName} (${acc.accountId})`) : '' + ); + onSelectAccount(acc); + setIsOpen(false); + }; + + return ( + + + + + + + ); +}; + +export default DateTimePicker; diff --git a/console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx b/console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx new file mode 100644 index 00000000..d1f0365d --- /dev/null +++ b/console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx @@ -0,0 +1,356 @@ +import { + Alert, + Button, + Checkbox, + FormHelperText, + FormGroup, + Form, + TextInput, + HelperText, + HelperTextItem, + Radio, + ToggleGroup, + ToggleGroupItem, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon, HelpIcon } from '@patternfly/react-icons'; +import { Popover } from '@patternfly/react-core'; +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, ALL_ACCOUNTS_ID } from './AccountSelector'; +import { ClusterTypeaheadSelect } from './ClusterSelector'; +import { ActionStatus } from '@app/types/types'; +import { debug } from '@app/utils/debugLogs'; +import { api, AccountResponseApi, ClusterResponseApi, ActionRequestApi } from '@api'; +import { useUser } from '@app/Contexts/UserContext'; +import cronValidate from 'cron-validate'; + +interface ModalCreateActionProps { + isOpen: boolean; + onClose: () => void; + onCreated: () => void; +} + +export const ModalCreateAction: React.FunctionComponent = ({ isOpen, onClose, onCreated }) => { + const { userEmail } = useUser(); + const [actionOperation, setActionOperation] = React.useState(''); + const [selectedAccount, setSelectedAccount] = React.useState(null); + const [selectedCluster, setSelectedCluster] = React.useState(null); + 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 [actionType, setActionType] = React.useState(ActionTypes.INSTANT_ACTION); + + const [allAccounts, setAllAccounts] = React.useState([]); + const [allClusters, setAllClusters] = React.useState([]); + const [submitError, setSubmitError] = React.useState(null); + + const isScan = actionOperation === ActionOperations.SCAN; + + const isValidCronExpression = (expr: string): boolean => { + if (!expr.trim()) return false; + const result = cronValidate(expr, { preset: 'default' }); + return result.isValid(); + }; + + const isTargetValid = isScan ? !!selectedAccount : !!selectedAccount && !!selectedCluster; + + const isExecutionValid = + actionType === ActionTypes.INSTANT_ACTION || + (actionType === ActionTypes.SCHEDULED_ACTION && scheduledDateTime !== '') || + (actionType === ActionTypes.CRON_ACTION && cronExpression !== '' && isValidCronExpression(cronExpression)); + + const isFormValid = actionOperation !== '' && isTargetValid && isExecutionValid; + + const handleOperationChange = (value: string) => { + setActionOperation(value); + setSelectedCluster(null); + setShowSchedule(false); + setActionType(ActionTypes.INSTANT_ACTION); + setScheduledDateTime(''); + setCronExpression(''); + setCronTouched(false); + }; + + 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 || accountId === ALL_ACCOUNTS_ID) 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]); + + React.useEffect(() => { + if (isOpen) return; + + setActionOperation(''); + setDescription(''); + setShowSchedule(false); + setActionType(ActionTypes.INSTANT_ACTION); + setScheduledDateTime(''); + setCronExpression(''); + setCronTouched(false); + setSelectedAccount(null); + setSelectedCluster(null); + setAllClusters([]); + setSubmitError(null); + }, [isOpen]); + + const handlerConfirmActionCreation = async () => { + if (!isScan && !selectedCluster?.clusterId) { + console.error('ClusterID is undefined. Cannot perform action'); + return; + } + + const actionRequest = { + accountId: selectedAccount?.accountId === ALL_ACCOUNTS_ID ? '' : selectedAccount?.accountId, + clusterId: isScan ? undefined : selectedCluster?.clusterId, + description: description || undefined, + enabled: true, + operation: actionOperation, + region: isScan ? undefined : selectedCluster?.region, + requester: userEmail || undefined, + status: ActionStatus.Pending, + type: actionType, + } as ActionRequestApi; + + if (actionType === ActionTypes.SCHEDULED_ACTION) { + actionRequest.time = scheduledDateTime; + } else if (actionType === ActionTypes.CRON_ACTION) { + actionRequest.cronExpression = cronExpression.trim(); + } + + debug('Creating action', actionRequest); + try { + await api.actions.actionsCreate([actionRequest]); + onCreated(); + onClose(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to create action'); + } + }; + + if (!isOpen) { + return null; + } + + return ( + + Confirm + , + , + ]} + appendTo={document.body} + > + {submitError && ( + + {submitError} + + )} +
+ {/* Operation selection */} + + , + , + ]} + > + + 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..9f98f0c0 --- /dev/null +++ b/console/src/app/ClusterDetails/components/ClusterDetailsDropdown.tsx @@ -0,0 +1,90 @@ +import { api, ResourceStatusApi, ActionRequestApi } from '@api'; +import { Dropdown, DropdownItem, DropdownList, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { ActionOperations, ActionTypes, ActionStatus } from '@app/types/types'; +import { ClusterActionConfirm } from './ClusterActionConfirm'; +import { useUser } from '@app/Contexts/UserContext'; + +interface ClusterDetailsDropdownProps { + clusterStatus: ResourceStatusApi | null; +} + +export const ClusterDetailsDropdown: React.FunctionComponent = () => { + const { userEmail } = useUser(); + const [isOpen, setIsOpen] = React.useState(false); + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [actionOperation, setActionOperation] = React.useState(null); + + const { clusterID } = useParams(); + + const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + const operation = value as ActionOperations; + + if (operation === ActionOperations.POWER_ON || operation === ActionOperations.POWER_OFF) { + setActionOperation(operation); + setIsModalOpen(true); + } + + setIsOpen(false); + }; + + const actionCreate = async (clusterId: string, operation: string) => { + const actionRequest = { + clusterId, + enabled: true, + operation, + requester: userEmail || undefined, + status: ActionStatus.Pending, + type: ActionTypes.INSTANT_ACTION, + } as ActionRequestApi; + + try { + await api.actions.actionsCreate([actionRequest]); + } catch (error) { + console.error('Failed to create action:', error); + } + }; + + 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); + 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..866e13e6 --- /dev/null +++ b/console/src/app/ClusterDetails/components/ClusterDetailsEvents.tsx @@ -0,0 +1,110 @@ +import { TableSkeleton } from '@app/components/common/TableSkeleton'; +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'; +import { debug } from '@app/utils/debugLogs'; + +interface TableEventsProps { + data: SystemEventResponseApi[]; + getSortParams: (columnIndex: number) => ThProps['sort']; +} + +const columnNames = { + action: 'Action', + result: 'Result', + severity: 'Severity', + requester: 'Requester', + 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.requester}{columnNames.description}{columnNames.date}
{event.action} + {getResultIcon(event.result as ResultStatus)} {event.result} + {event.severity}{event.requester}{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) { + debug('Fetched events:', clusterEvents); + setData(clusterEvents.items || []); + } + } catch (error) { + if (!cancelled) { + console.error('Error fetching events:', error); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + fetchData(); + + return () => { + cancelled = true; + }; + }, [clusterID]); + + debug('Rendered events data:', data); + + const getSortableRowValues = (event: SystemEventResponseApi): (string | number | null)[] => { + const { action, result, severity, requester, description: description, timestamp } = event; + return [action, result, severity, requester, 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..84bdd92a --- /dev/null +++ b/console/src/app/ClusterDetails/components/ClusterDetailsInstances.tsx @@ -0,0 +1,106 @@ +import { TableSkeleton } from '@app/components/common/TableSkeleton'; +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'; +import { debug } from '@app/utils/debugLogs'; + +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(() => { + let cancelled = false; + const fetchData = async () => { + try { + debug('Fetching data...'); + const { data: fetchedInstancesPerCluster } = await api.clusters.instancesList(clusterID!); + if (cancelled) return; + debug('Fetched data:', fetchedInstancesPerCluster); + setData(fetchedInstancesPerCluster); + } catch (error) { + if (!cancelled) console.error('Error fetching data:', error); + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchData(); + return () => { + cancelled = true; + }; + }, [clusterID]); + + if (!clusterID) { + return ; + } + + debug('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..5087c0aa --- /dev/null +++ b/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx @@ -0,0 +1,227 @@ +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { parseNumberToCurrency, parseScanTimestamp } from '@app/utils/parseFuncs'; +import { renderStatusLabel, ResourceLabel } from '@app/utils/renderUtils'; +import { ClusterResponseApi, TagResponseApi } from '@api'; +import { + Flex, + FlexItem, + Title, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + TabContentBody, + PageSection, + 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'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; + +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); + useDocumentTitle(`${cluster?.clusterName || clusterID || ''} — ClusterIQ`); + + useEffect(() => { + if (!clusterID) return; + let cancelled = false; + + const fetchData = async () => { + try { + const { data: fetchedCluster } = await api.clusters.clustersDetail(clusterID!); + if (cancelled) return; + setClusterData(fetchedCluster); + const { data: fetchedTags } = await api.clusters.tagsList(clusterID!); + if (cancelled) return; + setTagData(fetchedTags); + } catch (error) { + if (!cancelled) console.error('Error fetching data:', error); + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchData(); + return () => { + cancelled = true; + }; + }, [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 scan + + {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 ( + + + + + + <ResourceLabel label="Cluster" color="#0066cc" /> {cluster?.clusterName || clusterID} + + + + + + + + {/* Page tabs */} + + + + + Details} tabContentId={`tabContent${0}`} /> + Nodes} 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..61e09734 --- /dev/null +++ b/console/src/app/Clusters/Clusters.tsx @@ -0,0 +1,58 @@ +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'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; + +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 = () => { + useDocumentTitle('Clusters — ClusterIQ'); + 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..c8c92f21 --- /dev/null +++ b/console/src/app/Clusters/components/ClustersTable.tsx @@ -0,0 +1,181 @@ +import { renderStatusLabel, ResourceBadge, renderProviderIcon } from '@app/utils/renderUtils'; +import { parseNumberToCurrency } from '@app/utils/parseFuncs'; +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 { TableSkeleton } from '@app/components/common/TableSkeleton'; +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', + 'last15DaysCost', + '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', + provider: 'Provider', + region: 'Region', + cost15d: 'Cost (15d)', + 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 match the current filters.' + )} +
+
+ ); + } + + return ( + + + + + + + + + + + + + + + + {paginatedData.map(cluster => ( + + + + + + + + + + + ))} + +
{columnNames.id}{columnNames.name}{columnNames.status}{columnNames.account}{columnNames.provider}{columnNames.region}{columnNames.cost15d}{columnNames.console}
+ {' '} + {cluster.clusterId} + {cluster.clusterName}{renderStatusLabel(cluster.status)} + {cluster.accountName} + {renderProviderIcon(cluster.provider)}{cluster.region}{parseNumberToCurrency(cluster.last15DaysCost)} + + 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..cfb1a9f3 --- /dev/null +++ b/console/src/app/Clusters/components/ClustersTableToolbar.tsx @@ -0,0 +1,468 @@ +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 debouncedClusterSearchRef = React.useRef(debounce(setClusterNameSearch, 300)); + React.useEffect(() => { + debouncedClusterSearchRef.current = debounce(setClusterNameSearch, 300); + return () => debouncedClusterSearchRef.current.cancel(); + }, [setClusterNameSearch]); + const debouncedClusterSearch = React.useCallback((v: string) => debouncedClusterSearchRef.current(v), []); + + const debouncedAccountSearchRef = React.useRef(debounce(setAccountNameSearch, 300)); + React.useEffect(() => { + debouncedAccountSearchRef.current = debounce(setAccountNameSearch, 300); + return () => debouncedAccountSearchRef.current.cancel(); + }, [setAccountNameSearch]); + const debouncedAccountSearch = React.useCallback((v: string) => debouncedAccountSearchRef.current(v), []); + + 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..0de5f52e --- /dev/null +++ b/console/src/app/Contexts/UserContext.tsx @@ -0,0 +1,37 @@ +/* eslint-disable react-refresh/only-export-components */ +import * as React from 'react'; +import { debug } from '@app/utils/debugLogs'; + +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'); + setUserEmail(email || 'clusteriq@dev'); + debug('User email:', email || 'clusteriq@dev (fallback)'); + }) + .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/NodeDetails/NodeDetails.tsx b/console/src/app/NodeDetails/NodeDetails.tsx new file mode 100644 index 00000000..25dd425b --- /dev/null +++ b/console/src/app/NodeDetails/NodeDetails.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useState } from 'react'; +import { renderStatusLabel, ResourceLabel } 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, + Flex, + FlexItem, + Label, + LabelGroup, + Bullseye, + Spinner, +} from '@patternfly/react-core'; +import { api, InstanceResponseApi, TagResponseApi } from '@api'; +import { Link } from 'react-router-dom'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; +import { debug } from '@app/utils/debugLogs'; + +interface LabelGroupOverflowProps { + labels: Array; +} + +const LabelGroupOverflow: React.FunctionComponent = ({ labels }) => ( + + {labels.map(label => ( + + ))} + +); + +const NodeDetails: React.FunctionComponent = () => { + const { instanceID } = useParams(); + const [activeTabKey, setActiveTabKey] = React.useState(0); + const [instanceData, setInstanceData] = useState(null); + const [loading, setLoading] = useState(true); + useDocumentTitle(`${instanceData?.instanceName || instanceID || ''} — ClusterIQ`); + useEffect(() => { + if (!instanceID) return; + let cancelled = false; + const fetchData = async () => { + try { + debug('Fetching instance detail:', instanceID); + const { data: fetchedInstance } = await api.instances.instancesDetail(instanceID); + if (cancelled) return; + setInstanceData(fetchedInstance); + debug('Fetched instance detail:', instanceID); + } catch (error) { + if (!cancelled) console.error('Error fetching data:', error); + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchData(); + return () => { + cancelled = true; + }; + }, [instanceID]); + + const handleTabClick = (_event, tabIndex) => { + setActiveTabKey(tabIndex); + }; + + const detailsTabContent = ( + + {loading ? ( + + + + ) : ( + + + + Node details + + + + + + + Name + {instanceID} + Status + {renderStatusLabel(instanceData?.status)} + Cluster ID + + {instanceData?.clusterId} + + Cloud Provider + {instanceData?.provider} + + + + Labels + + Last scan + + {parseScanTimestamp(instanceData?.lastScanTimestamp)} + + Created at + + {parseScanTimestamp(instanceData?.creationTimestamp)} + + + + + + + Total Cost (aprox) + + {parseNumberToCurrency(instanceData?.totalCost)} + + + + + + + + )} + + ); + + return ( + + {/* Page header */} + + + <ResourceLabel label="Node" color="#4cb140" /> {instanceData?.instanceName || instanceID} + + {/* Page tabs */} + + + + Details} tabContentId={`tabContent${0}`} /> + + + + + + + + ); +}; + +export default NodeDetails; diff --git a/console/src/app/Nodes/Nodes.tsx b/console/src/app/Nodes/Nodes.tsx new file mode 100644 index 00000000..1a0526ad --- /dev/null +++ b/console/src/app/Nodes/Nodes.tsx @@ -0,0 +1,54 @@ +import { PageSection, Panel, Content } from '@patternfly/react-core'; +import React from 'react'; +import NodesTableToolbar from './components/NodesTableToolbar'; +import NodesTable from './components/NodesTable'; +import { parseAsArrayOf, parseAsString, parseAsStringEnum, parseAsBoolean, useQueryStates } from 'nuqs'; +import { ResourceStatusApi, ProviderApi } from '@api'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; + +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 Nodes: React.FunctionComponent = () => { + useDocumentTitle('Nodes — ClusterIQ'); + const [{ status, provider, serverName, showTerminated }, setQuery] = useQueryStates(filterParams); + + return ( + + + + Nodes + + + + + 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 Nodes; diff --git a/console/src/app/Nodes/components/NodesTable.tsx b/console/src/app/Nodes/components/NodesTable.tsx new file mode 100644 index 00000000..7c2b4f59 --- /dev/null +++ b/console/src/app/Nodes/components/NodesTable.tsx @@ -0,0 +1,161 @@ +import { renderStatusLabel, ResourceBadge } 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 { NodesTableProps } from '../types'; +import { InstanceResponseApi } from '@api'; +import { TablePagination } from '@app/components/common/TablesPagination'; +import { searchItems, filterByStatus, filterByProvider, sortItems } from '@app/utils/tableFilters'; +import { TableSkeleton } from '@app/components/common/TableSkeleton'; +import { ServerIcon } from '@patternfly/react-icons'; +import { useInstances } from '@app/hooks/useInstances'; +import { useTablePagination } from '@app/hooks/useTablePagination'; + +export const NodesTable: 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 nodes found + + } + icon={ServerIcon} + variant={EmptyStateVariant.sm} + > + + {!showTerminated ? ( + <> + There are no active nodes. +
+ Toggle 'Show terminated nodes' to view all nodes. + + ) : ( + 'No nodes match the current filters.' + )} +
+
+ ); + } + + 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 NodesTable; diff --git a/console/src/app/Nodes/components/NodesTableToolbar.tsx b/console/src/app/Nodes/components/NodesTableToolbar.tsx new file mode 100644 index 00000000..f6942689 --- /dev/null +++ b/console/src/app/Nodes/components/NodesTableToolbar.tsx @@ -0,0 +1,432 @@ +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 { NodesTableToolbarProps } from '../types'; +import { ResourceStatusApi, ProviderApi } from '@api'; +import debounce from 'lodash.debounce'; +import { usePopperContainer } from '@app/hooks/usePopperContainer'; + +export const NodesTableToolbar: React.FunctionComponent = ({ + searchValue, + setSearchValue, + setStatusSelection, + setProviderSelections, + providerSelections, + statusSelection, + showTerminated, + setShowTerminated, +}) => { + const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); + + React.useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + const searchInput = ( + debouncedSearch(value)} + onClear={() => debouncedSearch('')} + /> + ); + + 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 = ( +
+ +
+ ); + + 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 = ( +
+ +
+ ); + + const [activeAttributeMenu, setActiveAttributeMenu] = React.useState<'Nodes' | 'Status' | 'Provider'>('Nodes'); + 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 'Nodes' | 'Status' | 'Provider'); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }} + > + + + Nodes + Status + Provider + + + + ); + + const attributeDropdown = ( +
+ +
+ ); + + return ( + { + setSearchValue(''); + setStatusSelection(null); + setProviderSelections(null); + }} + > + + } breakpoint="xl"> + + {attributeDropdown} + setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} + categoryName="Name" + showToolbarItem={activeAttributeMenu === 'Nodes'} + > + {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 NodesTableToolbar; diff --git a/console/src/app/Nodes/types.ts b/console/src/app/Nodes/types.ts new file mode 100644 index 00000000..97653f86 --- /dev/null +++ b/console/src/app/Nodes/types.ts @@ -0,0 +1,19 @@ +import { ResourceStatusApi, ProviderApi } from '@api'; + +export interface NodesTableProps { + searchValue: string; + statusSelection: string | null; + providerSelections: ProviderApi[] | null; + showTerminated: boolean; +} + +export interface NodesTableToolbarProps { + 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/NotFound/NotFound.tsx b/console/src/app/NotFound/NotFound.tsx new file mode 100644 index 00000000..938f83d2 --- /dev/null +++ b/console/src/app/NotFound/NotFound.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Button, EmptyState, EmptyStateBody, EmptyStateVariant, PageSection, Title } from '@patternfly/react-core'; +import { useNavigate } from 'react-router-dom'; + +const NotFound: React.FunctionComponent = () => { + const navigate = useNavigate(); + + return ( + + + 404: Page not found + + } + variant={EmptyStateVariant.full} + > + The page you are looking for does not exist. + + + + ); +}; + +export default NotFound; diff --git a/console/src/app/Overview/Overview.css b/console/src/app/Overview/Overview.css new file mode 100644 index 00000000..02715451 --- /dev/null +++ b/console/src/app/Overview/Overview.css @@ -0,0 +1,4 @@ +.overview-card { + border: 1px solid var(--pf-t--global--border--color--default); + border-radius: var(--pf-t--global--border--radius--medium); +} diff --git a/console/src/app/Overview/Overview.tsx b/console/src/app/Overview/Overview.tsx new file mode 100644 index 00000000..116b7559 --- /dev/null +++ b/console/src/app/Overview/Overview.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { + Card, + CardBody, + CardTitle, + Gallery, + PageSection, + Content, + Alert, + Button, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateActions, +} from '@patternfly/react-core'; +import { CubesIcon, DollarSignIcon, GlobeIcon, UserIcon, HandshakeIcon, HistoryIcon } from '@patternfly/react-icons'; +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { generateCards } from './components/CardData'; +import { PartnerDonutChart } from './components/PartnerDonutChart'; +import { TopMetricCard } from './components/TopMetricCard'; +import { ProviderApi, TopItemApi } from '@api'; +import { renderContent } from './utils/cardRendererUtils.tsx'; +import { useDashboardData } from './hooks/useDashboardData'; +import { useEventsData } from './hooks/useEventsData'; +import { useDocumentTitle } from '@app/utils/useDocumentTitle'; +import { DashboardState } from './types'; +import './Overview.css'; + +const AggregateStatusCards: React.FunctionComponent = () => { + useDocumentTitle('Overview — ClusterIQ'); + 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, + }, + 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, + }, + lastScanTimestamp: inventoryData?.scanner?.lastScanTimestamp, + topRegions: inventoryData?.topRegions || [], + topOwners: inventoryData?.topOwners || [], + clustersByPartner: inventoryData?.clustersByPartner || [], + costPerAccount: inventoryData?.costPerAccount || [], + }; + + const costAsTopItems: TopItemApi[] = (dashboardState.costPerAccount || []).map(a => ({ + name: a.accountName, + clusterCount: a.currentMonthCost, + })); + const formatCost = (v: number) => `$${v.toFixed(2)}`; + + const cardData = generateCards(dashboardState, events); + + return ( + + + + Overview + + + +
+ {/* Row 1: Summary cards */} + + {cardData.summaryCards.map((card, cardIndex) => ( + + + {card.title} + + {renderContent(card.content, card.layout, card.totalCount)} + + ))} + + + {/* Row 2: Partner chart + ranked lists */} +
+ +
+ } + /> + } /> + } /> + } + /> +
+
+ + {/* Row 4: Recent Events */} + + + + {cardData.activityCards[0].title} + + + {eventsLoading ? ( + + ) : eventsError ? ( + +

{eventsError}

+

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

+
+ ) : cardData.activityCards[0].customComponent ? ( + cardData.activityCards[0].customComponent + ) : ( + renderContent( + cardData.activityCards[0].content, + cardData.activityCards[0].layout, + cardData.activityCards[0].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..caf8cb93 --- /dev/null +++ b/console/src/app/Overview/components/ActivityTable.tsx @@ -0,0 +1,64 @@ +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 { parseScanTimestamp, resolveResourcePath } from '@app/utils/parseFuncs'; +import { InboxIcon } from '@patternfly/react-icons'; +import { + renderOperationLabel, + renderActionStatusLabel, + renderResourceBadge, + ResourceBadge, +} from '@app/utils/renderUtils'; +import { ActionOperations } from '@app/types/types'; + +interface ActivityTableProps { + events: SystemEventResponseApi[]; +} + +export const ActivityTable: React.FunctionComponent = ({ events }) => { + if (events.length === 0) { + return ; + } + + return ( + + + + + + + + + + + + {events.map(event => ( + + + + + + + + ))} + +
DateOperationResourceStatusRequester
{parseScanTimestamp(event.timestamp)}{renderOperationLabel(event.action)} + {event.resourceId ? ( + <> + {renderResourceBadge(event.resourceType)}{' '} + + {event.resourceName || event.resourceId} + + + ) : event.action === ActionOperations.SCAN ? ( + <> + All Accounts + + ) : ( + '-' + )} + {renderActionStatusLabel(event.result)}{event.requester}
+ ); +}; diff --git a/console/src/app/Overview/components/CardData.tsx b/console/src/app/Overview/components/CardData.tsx new file mode 100644 index 00000000..10ce5bc4 --- /dev/null +++ b/console/src/app/Overview/components/CardData.tsx @@ -0,0 +1,83 @@ +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'; +import { parseScanTimestamp } from '@app/utils/parseFuncs'; + +export const generateCards = ( + state: DashboardState, + events: SystemEventResponseApi[] = [] +): Record => { + const scannerContent = parseScanTimestamp(state.lastScanTimestamp); + + const totalAccounts = Object.values(state.accountsByProvider).reduce((sum, count) => sum + count, 0); + const totalClustersByProvider = + Object.values(state.clustersByProvider).reduce((sum, count) => sum + count, 0) - + (state.clustersByStatus.terminated || 0); + const totalClustersByStatus = (state.clustersByStatus.running || 0) + (state.clustersByStatus.stopped || 0); + + const summaryCards: CardDefinition[] = [ + { + title: 'Accounts', + content: Object.values(CLOUD_PROVIDERS).map(provider => ({ + icon: provider.providerIcon, + value: state.accountsByProvider[provider.key] ?? 0, + ref: `/accounts?provider=${provider.key}`, + })), + layout: CardLayout.MULTI_ICON, + totalCount: { + icon: TOTAL_COUNT_ICONS.clusters, + value: totalAccounts, + label: 'Total', + }, + }, + { + title: 'Clusters by Provider', + content: Object.values(CLOUD_PROVIDERS).map(provider => ({ + icon: provider.icon, + value: state.clustersByProvider[provider.key] ?? 0, + ref: `/clusters?provider=${provider.key}`, + })), + layout: CardLayout.MULTI_ICON, + totalCount: { + icon: TOTAL_COUNT_ICONS.clusters, + value: totalClustersByProvider, + label: 'Total', + }, + }, + { + title: 'Clusters by Status', + 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: totalClustersByStatus, + label: 'Total', + }, + }, + { + title: 'Last Scan Timestamp', + content: [{ value: scannerContent }], + layout: CardLayout.MULTI_ICON, + }, + ]; + + const activityCards: CardDefinition[] = [ + { + title: 'Recent events', + content: [], + layout: CardLayout.MULTI_ICON, + customComponent: , + }, + ]; + + return { + summaryCards, + 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..7f0e32ae --- /dev/null +++ b/console/src/app/Overview/components/CardRenderer.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { CardContentItem, CardTotalCount } from '@app/Overview/types.ts'; +import { Divider, Flex, FlexItem, Stack } from '@patternfly/react-core'; + +export const RenderSingleIcon: React.FunctionComponent<{ content: CardContentItem[] }> = ({ content }) => + content[0]?.icon; + +export const RenderMultiIcon: React.FunctionComponent<{ content: CardContentItem[]; 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} + + + + )} + +); +interface SubtitleContentItem { + icon?: React.ReactNode; + status: string; + subtitle: string; +} + +export const RenderWithSubtitle: React.FC<{ content: SubtitleContentItem[] }> = ({ content }) => ( + + {content.map(({ icon, status, subtitle }, index) => ( + + {icon} + + {status} + {subtitle} + + + ))} + +); diff --git a/console/src/app/Overview/components/CostBarChart.tsx b/console/src/app/Overview/components/CostBarChart.tsx new file mode 100644 index 00000000..769ed932 --- /dev/null +++ b/console/src/app/Overview/components/CostBarChart.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { Chart, ChartBar, ChartAxis, ChartGroup, ChartThemeColor } from '@patternfly/react-charts/victory'; +import { AccountCostApi } from '@api'; + +interface CostBarChartProps { + data: AccountCostApi[]; +} + +const axisTextStyle = { fill: 'var(--pf-t--global--text--color--regular)' }; + +export const CostBarChart: React.FC = ({ data }) => { + const chartData = (data || []).map(item => ({ + x: item.accountName || 'Unknown', + y: item.currentMonthCost ?? 0, + })); + + const maxCost = Math.max(...chartData.map(d => d.y), 1); + + return ( + + Cost per Account (Current Month) + + {chartData.length === 0 ? ( + No cost data available + ) : ( +
+ + + `$${t.toFixed(0)}`} + style={{ tickLabels: { ...axisTextStyle, fontSize: 12 } }} + /> + + + + +
+ )} +
+
+ ); +}; diff --git a/console/src/app/Overview/components/PartnerDonutChart.tsx b/console/src/app/Overview/components/PartnerDonutChart.tsx new file mode 100644 index 00000000..e01b1e7f --- /dev/null +++ b/console/src/app/Overview/components/PartnerDonutChart.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { ChartDonut, ChartThemeColor } from '@patternfly/react-charts/victory'; +import { HandshakeIcon } from '@patternfly/react-icons'; +import { TopItemApi } from '@api'; + +interface PartnerDonutChartProps { + data: TopItemApi[]; +} + +const DONUT_COLORS = ['#06c', '#4cb140', '#009596', '#f4c145', '#ec7a08', '#7d1007', '#8481dd']; + +export const PartnerDonutChart: React.FC = ({ data }) => { + const chartData = (data || []).map(item => ({ + x: item.name || 'Unknown', + y: item.clusterCount ?? 0, + })); + + const total = chartData.reduce((sum, d) => sum + d.y, 0); + + return ( + + + + Clusters by Partner + + + {chartData.length === 0 ? ( + No partner data available + ) : ( +
+
+ DONUT_COLORS[index % DONUT_COLORS.length], + }, + }} + /> +
+
{total}
+
Clusters
+
+
+
+ {chartData.map((d, i) => ( +
+ + + {d.x}: {d.y} + +
+ ))} +
+
+ )} +
+
+ ); +}; diff --git a/console/src/app/Overview/components/TopMetricCard.tsx b/console/src/app/Overview/components/TopMetricCard.tsx new file mode 100644 index 00000000..fa6c69b0 --- /dev/null +++ b/console/src/app/Overview/components/TopMetricCard.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { TopItemApi } from '@api'; + +interface TopMetricCardProps { + title: string; + items: TopItemApi[]; + formatValue?: (value: number) => string; + icon?: React.ReactNode; +} + +export const TopMetricCard: React.FC = ({ title, items, formatValue, icon }) => { + if (!items || items.length === 0) { + return ( + + + {icon && {icon}} + {title} + + + No data available + + + ); + } + + return ( + + + {icon && {icon}} + {title} + + +
+ {items.map((item, index) => ( +
+ {item.name || 'Unknown'} + + {formatValue ? formatValue(item.clusterCount ?? 0) : (item.clusterCount ?? 0)} + +
+ ))} +
+
+
+ ); +}; 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..3ca6535f --- /dev/null +++ b/console/src/app/Overview/hooks/useDashboardData.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, OverviewSummaryApi } from '@api'; + +export const useDashboardData = () => { + const { data, isLoading, error } = useQuery({ + queryKey: ['overview'], + queryFn: async ({ signal }) => { + const { data } = await api.overview.overviewList({ signal }); + return data; + }, + refetchInterval: 5_000, + }); + + return { + inventoryData: data, + loading: isLoading, + error: error ? 'Failed to fetch inventory data' : null, + }; +}; diff --git a/console/src/app/Overview/hooks/useEventsData.ts b/console/src/app/Overview/hooks/useEventsData.ts new file mode 100644 index 00000000..b713bc01 --- /dev/null +++ b/console/src/app/Overview/hooks/useEventsData.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, SystemEventResponseApi } from '@api'; + +export const useEventsData = () => { + const { data, isLoading, error } = useQuery({ + queryKey: ['recentEvents'], + queryFn: async ({ signal }) => { + const { data } = await api.events.eventsList({ page: 1, page_size: 10 }, { signal }); + return data.items || []; + }, + refetchInterval: 5_000, + }); + + return { + events: data || [], + loading: isLoading, + error: error ? 'Failed to fetch events' : null, + }; +}; diff --git a/console/src/app/Overview/types.ts b/console/src/app/Overview/types.ts new file mode 100644 index 00000000..1f1a1638 --- /dev/null +++ b/console/src/app/Overview/types.ts @@ -0,0 +1,39 @@ +import React from 'react'; +import { ProviderApi, TopItemApi, AccountCostApi } 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; + clustersByProvider: Record; + accountsByProvider: Record; + lastScanTimestamp?: string; + topRegions: TopItemApi[]; + topOwners: TopItemApi[]; + clustersByPartner: TopItemApi[]; + costPerAccount: AccountCostApi[]; +} 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/components/common/ErrorBoundary.tsx b/console/src/app/components/common/ErrorBoundary.tsx new file mode 100644 index 00000000..d2cdac3b --- /dev/null +++ b/console/src/app/components/common/ErrorBoundary.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateActions, + PageSection, +} from '@patternfly/react-core'; +import { ExclamationTriangleIcon } from '@patternfly/react-icons'; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, ErrorBoundaryState> { + state: ErrorBoundaryState = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + render() { + if (!this.state.hasError) return this.props.children; + + return ( + + + {this.state.error?.message || 'An unexpected error occurred.'} + + + + + + + + + ); + } +} 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/TableSkeleton.tsx b/console/src/app/components/common/TableSkeleton.tsx new file mode 100644 index 00000000..bb166c24 --- /dev/null +++ b/console/src/app/components/common/TableSkeleton.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Skeleton } from '@patternfly/react-core'; + +interface TableSkeletonProps { + rows?: number; + columns?: number; +} + +export const TableSkeleton: React.FunctionComponent = ({ rows = 5, columns = 4 }) => ( +
+
+ {Array.from({ length: columns }, (_, i) => ( + + ))} +
+ {Array.from({ length: rows }, (_, rowIndex) => ( +
+ {Array.from({ length: columns }, (_, colIndex) => ( + + ))} +
+ ))} +
+); diff --git a/console/src/app/components/common/TablesPagination.tsx b/console/src/app/components/common/TablesPagination.tsx new file mode 100644 index 00000000..d08665f5 --- /dev/null +++ b/console/src/app/components/common/TablesPagination.tsx @@ -0,0 +1,35 @@ +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..adf7ecb3 --- /dev/null +++ b/console/src/app/hooks/useAccounts.ts @@ -0,0 +1,13 @@ +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 || []; + }, + refetchInterval: 10_000, + }); +} diff --git a/console/src/app/hooks/useClusters.ts b/console/src/app/hooks/useClusters.ts new file mode 100644 index 00000000..4402ae38 --- /dev/null +++ b/console/src/app/hooks/useClusters.ts @@ -0,0 +1,13 @@ +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: 10000 }, { signal }); + return data.items || []; + }, + refetchInterval: 10_000, + }); +} diff --git a/console/src/app/hooks/useEvents.ts b/console/src/app/hooks/useEvents.ts new file mode 100644 index 00000000..9c961461 --- /dev/null +++ b/console/src/app/hooks/useEvents.ts @@ -0,0 +1,13 @@ +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 || []; + }, + refetchInterval: 5_000, + }); +} diff --git a/console/src/app/hooks/useInstances.ts b/console/src/app/hooks/useInstances.ts new file mode 100644 index 00000000..5e8a7054 --- /dev/null +++ b/console/src/app/hooks/useInstances.ts @@ -0,0 +1,13 @@ +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: 10000 }, { signal }); + return data.items || []; + }, + refetchInterval: 10_000, + }); +} 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..67977849 --- /dev/null +++ b/console/src/app/hooks/useScheduleActions.ts @@ -0,0 +1,20 @@ +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 || []; + }, + refetchInterval: 5_000, + }); +} + +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..5a28de65 --- /dev/null +++ b/console/src/app/hooks/useTableSort.tsx @@ -0,0 +1,46 @@ +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' && typeof bValue === 'number') { + return activeSortDirection === 'asc' ? aValue - bValue : bValue - aValue; + } + const aStr = String(aValue); + const bStr = String(bValue); + return activeSortDirection === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr); + }); + } + + 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..e970d469 --- /dev/null +++ b/console/src/app/index.tsx @@ -0,0 +1,65 @@ +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 NodeDetails from './NodeDetails/NodeDetails'; +import AuditLogs from './Actions/AuditLogs/AuditLogs'; +import Scheduler from './Actions/Scheduler/Schedule'; +import Nodes from './Nodes/Nodes'; +import Accounts from './Accounts/Accounts'; +import NotFound from './NotFound/NotFound'; +import { NuqsAdapter } from 'nuqs/adapters/react'; +import { UserProvider } from './Contexts/UserContext'; +import { ErrorBoundary } from '@app/components/common/ErrorBoundary'; +import { debug } from '@app/utils/debugLogs'; + +const RouteDebugWrapper = ({ children }: { children: React.ReactNode }) => { + const location = useLocation(); + + React.useEffect(() => { + debug('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..2fe4a4cf --- /dev/null +++ b/console/src/app/types/types.tsx @@ -0,0 +1,52 @@ +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', + SCAN = 'Scan', +} + +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..00c5dc49 --- /dev/null +++ b/console/src/app/utils/parseFuncs.tsx @@ -0,0 +1,48 @@ +import { parseISO, format, formatDistanceToNow } from 'date-fns'; +import { createParser } from 'nuqs'; + +export function parseScanTimestamp(ts: string | undefined) { + if (!ts || ts.startsWith('0001-01-01')) return 'Never'; + const date = parseISO(ts); + const absolute = format(date, 'HH:mm:ss - dd/MM/yyyy'); + const relative = formatDistanceToNow(date, { addSuffix: true }); + return `${absolute} (${relative})`; +} + +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}`; + } + + if (resourceType === 'Account') { + return `/accounts/${resourceName}`; + } + + 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..2dd9baae --- /dev/null +++ b/console/src/app/utils/renderUtils.tsx @@ -0,0 +1,192 @@ +import React, { CSSProperties } from 'react'; +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, + AwsIcon, + GoogleIcon, + AzureIcon, +} from '@patternfly/react-icons'; +import { Link } from 'react-router-dom'; + +const resourceBadgeStyle = (color: string): CSSProperties => ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: '1.5em', + height: '1.5em', + padding: '0 0.35em', + borderRadius: '50%', + backgroundColor: color, + color: '#fff', + fontSize: '0.75rem', + fontWeight: 700, + lineHeight: 1, + verticalAlign: 'middle', +}); + +export function ResourceBadge({ label, color }: { label: string; color: string }) { + return {label}; +} + +const resourceLabelStyle = (color: string): CSSProperties => ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + padding: '0.1em 0.5em', + borderRadius: '0.75em', + backgroundColor: color, + color: '#fff', + fontSize: '0.75em', + fontWeight: 700, + lineHeight: 1, + verticalAlign: 'middle', +}); + +export function ResourceLabel({ label, color }: { label: string; color: string }) { + return {label}; +} + +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 ; + case ActionOperations.SCAN: + return ; + default: + return ; + } +} + +export function renderTargetLabel( + clusterId: string | undefined, + clusterName: string | undefined, + targetAccountIds: string[] | undefined, + targetAccountNames: string[] | undefined, + selectAll: boolean | undefined +): React.ReactNode { + if (clusterId) { + return ( + <> + {' '} + {clusterName || clusterId} + + ); + } + if (!selectAll && targetAccountIds?.length) { + const accId = targetAccountIds[0]; + const accName = targetAccountNames?.[0]; + return ( + <> + {accName || accId} + + ); + } + return ( + <> + All Accounts + + ); +} + +export const getResultIcon = (result: ResultStatus) => { + return ( + { + [ResultStatus.Success]: ( + + ), + [ResultStatus.Running]: ( + + ), + [ResultStatus.Pending]: ( + + ), + [ResultStatus.Failed]: ( + + ), + [ResultStatus.Warning]: ( + + ), + [ResultStatus.Unknown]: , + }[result] || + ); +}; + +const providerIconStyle: CSSProperties = { fontSize: '2.0em', verticalAlign: 'middle' }; + +export function renderProviderIcon(provider: string | null | undefined): React.ReactNode { + switch (provider) { + case 'AWS': + return ; + case 'GCP': + return ; + case 'Azure': + return ; + default: + return provider || '-'; + } +} + +const NODE_BADGE = { label: 'N', color: '#4cb140' }; + +const RESOURCE_BADGE_MAP: Record = { + Cluster: { label: 'C', color: '#0066cc' }, + Node: NODE_BADGE, + Instance: NODE_BADGE, + Account: { label: 'A', color: '#c9190b' }, +}; + +export function renderResourceBadge(resourceType: string | undefined): React.ReactNode { + const badge = RESOURCE_BADGE_MAP[resourceType ?? '']; + if (!badge) return null; + return ; +} 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/db/sql/cron.sql b/db/sql/cron.sql index fe841b3a..2665b2b6 100644 --- a/db/sql/cron.sql +++ b/db/sql/cron.sql @@ -10,18 +10,18 @@ SELECT cron.schedule_in_database( 'clusteriq' ); --- pg_cron task for creating a new monthly partition for 'Expenses' table every Sunday +-- pg_cron task for creating a new monthly partition for 'Expenses' table daily SELECT cron.schedule_in_database( 'expenses_partitioning', - '0 0 * * 6', + '0 0 * * *', $$SELECT create_next_month_expenses_partition();$$, 'clusteriq' ); --- pg_cron task for creating a new monthly partition for 'audit_logs' table every Sunday +-- pg_cron task for creating a new monthly partition for 'audit_logs' table daily SELECT cron.schedule_in_database( 'events_partitioning', - '0 0 * * 6', + '0 0 * * *', $$SELECT create_next_month_events_partition();$$, 'clusteriq' ); @@ -34,6 +34,14 @@ SELECT cron.schedule_in_database( 'clusteriq' ); +-- pg_cron task for purging expired terminated clusters daily at midnight +SELECT cron.schedule_in_database( + 'purge_expired_clusters', + '0 0 * * *', + $$SELECT purge_expired_clusters();$$, + 'clusteriq' +); + -- Function to check easier how the pg_cron tasks went CREATE OR REPLACE FUNCTION pg_cron_history(p_limit int DEFAULT 20) RETURNS TABLE( diff --git a/db/sql/init.sql b/db/sql/init.sql index 19d7ecec..046f2e40 100644 --- a/db/sql/init.sql +++ b/db/sql/init.sql @@ -26,7 +26,8 @@ CREATE TYPE RESOURCE_TYPE AS ENUM ( -- Supported values of Action Operations CREATE TYPE ACTION_OPERATION AS ENUM ( 'PowerOn', - 'PowerOff' + 'PowerOff', + 'Scan' ); -- Supported values of action types @@ -192,13 +193,14 @@ CREATE TABLE expenses_default PARTITION OF expenses DEFAULT; CREATE TABLE IF NOT EXISTS events ( id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, event_timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - triggered_by TEXT NOT NULL, + requester TEXT NOT NULL, action TEXT NOT NULL, resource_id BIGINT, resource_type RESOURCE_TYPE NOT NULL, result ACTION_STATUS NOT NULL, description TEXT NULL, severity TEXT DEFAULT 'info'::TEXT NOT NULL, + schedule_id BIGINT NULL, PRIMARY KEY (id, event_timestamp) ) PARTITION BY RANGE (event_timestamp); @@ -237,6 +239,37 @@ CREATE TRIGGER trg_delete_instance_events EXECUTE FUNCTION delete_instance_events(); +-- ############################################################################# +-- ## Targets definition ## +-- ############################################################################# +\! echo '## Creating Targets tables' + +CREATE TABLE IF NOT EXISTS targets ( + id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, + target_type RESOURCE_TYPE NOT NULL, + select_all BOOLEAN DEFAULT false, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS target_accounts ( + target_id BIGINT REFERENCES targets(id) ON DELETE CASCADE NOT NULL, + account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (target_id, account_id) +); + +CREATE TABLE IF NOT EXISTS target_clusters ( + target_id BIGINT REFERENCES targets(id) ON DELETE CASCADE NOT NULL, + cluster_id BIGINT REFERENCES clusters(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (target_id, cluster_id) +); + +CREATE TABLE IF NOT EXISTS target_instances ( + target_id BIGINT REFERENCES targets(id) ON DELETE CASCADE NOT NULL, + instance_id BIGINT REFERENCES instances(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (target_id, instance_id) +); + + -- ############################################################################# -- ## Actions and Scheduling definition ## -- ############################################################################# @@ -248,10 +281,12 @@ CREATE TABLE IF NOT EXISTS schedule ( time TIMESTAMP WITH TIME ZONE, cron_exp TEXT, operation ACTION_OPERATION NOT NULL, - target INTEGER REFERENCES clusters(id) ON DELETE CASCADE NOT NULL, + target BIGINT REFERENCES targets(id) ON DELETE CASCADE NOT NULL, status ACTION_STATUS DEFAULT 'Unknown' NOT NULL, enabled BOOLEAN DEFAULT false, - PRIMARY KEY (id), + requester TEXT, + description TEXT, + PRIMARY KEY (id), CONSTRAINT chk_schedule_time_or_cron CHECK ((time IS NOT NULL) <> (cron_exp IS NOT NULL)) ); @@ -259,6 +294,25 @@ CREATE INDEX IF NOT EXISTS ix_schedule_target_enabled ON schedule (target, enabl CREATE INDEX IF NOT EXISTS ix_schedule_status ON schedule (status); +-- ############################################################################# +-- ## Action Runs (execution history) ## +-- ############################################################################# +\! echo '## Creating Action Runs table' + +CREATE TABLE IF NOT EXISTS action_runs ( + id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, + schedule_id BIGINT REFERENCES schedule(id) ON DELETE CASCADE NOT NULL, + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + finished_at TIMESTAMP WITH TIME ZONE, + status ACTION_STATUS NOT NULL DEFAULT 'Running', + error_msg TEXT, + PRIMARY KEY (id) +); + +CREATE INDEX IF NOT EXISTS ix_action_runs_schedule ON action_runs (schedule_id); +CREATE INDEX IF NOT EXISTS ix_action_runs_status ON action_runs (status); + + -- ## Advanced Inventory definition (Views & Functions) ## -- ################################################################################################# @@ -516,29 +570,50 @@ WHERE -- ## Schedule -- ############################################################################# --- Schedule with cluster and instances list view +-- Schedule with target details view CREATE OR REPLACE VIEW schedule_full_view AS SELECT - s.id, - s.type, - s.time, - s.cron_exp, - s.operation, - s.status, - s.enabled, - c.cluster_id, - c.region, - a.account_id, - COALESCE( - array_agg(DISTINCT i.instance_id ORDER BY i.instance_id), - '{}' - ) AS instances -FROM - schedule s -JOIN clusters c ON c.id = s.target + s.id, + s.type, + s.time, + s.cron_exp, + s.operation, + s.status, + s.enabled, + s.requester, + s.description, + t.target_type, + t.select_all, + c.cluster_id, + c.cluster_name, + c.region, + COALESCE(a_power.account_id, '') AS account_id, + COALESCE( + array_agg(DISTINCT i.instance_id ORDER BY i.instance_id) + FILTER (WHERE i.instance_id IS NOT NULL), + '{}' + ) AS instances, + COALESCE( + (SELECT array_agg(DISTINCT accs.account_id ORDER BY accs.account_id) + FROM target_accounts ta_sub + JOIN accounts accs ON accs.id = ta_sub.account_id + WHERE ta_sub.target_id = t.id), + '{}' + ) AS target_account_ids, + COALESCE( + (SELECT array_agg(DISTINCT accs.account_name ORDER BY accs.account_name) + FROM target_accounts ta_sub + JOIN accounts accs ON accs.id = ta_sub.account_id + WHERE ta_sub.target_id = t.id), + '{}' + ) AS target_account_names +FROM schedule s +JOIN targets t ON t.id = s.target +LEFT JOIN target_clusters tc ON tc.target_id = t.id +LEFT JOIN clusters c ON c.id = tc.cluster_id LEFT JOIN instances i ON i.cluster_id = c.id -JOIN accounts a ON c.account_id = a.id -GROUP BY a.account_id, s.id, c.id +LEFT JOIN accounts a_power ON a_power.id = c.account_id +GROUP BY s.id, t.id, c.id, a_power.account_id ORDER BY s.id; @@ -578,16 +653,18 @@ CREATE OR REPLACE VIEW cluster_events AS SELECT ev.id, ev.event_timestamp, - ev.triggered_by, + ev.requester, ev.action, - COALESCE(c.cluster_id, i.instance_id) AS resource_id, + COALESCE(c.cluster_id, i.instance_id, a.account_id) AS resource_id, ev.resource_type, ev.result, ev.description, - ev.severity + ev.severity, + ev.schedule_id FROM events ev LEFT JOIN clusters c ON ev.resource_type = 'Cluster'::RESOURCE_TYPE AND c.id = ev.resource_id LEFT JOIN instances i ON ev.resource_type = 'Instance'::RESOURCE_TYPE AND i.id = ev.resource_id +LEFT JOIN accounts a ON ev.resource_type = 'Account'::RESOURCE_TYPE AND a.id = ev.resource_id ORDER BY event_timestamp DESC; -- View for System Events @@ -595,24 +672,30 @@ CREATE OR REPLACE VIEW system_events AS SELECT ev.id, ev.event_timestamp, - ev.triggered_by, + ev.requester, ev.action, - COALESCE(c.cluster_id, i.instance_id) AS resource_id, + COALESCE(c.cluster_id, i.instance_id, a.account_id) AS resource_id, + COALESCE(c.cluster_name, i.instance_name, a.account_name) AS resource_name, ev.resource_type, ev.result, ev.description, ev.severity, + ev.schedule_id, acc.account_id, + acc.account_name, acc.provider FROM events ev LEFT JOIN clusters c ON ev.resource_type = 'Cluster'::RESOURCE_TYPE AND c.id = ev.resource_id LEFT JOIN instances i ON ev.resource_type = 'Instance'::RESOURCE_TYPE AND i.id = ev.resource_id +LEFT JOIN accounts a ON ev.resource_type = 'Account'::RESOURCE_TYPE AND a.id = ev.resource_id LEFT JOIN accounts acc ON acc.id = ( CASE WHEN ev.resource_type = 'Cluster'::RESOURCE_TYPE THEN (SELECT c.account_id FROM clusters c WHERE c.id = ev.resource_id) WHEN ev.resource_type = 'Instance'::RESOURCE_TYPE THEN (SELECT c.account_id FROM clusters c WHERE c.id = (SELECT i.cluster_id FROM instances i WHERE i.id = ev.resource_id)) + WHEN ev.resource_type = 'Account'::RESOURCE_TYPE + THEN ev.resource_id END ) ORDER BY ev.event_timestamp DESC; @@ -690,3 +773,27 @@ BEGIN REFRESH MATERIALIZED VIEW m_instances_full_view_with_tags; END; $$ LANGUAGE plpgsql; + +-- Purges terminated clusters whose last_scan_ts is older than retention_days. +-- Cascading FKs handle deletion of instances, tags, expenses, targets, schedules, and action_runs. +-- Triggers handle deletion of associated events. +CREATE OR REPLACE FUNCTION purge_expired_clusters(retention_days INTEGER DEFAULT 365) +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + WITH deleted AS ( + DELETE FROM clusters + WHERE status = 'Terminated' + AND last_scan_ts < NOW() - (retention_days || ' days')::INTERVAL + RETURNING id + ) + SELECT COUNT(*) INTO deleted_count FROM deleted; + + IF deleted_count > 0 THEN + PERFORM refresh_materialized_views(); + END IF; + + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; diff --git a/db/test_files/integration_tests_data.sql b/db/test_files/integration_tests_data.sql index aaddeaf3..4c63021e 100644 --- a/db/test_files/integration_tests_data.sql +++ b/db/test_files/integration_tests_data.sql @@ -1,7 +1,7 @@ BEGIN; -- Cleaning -TRUNCATE expenses, tags, instances, clusters, accounts RESTART IDENTITY CASCADE; +TRUNCATE action_runs, schedule, targets, expenses, tags, instances, clusters, accounts RESTART IDENTITY CASCADE; -- ## Accounts ## INSERT INTO accounts (account_id, account_name, provider, last_scan_ts, created_at) VALUES @@ -70,17 +70,35 @@ INSERT INTO expenses (instance_id, date, amount) VALUES (12,'2025-07-30',1.10),(12,'2025-07-31',1.15),(12,'2025-08-01',1.20),(12,'2025-08-02',1.25),(12,'2025-08-03',1.30); -INSERT INTO events (event_timestamp, triggered_by, action, resource_id, resource_type, result, description, severity) VALUES +INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) VALUES ('2025-08-02 12:00:00+00', 'cluster-iq-tester', 'test', '1', 'Cluster', 'Success', 'integration test event', 'info'), ('2025-08-02 12:00:00+00', 'cluster-iq-tester', 'test', '10', 'Instance', 'Pending', 'integration test event', 'critical'); +-- ## Targets (for schedule entries) ## +INSERT INTO targets (target_type, select_all) VALUES + ('Cluster', false), + ('Cluster', false), + ('Cluster', false); + +INSERT INTO target_clusters (target_id, cluster_id) VALUES + (1, 1), + (2, 2), + (3, 4); + INSERT INTO schedule (type, time, operation, target, status, enabled) VALUES ('scheduled_action', '1970-01-01 00:00:00+000', 'PowerOff', 1, 'Pending', 't'), - ('scheduled_action', '1970-01-01 00:00:00+000', 'PowerOn', 4, 'Pending', 'f'); + ('scheduled_action', '1970-01-01 00:00:00+000', 'PowerOn', 3, 'Pending', 'f'); INSERT INTO schedule (type, cron_exp, operation, target, status, enabled) VALUES ('scheduled_action', '30 */12 * 6 *', 'PowerOff', 2, 'Pending', 'f'); +-- ## Action Runs ## +INSERT INTO action_runs (schedule_id, status) VALUES + (1, 'Running'); + +INSERT INTO action_runs (schedule_id, status, finished_at, error_msg) VALUES + (2, 'Success', '2025-08-02 13:00:00+00', NULL); + COMMIT; diff --git a/db/test_files/load_example_data.sql b/db/test_files/load_example_data.sql index 7367fd47..30e2d99a 100644 --- a/db/test_files/load_example_data.sql +++ b/db/test_files/load_example_data.sql @@ -2,19 +2,10 @@ -- psql postgresql://user:password@pgsql:5432/clusteriq < load_example_data.sql BEGIN; --- Limpia datos previos (si los hubiera) -TRUNCATE expenses, tags, instances, clusters, accounts RESTART IDENTITY CASCADE; --- Inserta 3 cuentas (una por proveedor) y guarda sus IDs -WITH ins AS ( - INSERT INTO accounts (account_id, account_name, provider, last_scan_ts) - VALUES - ('111111111111', 'aws-account-demo', 'AWS', now() - INTERVAL '1 day'), - ('gcp-project-1', 'gcp-project-demo', 'GCP', now() - INTERVAL '2 days'), - ('subs-00000001', 'azure-sub-demo', 'Azure', now() - INTERVAL '3 days') - RETURNING id, provider -) -SELECT * FROM ins; +-- Clean previous data +TRUNCATE action_runs, schedule, targets, expenses, tags, instances, clusters, accounts RESTART IDENTITY CASCADE; +-- Drop existing expense partitions to avoid conflicts DO $$ DECLARE r RECORD; @@ -29,235 +20,526 @@ BEGIN END $$; - +-- Create expense partitions for 3 months DO $$ DECLARE cur_start DATE := date_trunc('month', current_date)::date; cur_end DATE := (cur_start + INTERVAL '1 month')::date; - prev_start DATE := date_trunc('month', current_date - INTERVAL '1 month')::date; prev_end DATE := cur_start; - - prev_prev_start DATE := date_trunc('month', current_date - INTERVAL '2 month')::date; - prev_prev_end DATE := prev_start; - - cur_suffix TEXT := to_char(cur_start, 'YYYY_MM'); - prev_suffix TEXT := to_char(prev_start, 'YYYY_MM'); - prev_prev_suffix TEXT := to_char(prev_prev_start, 'YYYY_MM'); - - part_name TEXT; - sql TEXT; + prev2_start DATE := date_trunc('month', current_date - INTERVAL '2 month')::date; + prev2_end DATE := prev_start; + part_name TEXT; BEGIN - -- Partición del mes anterior anterior: expenses_YYYY_MM - part_name := format('expenses_%s', prev_prev_suffix); + part_name := format('expenses_%s', to_char(prev2_start, 'YYYY_MM')); IF to_regclass(part_name) IS NULL THEN - sql := format( - 'CREATE TABLE %I PARTITION OF expenses - FOR VALUES FROM (%L) TO (%L);', - part_name, prev_prev_start, prev_prev_end - ); - EXECUTE sql; + EXECUTE format('CREATE TABLE %I PARTITION OF expenses FOR VALUES FROM (%L) TO (%L)', part_name, prev2_start, prev2_end); END IF; - -- Partición del mes anterior: expenses_YYYY_MM - part_name := format('expenses_%s', prev_suffix); + part_name := format('expenses_%s', to_char(prev_start, 'YYYY_MM')); IF to_regclass(part_name) IS NULL THEN - sql := format( - 'CREATE TABLE %I PARTITION OF expenses - FOR VALUES FROM (%L) TO (%L);', - part_name, prev_start, prev_end - ); - EXECUTE sql; + EXECUTE format('CREATE TABLE %I PARTITION OF expenses FOR VALUES FROM (%L) TO (%L)', part_name, prev_start, prev_end); END IF; - -- Partición del mes en curso: expenses_YYYY_MM - part_name := format('expenses_%s', cur_suffix); + part_name := format('expenses_%s', to_char(cur_start, 'YYYY_MM')); IF to_regclass(part_name) IS NULL THEN - sql := format( - 'CREATE TABLE %I PARTITION OF expenses - FOR VALUES FROM (%L) TO (%L);', - part_name, cur_start, cur_end - ); - EXECUTE sql; + EXECUTE format('CREATE TABLE %I PARTITION OF expenses FOR VALUES FROM (%L) TO (%L)', part_name, cur_start, cur_end); END IF; END $$; +-- ============================================================================ +-- Accounts: 5 accounts across 3 providers +-- ============================================================================ +INSERT INTO accounts (account_id, account_name, provider, last_scan_ts) VALUES + ('111111111111', 'rh-engineering-prod', 'AWS', now() - INTERVAL '2 hours'), + ('222222222222', 'rh-engineering-dev', 'AWS', now() - INTERVAL '2 hours'), + ('333333333333', 'rh-qe-staging', 'AWS', now() - INTERVAL '2 hours'), + ('gcp-proj-001', 'rh-platform-gcp', 'GCP', now() - INTERVAL '2 hours'), + ('azure-sub-01', 'rh-services-azure', 'Azure', now() - INTERVAL '2 hours'); + +-- ============================================================================ +-- Main data generation +-- ============================================================================ +DO $$ +DECLARE + v_acc_id INT; + v_clu_pk BIGINT; + v_ins_pk BIGINT; + + -- Cluster definition arrays + v_name TEXT; + v_infra TEXT; + v_region TEXT; + v_status STATUS; + v_owner TEXT; + v_provider CLOUD_PROVIDER; + v_age INT; + v_partner TEXT; + + -- Instance vars + v_ins_name TEXT; + v_ins_type TEXT; + v_az TEXT; + v_ins_status STATUS; + + -- Expense vars + d DATE; + base_cost NUMERIC(12,2); + amt NUMERIC(12,2); + + -- Tag key pools + partners TEXT[] := ARRAY['Red Hat', 'Accenture', 'IBM', 'Deloitte', 'Wipro', 'Infosys', 'TCS']; + owners TEXT[] := ARRAY['jsmith@redhat.com', 'agarcia@redhat.com', 'mchen@redhat.com', 'pjones@redhat.com', 'lbrown@redhat.com', 'klee@redhat.com', 'ssingh@redhat.com', 'twilson@redhat.com']; + teams TEXT[] := ARRAY['Platform', 'SRE', 'QE', 'Performance', 'Security', 'DevOps', 'Middleware']; + envs TEXT[] := ARRAY['production', 'staging', 'development', 'qa', 'perf-test', 'sandbox']; + + -- AWS regions + aws_regions TEXT[] := ARRAY['us-east-1', 'us-east-2', 'us-west-2', 'eu-west-1', 'eu-central-1', 'ap-southeast-1']; + -- GCP regions + gcp_regions TEXT[] := ARRAY['us-central1', 'europe-west1', 'europe-west3', 'asia-east1']; + -- Azure regions + az_regions TEXT[] := ARRAY['eastus', 'westeurope', 'northeurope', 'southeastasia']; + + -- Instance type pools + aws_types TEXT[] := ARRAY['m5.xlarge', 'm5.2xlarge', 'r5.xlarge', 'r5.2xlarge', 'c5.2xlarge', 'c5.4xlarge', 'm6i.xlarge', 'm6i.2xlarge']; + gcp_types TEXT[] := ARRAY['e2-standard-4', 'e2-standard-8', 'n2-standard-4', 'n2-standard-8', 'n2-highmem-4']; + az_types TEXT[] := ARRAY['Standard_D4s_v3', 'Standard_D8s_v3', 'Standard_E4s_v3', 'Standard_E8s_v3']; + + -- Cluster definitions: (account_index, name, status, region_index, owner_index, partner_index, base_daily_cost) + -- We'll generate these programmatically per account + n_instances INT; + cost_base NUMERIC(12,2); + +BEGIN + -- ======================================================================== + -- Account 1: rh-engineering-prod (AWS) — 6 clusters, heavy usage + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = '111111111111'; + v_provider := 'AWS'; + + -- Cluster 1: Large production cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-prod-east', 'ocp-prod-east-abc12', 'abc12', v_provider, 'Running', 'us-east-1', v_acc_id, 'https://console-openshift-console.apps.ocp-prod-east.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '120 days', 120, 'jsmith@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..8 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-prod-east', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '120 days', 120) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'jsmith@redhat.com', v_ins_pk), ('Partner', 'Red Hat', v_ins_pk), ('Team', 'Platform', v_ins_pk), ('Environment', 'production', v_ins_pk); + cost_base := 3.50 + random() * 2.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.50, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 2: Staging cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-staging-east', 'ocp-staging-east-def34', 'def34', v_provider, 'Running', 'us-east-1', v_acc_id, 'https://console-openshift-console.apps.ocp-staging-east.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '90 days', 90, 'agarcia@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..5 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-staging-east', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '90 days', 90) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'agarcia@redhat.com', v_ins_pk), ('Partner', 'Accenture', v_ins_pk), ('Team', 'SRE', v_ins_pk), ('Environment', 'staging', v_ins_pk); + cost_base := 2.20 + random() * 1.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.30, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 3: Stopped weekend cluster + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-weekend-west', 'ocp-weekend-west-ghi56', 'ghi56', v_provider, 'Stopped', 'us-west-2', v_acc_id, 'https://console-openshift-console.apps.ocp-weekend-west.example.com', now() - INTERVAL '3 hours', now() - INTERVAL '60 days', 60, 'mchen@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-weekend-west', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-west-2' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '3 hours', now() - INTERVAL '60 days', 60) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'mchen@redhat.com', v_ins_pk), ('Partner', 'IBM', v_ins_pk), ('Team', 'QE', v_ins_pk), ('Environment', 'development', v_ins_pk); + cost_base := 1.80 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.10, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 4: EU production cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-prod-eu', 'ocp-prod-eu-jkl78', 'jkl78', v_provider, 'Running', 'eu-west-1', v_acc_id, 'https://console-openshift-console.apps.ocp-prod-eu.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '200 days', 200, 'pjones@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..6 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-prod-eu', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'eu-west-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '200 days', 200) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'pjones@redhat.com', v_ins_pk), ('Partner', 'Deloitte', v_ins_pk), ('Team', 'Security', v_ins_pk), ('Environment', 'production', v_ins_pk); + cost_base := 4.00 + random() * 2.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.80, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 5: Terminated old cluster + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-legacy-east', 'ocp-legacy-east-mno90', 'mno90', v_provider, 'Terminated', 'us-east-2', v_acc_id, '', now() - INTERVAL '30 days', now() - INTERVAL '365 days', 365, 'lbrown@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-legacy-east', j); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, 'm5.xlarge', 'us-east-2a', 'Terminated', now() - INTERVAL '30 days', now() - INTERVAL '365 days', 365) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'lbrown@redhat.com', v_ins_pk), ('Partner', 'Red Hat', v_ins_pk), ('Team', 'Platform', v_ins_pk), ('Environment', 'production', v_ins_pk); + END LOOP; + + -- Cluster 6: Frankfurt perf-test cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-perf-fra', 'ocp-perf-fra-pqr12', 'pqr12', v_provider, 'Running', 'eu-central-1', v_acc_id, 'https://console-openshift-console.apps.ocp-perf-fra.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '45 days', 45, 'ssingh@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..6 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-perf-fra', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'eu-central-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '45 days', 45) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'ssingh@redhat.com', v_ins_pk), ('Partner', 'Wipro', v_ins_pk), ('Team', 'Performance', v_ins_pk), ('Environment', 'perf-test', v_ins_pk); + cost_base := 5.00 + random() * 3.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(1.00, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- ======================================================================== + -- Account 2: rh-engineering-dev (AWS) — 4 clusters, moderate usage + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = '222222222222'; + + -- Cluster 7: Dev cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-dev-east', 'ocp-dev-east-stu34', 'stu34', v_provider, 'Running', 'us-east-1', v_acc_id, 'https://console-openshift-console.apps.ocp-dev-east.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '30 days', 30, 'klee@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-dev-east', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '30 days', 30) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'klee@redhat.com', v_ins_pk), ('Partner', 'Infosys', v_ins_pk), ('Team', 'DevOps', v_ins_pk), ('Environment', 'development', v_ins_pk); + cost_base := 1.50 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.20, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 8: Sandbox, stopped + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-sandbox', 'ocp-sandbox-vwx56', 'vwx56', v_provider, 'Stopped', 'us-east-2', v_acc_id, 'https://console-openshift-console.apps.ocp-sandbox.example.com', now() - INTERVAL '5 hours', now() - INTERVAL '15 days', 15, 'twilson@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-sandbox', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-2' || chr(97 + (j % 2)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '5 hours', now() - INTERVAL '15 days', 15) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'twilson@redhat.com', v_ins_pk), ('Partner', 'TCS', v_ins_pk), ('Team', 'QE', v_ins_pk), ('Environment', 'sandbox', v_ins_pk); + cost_base := 0.80 + random() * 0.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.05, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 9: CI cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-ci-west', 'ocp-ci-west-yza78', 'yza78', v_provider, 'Running', 'us-west-2', v_acc_id, 'https://console-openshift-console.apps.ocp-ci-west.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '75 days', 75, 'jsmith@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..5 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-ci-west', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-west-2' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '75 days', 75) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'jsmith@redhat.com', v_ins_pk), ('Partner', 'Red Hat', v_ins_pk), ('Team', 'SRE', v_ins_pk), ('Environment', 'qa', v_ins_pk); + cost_base := 2.50 + random() * 1.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.30, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 10: Terminated dev cluster + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-dev-old', 'ocp-dev-old-bcd90', 'bcd90', v_provider, 'Terminated', 'us-east-1', v_acc_id, '', now() - INTERVAL '45 days', now() - INTERVAL '180 days', 180, 'agarcia@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-dev-old', j); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, 'm5.xlarge', 'us-east-1a', 'Terminated', now() - INTERVAL '45 days', now() - INTERVAL '180 days', 180) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'agarcia@redhat.com', v_ins_pk), ('Partner', 'Accenture', v_ins_pk), ('Team', 'DevOps', v_ins_pk), ('Environment', 'development', v_ins_pk); + END LOOP; + + -- ======================================================================== + -- Account 3: rh-qe-staging (AWS) — 3 clusters + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = '333333333333'; + + -- Cluster 11: QE main cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-qe-main', 'ocp-qe-main-efg12', 'efg12', v_provider, 'Running', 'us-east-1', v_acc_id, 'https://console-openshift-console.apps.ocp-qe-main.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '100 days', 100, 'mchen@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..6 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-qe-main', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '100 days', 100) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'mchen@redhat.com', v_ins_pk), ('Partner', 'IBM', v_ins_pk), ('Team', 'QE', v_ins_pk), ('Environment', 'qa', v_ins_pk); + cost_base := 2.80 + random() * 1.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.40, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 12: QE nightly, stopped + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-qe-nightly', 'ocp-qe-nightly-hij34', 'hij34', v_provider, 'Stopped', 'us-west-2', v_acc_id, 'https://console-openshift-console.apps.ocp-qe-nightly.example.com', now() - INTERVAL '6 hours', now() - INTERVAL '50 days', 50, 'lbrown@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-qe-nightly', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-west-2' || chr(97 + (j % 2)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '6 hours', now() - INTERVAL '50 days', 50) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'lbrown@redhat.com', v_ins_pk), ('Partner', 'Deloitte', v_ins_pk), ('Team', 'QE', v_ins_pk), ('Environment', 'qa', v_ins_pk); + cost_base := 1.20 + random() * 0.8; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.10, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 13: APAC QE cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-qe-apac', 'ocp-qe-apac-klm56', 'klm56', v_provider, 'Running', 'ap-southeast-1', v_acc_id, 'https://console-openshift-console.apps.ocp-qe-apac.example.com', now() - INTERVAL '2 hours', now() - INTERVAL '25 days', 25, 'ssingh@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-qe-apac', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'ap-southeast-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '2 hours', now() - INTERVAL '25 days', 25) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'ssingh@redhat.com', v_ins_pk), ('Partner', 'Wipro', v_ins_pk), ('Team', 'Performance', v_ins_pk), ('Environment', 'perf-test', v_ins_pk); + cost_base := 2.00 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.25, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- ======================================================================== + -- Account 4: rh-platform-gcp (GCP) — 3 clusters + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = 'gcp-proj-001'; + v_provider := 'GCP'; + + -- Cluster 14: GCP production, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('gke-prod-eu', 'gke-prod-eu-nop78', 'nop78', v_provider, 'Running', 'europe-west1', v_acc_id, 'https://console.cloud.google.com/kubernetes/clusters/gke-prod-eu', now() - INTERVAL '1 hour', now() - INTERVAL '150 days', 150, 'pjones@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..5 LOOP + v_ins_name := format('gke-%s-node-%s', 'prod-eu', j); + v_ins_type := gcp_types[1 + floor(random()*array_length(gcp_types,1))::INT]; + v_az := 'europe-west1-' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '150 days', 150) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'pjones@redhat.com', v_ins_pk), ('Partner', 'Red Hat', v_ins_pk), ('Team', 'Middleware', v_ins_pk), ('Environment', 'production', v_ins_pk); + cost_base := 3.00 + random() * 2.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.50, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 15: GCP staging, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('gke-staging-us', 'gke-staging-us-qrs90', 'qrs90', v_provider, 'Running', 'us-central1', v_acc_id, 'https://console.cloud.google.com/kubernetes/clusters/gke-staging-us', now() - INTERVAL '1 hour', now() - INTERVAL '80 days', 80, 'klee@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('gke-%s-node-%s', 'staging-us', j); + v_ins_type := gcp_types[1 + floor(random()*array_length(gcp_types,1))::INT]; + v_az := 'us-central1-' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '80 days', 80) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'klee@redhat.com', v_ins_pk), ('Partner', 'TCS', v_ins_pk), ('Team', 'Platform', v_ins_pk), ('Environment', 'staging', v_ins_pk); + cost_base := 2.00 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.30, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 16: GCP dev, stopped + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('gke-dev-asia', 'gke-dev-asia-tuv12', 'tuv12', v_provider, 'Stopped', 'asia-east1', v_acc_id, 'https://console.cloud.google.com/kubernetes/clusters/gke-dev-asia', now() - INTERVAL '8 hours', now() - INTERVAL '20 days', 20, 'agarcia@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('gke-%s-node-%s', 'dev-asia', j); + v_ins_type := gcp_types[1 + floor(random()*array_length(gcp_types,1))::INT]; + v_az := 'asia-east1-' || chr(97 + (j % 2)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '8 hours', now() - INTERVAL '20 days', 20) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'agarcia@redhat.com', v_ins_pk), ('Partner', 'Infosys', v_ins_pk), ('Team', 'DevOps', v_ins_pk), ('Environment', 'development', v_ins_pk); + cost_base := 1.00 + random() * 0.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.10, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + -- ======================================================================== + -- Account 5: rh-services-azure (Azure) — 3 clusters + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = 'azure-sub-01'; + v_provider := 'Azure'; + + -- Cluster 17: Azure production, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('aro-prod-westeu', 'aro-prod-westeu-wxy34', 'wxy34', v_provider, 'Running', 'westeurope', v_acc_id, 'https://portal.azure.com/aro-prod-westeu', now() - INTERVAL '1 hour', now() - INTERVAL '110 days', 110, 'twilson@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..5 LOOP + v_ins_name := format('aro-%s-node-%s', 'prod-westeu', j); + v_ins_type := az_types[1 + floor(random()*array_length(az_types,1))::INT]; + v_az := 'westeurope-' || (j % 3 + 1); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '110 days', 110) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'twilson@redhat.com', v_ins_pk), ('Partner', 'Accenture', v_ins_pk), ('Team', 'Security', v_ins_pk), ('Environment', 'production', v_ins_pk); + cost_base := 3.80 + random() * 2.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.60, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 18: Azure staging, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('aro-staging-north', 'aro-staging-north-zab56', 'zab56', v_provider, 'Running', 'northeurope', v_acc_id, 'https://portal.azure.com/aro-staging-north', now() - INTERVAL '2 hours', now() - INTERVAL '40 days', 40, 'mchen@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('aro-%s-node-%s', 'staging-north', j); + v_ins_type := az_types[1 + floor(random()*array_length(az_types,1))::INT]; + v_az := 'northeurope-' || (j % 3 + 1); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '2 hours', now() - INTERVAL '40 days', 40) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'mchen@redhat.com', v_ins_pk), ('Partner', 'IBM', v_ins_pk), ('Team', 'Middleware', v_ins_pk), ('Environment', 'staging', v_ins_pk); + cost_base := 2.50 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.30, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 19: Azure APAC dev, stopped + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('aro-dev-seasia', 'aro-dev-seasia-cde78', 'cde78', v_provider, 'Stopped', 'southeastasia', v_acc_id, 'https://portal.azure.com/aro-dev-seasia', now() - INTERVAL '10 hours', now() - INTERVAL '10 days', 10, 'jsmith@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('aro-%s-node-%s', 'dev-seasia', j); + v_ins_type := az_types[1 + floor(random()*array_length(az_types,1))::INT]; + v_az := 'southeastasia-' || (j % 2 + 1); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '10 hours', now() - INTERVAL '10 days', 10) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'jsmith@redhat.com', v_ins_pk), ('Partner', 'Deloitte', v_ins_pk), ('Team', 'SRE', v_ins_pk), ('Environment', 'development', v_ins_pk); + cost_base := 1.20 + random() * 0.6; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.10, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; --- Función auxiliar para obtener un STATUS aleatorio --- (usamos un SELECT al vuelo dentro del DO; no se define nada permanente) --- Generación principal +END +$$; + +-- ============================================================================ +-- Scheduled actions (with targets) +-- ============================================================================ DO $$ DECLARE - -- cuentas - r_acc RECORD; - - -- clusters - n_clusters INT; - r_clu_id BIGINT; - r_clu_name TEXT; - r_clu_infra TEXT; - r_region TEXT; - - -- instancias - n_insts INT; - r_ins_id BIGINT; - r_ins_name TEXT; - r_az TEXT; - st STATUS; - - -- fechas/importe expenses - d DATE; - amt NUMERIC(12,2); - base_cost NUMERIC(12,2); - day_offset INT; + v_target_id BIGINT; + v_cluster_id BIGINT; + v_operation ACTION_OPERATION; BEGIN - -- Itera por cada cuenta creada - FOR r_acc IN - SELECT id, provider FROM accounts ORDER BY id - LOOP - -- nº de clusters aleatorio por cuenta: 4–10 - n_clusters := 4 + floor(random() * 7)::INT; - - FOR i IN 1..n_clusters LOOP - -- Región “plausible” por proveedor - IF r_acc.provider = 'AWS'::cloud_provider THEN - r_region := ('us-east-' || (1 + floor(random()*2))::INT); - ELSIF r_acc.provider = 'GCP'::cloud_provider THEN - r_region := ('europe-west' || (1 + floor(random()*2))::INT); - ELSE - r_region := ('westeurope'); - END IF; - - r_clu_name := format('%s-cluster-%s', lower(r_acc.provider::TEXT), i); - r_clu_infra := format('%s-infra-%s', lower(r_acc.provider::TEXT), i); - - INSERT INTO clusters ( - cluster_name, cluster_id, infra_id, provider, status, region, account_id, - console_link, last_scan_ts, created_at, age, owner - ) - VALUES ( - r_clu_name, - r_clu_name || r_clu_infra, - r_clu_infra, - r_acc.provider, - (ARRAY['Running','Stopped','Unknown']::status[])[1 + floor(random()*3)::INT], - r_region, - r_acc.id, - 'https://console.example.local', - now() - make_interval(days => (1 + floor(random()*5))::INT), - now() - make_interval(days => (10 + floor(random()*90))::INT), - 10 + floor(random()*90)::INT, - 'team@example.com' - ) - RETURNING id INTO r_clu_id; - - -- nº de instancias por cluster: 6–12 - n_insts := 6 + floor(random() * 7)::INT; - - FOR j IN 1..n_insts LOOP - -- Status aleatorio (más prob. de Running) - st := (ARRAY['Running','Running','Running','Stopped','Unknown']::status[])[1 + floor(random()*5)::INT]; - - -- AZ derivada de la región - r_az := r_region || chr(97 + floor(random()*3)::INT); -- a/b/c - - r_ins_name := format('%s-%s-%s', r_clu_name, r_az, j); - - INSERT INTO instances ( - instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, - status, last_scan_ts, created_at, age - ) - VALUES ( - r_ins_name, - 'id-' || r_ins_name, - r_clu_id, - r_acc.provider, - (ARRAY['t3.micro','t3.medium','m6g.large','c6i.large']::TEXT[])[1 + floor(random()*4)::INT], - r_az, - st, - now() - make_interval(days => (0 + floor(random()*3))::INT), - now() - make_interval(days => (20 + floor(random()*200))::INT), - 20 + floor(random()*200)::INT - ) - RETURNING id INTO r_ins_id; - - -- Tags fijas por instancia - INSERT INTO tags(key, value, instance_id) - VALUES - ('name', r_ins_name, r_ins_id), - ('owner', 'john.doe@example.com', r_ins_id); - - -- Expenses: 60 días hacia atrás (>= 40) - base_cost := (ARRAY[0.75, 1.25, 1.80, 2.40]::NUMERIC[])[1 + floor(random()*4)::INT]; - FOR day_offset IN 0..59 LOOP - d := (current_date - day_offset); - -- Variación diaria suave - amt := round( GREATEST(0.10, base_cost * (0.8 + random()*0.6))::NUMERIC, 2); - INSERT INTO expenses(instance_id, date, amount) - VALUES (r_ins_id, d, amt); - END LOOP; - END LOOP; - END LOOP; + -- 4 scheduled actions on random clusters + FOR g IN 1..4 LOOP + SELECT id INTO v_cluster_id FROM clusters WHERE status != 'Terminated' ORDER BY random() LIMIT 1; + v_operation := (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION; + + INSERT INTO targets (target_type, select_all) VALUES ('Cluster', false) RETURNING id INTO v_target_id; + INSERT INTO target_clusters (target_id, cluster_id) VALUES (v_target_id, v_cluster_id); + INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled, requester, description) + VALUES ('scheduled_action', now() + (g * interval '1 day'), NULL, v_operation, v_target_id, 'Pending', true, 'scheduler@clusteriq', format('Scheduled %s for cluster', v_operation)); END LOOP; END $$; --- Generating scheduled actions -INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled) -SELECT - 'scheduled_action'::ACTION_TYPE, - now() + (g * interval '1 day') AS time, - NULL, - (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION, - c.id AS target, - 'Pending'::ACTION_STATUS, - (random() > 0.5) AS enabled -FROM generate_series(1,3) g -JOIN LATERAL ( - SELECT id FROM clusters ORDER BY random() LIMIT 1 -) c ON true; - --- Generating cron-based action -INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled) -SELECT - 'cron_action'::ACTION_TYPE, - NULL, - (ARRAY['0 6 * * *', '0 0 * * 0', '*/30 * * * *'])[g] AS cron_exp, - (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION, - c.id AS target, - 'Pending'::ACTION_STATUS, - (random() > 0.5) AS enabled -FROM generate_series(1,3) g -JOIN LATERAL ( - SELECT id FROM clusters ORDER BY random() LIMIT 1 -) c ON true; - --- Generating events -INSERT INTO events ( - event_timestamp, triggered_by, action, resource_id, resource_type, result, description, severity -) -SELECT - now() - (random() * interval '10 days') AS event_timestamp, - (ARRAY['scanner','agent','api','scheduler','user'])[1 + floor(random()*5)], - (ARRAY['scan','PowerOn','PowerOff','RestartCluster','Terminate'])[1 + floor(random()*5)], - (1 + floor(random()*20))::int AS resource_id, - CASE WHEN random() < 0.6 THEN 'Instance'::RESOURCE_TYPE ELSE 'Cluster'::RESOURCE_TYPE END AS resource_type, - (ARRAY['Pending','Running','Failed','Success','Unknown'])[1 + floor(random()*5)]::ACTION_STATUS AS result, - 'auto-generated dev event' AS description, - (ARRAY['info','warning','error','notice'])[1 + floor(random()*4)] AS severity -FROM generate_series(1,15); +-- ============================================================================ +-- Cron-based actions (with targets) +-- ============================================================================ +DO $$ +DECLARE + v_target_id BIGINT; + v_cluster_id BIGINT; + v_operation ACTION_OPERATION; + v_cron TEXT; +BEGIN + FOR g IN 1..3 LOOP + SELECT id INTO v_cluster_id FROM clusters WHERE status != 'Terminated' ORDER BY random() LIMIT 1; + v_operation := (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION; + v_cron := (ARRAY['0 8 * * 1-5', '0 20 * * 1-5', '0 6 * * *'])[g]; + + INSERT INTO targets (target_type, select_all) VALUES ('Cluster', false) RETURNING id INTO v_target_id; + INSERT INTO target_clusters (target_id, cluster_id) VALUES (v_target_id, v_cluster_id); + INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled, requester, description) + VALUES ('cron_action', NULL, v_cron, v_operation, v_target_id, 'Pending', true, 'scheduler@clusteriq', format('Recurring %s (%s)', v_operation, v_cron)); + END LOOP; +END +$$; +-- ============================================================================ +-- Events: realistic audit trail +-- ============================================================================ +DO $$ +DECLARE + v_cluster RECORD; + v_account RECORD; +BEGIN + -- Scan events (one per account, recent) + FOR v_account IN SELECT id, account_name FROM accounts LOOP + INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) + VALUES (now() - (random() * interval '2 hours'), 'scanner@clusteriq', 'Scan', v_account.id, 'Account', 'Success', format('Inventory scan completed for %s', v_account.account_name), 'info'); + END LOOP; -COMMIT; + -- PowerOn/PowerOff events on clusters + FOR v_cluster IN SELECT id, cluster_name, status FROM clusters WHERE status != 'Terminated' ORDER BY random() LIMIT 8 LOOP + INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) + VALUES ( + now() - (random() * interval '5 days'), + (ARRAY['jsmith@redhat.com', 'agarcia@redhat.com', 'scheduler@clusteriq', 'agent@clusteriq'])[1 + floor(random()*4)::INT], + CASE WHEN v_cluster.status = 'Running' THEN 'PowerOn' ELSE 'PowerOff' END, + v_cluster.id, + 'Cluster', + 'Success', + format('%s cluster %s', CASE WHEN v_cluster.status = 'Running' THEN 'Started' ELSE 'Stopped' END, v_cluster.cluster_name), + 'info' + ); + END LOOP; --- Checks (opcionales) --- SELECT provider, COUNT(*) clusters FROM clusters JOIN accounts a ON a.id = clusters.account_id GROUP BY provider; --- SELECT COUNT(*) AS total_instances FROM instances; --- SELECT COUNT(*) AS total_expenses FROM expenses; + -- A few failed events + INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) + SELECT + now() - (random() * interval '7 days'), + 'agent@clusteriq', + 'PowerOff', + (SELECT id FROM clusters WHERE status = 'Running' ORDER BY random() LIMIT 1), + 'Cluster', + 'Failed', + 'Timeout waiting for instances to stop', + 'error' + FROM generate_series(1, 2); + + -- Warning events + INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) + VALUES + (now() - interval '1 day', 'scanner@clusteriq', 'Scan', (SELECT id FROM accounts ORDER BY random() LIMIT 1), 'Account', 'Success', 'Scan completed with warnings: 2 instances unreachable', 'warning'), + (now() - interval '3 days', 'scheduler@clusteriq', 'PowerOn', (SELECT id FROM clusters WHERE status = 'Stopped' ORDER BY random() LIMIT 1), 'Cluster', 'Success', 'Scheduled power-on executed', 'info'); +END +$$; --- SELECT * FROM account_cluster_count ORDER BY account_id; --- SELECT * FROM account_costs ORDER BY id; --- SELECT * FROM account_full_view ORDER BY id; +-- ============================================================================ +-- Refresh materialized views +-- ============================================================================ +REFRESH MATERIALIZED VIEW m_accounts_full_view; +REFRESH MATERIALIZED VIEW m_clusters_full_view; +REFRESH MATERIALIZED VIEW m_instances_full_view; +REFRESH MATERIALIZED VIEW m_instances_full_view_with_tags; +COMMIT; diff --git a/deployments/compose/compose-devel.yaml b/deployments/compose/compose-devel.yaml index 905b3e73..e3b62fd2 100644 --- a/deployments/compose/compose-devel.yaml +++ b/deployments/compose/compose-devel.yaml @@ -10,8 +10,8 @@ services: container_name: api restart: "always" depends_on: - pgsql: - condition: service_started + init-pgsql: + condition: service_completed_successfully ports: - 8081:8080 environment: @@ -30,16 +30,24 @@ services: scanner: image: quay.io/ecosystem-appeng/cluster-iq-scanner:latest container_name: scanner - restart: "no" + restart: "always" depends_on: api: condition: service_healthy restart: true + ports: + - 50052:50052 environment: CIQ_API_URL: "http://api:8080/api/v1" + CIQ_SCANNER_LISTEN_URL: "0.0.0.0:50052" CIQ_CREDS_FILE: "/credentials" CIQ_SKIP_NO_OPENSHIFT_INSTANCES: "true" CIQ_LOG_LEVEL: "DEBUG" + healthcheck: + test: ["CMD-SHELL", "timeout 2 bash -lc 'echo > /dev/tcp/127.0.0.1/50052'"] + interval: 10s + timeout: 5s + retries: 5 volumes: - ../../secrets/credentials:/credentials:ro,Z networks: @@ -57,6 +65,7 @@ services: CIQ_API_URL: "http://api:8080/api/v1" CIQ_DB_URL: "postgresql://user:password@pgsql:5432/clusteriq?sslmode=disable" CIQ_AGENT_INSTANT_SERVICE_LISTEN_URL: "0.0.0.0:50051" + CIQ_SCANNER_URL: "scanner:50052" CIQ_CREDS_FILE: "/credentials" CIQ_LOG_LEVEL: "DEBUG" CIQ_AGENT_POLLING_SECONDS_INTERVAL: 10 # Seconds @@ -72,6 +81,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 @@ -113,6 +142,9 @@ services: echo "Creating PG_CRON tasks" psql postgresql://postgres:admin@pgsql:5432/postgres < /cron.sql && { echo "Ok"; } || { echo "Cron Tasks creation Failed"; exit 1; } + echo "Configuring purge retention (${CIQ_MAX_DATA_AGE:-365} days)" + psql postgresql://postgres:admin@pgsql:5432/postgres -c "UPDATE cron.job SET command = \$\$SELECT purge_expired_clusters(${CIQ_MAX_DATA_AGE:-365})\$\$ WHERE jobname = \$\$purge_expired_clusters\$\$;" + if [[ $CIQ_DB_PRELOAD_DATA == "true" ]]; then echo "Initializing DB with fake data" psql postgresql://user:password@pgsql:5432/clusteriq < /load_example_data.sql && { echo "Ok"; } || { echo "Initialization Failed"; exit 1; } @@ -121,6 +153,7 @@ services: echo "Done!" ' environment: + CIQ_MAX_DATA_AGE: "365" CIQ_DB_PRELOAD_DATA: "true" volumes: - ./../../db/sql/init.sql:/init.sql:ro,Z diff --git a/deployments/compose/compose-gh.yaml b/deployments/compose/compose-gh.yaml index 5e1139cd..040a15b1 100644 --- a/deployments/compose/compose-gh.yaml +++ b/deployments/compose/compose-gh.yaml @@ -7,6 +7,9 @@ services: image: quay.io/ecosystem-appeng/cluster-iq-api:latest container_name: api restart: always + depends_on: + init-pgsql: + condition: service_completed_successfully ports: - 8081:8080 environment: @@ -28,6 +31,11 @@ services: POSTGRESQL_PASSWORD: "password" POSTGRESQL_DATABASE: "clusteriq" POSTGRESQL_ADMIN_PASSWORD: "admin" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 networks: - cluster_iq @@ -35,6 +43,9 @@ services: image: quay.io/fedora/postgresql-16:16 container_name: init-pgsql restart: "no" + depends_on: + pgsql: + condition: service_healthy command: | sh -c 'while true; do psql postgresql://user:password@pgsql:5432/clusteriq -c "SELECT true" && break || sleep 2; diff --git a/deployments/containerfiles/Containerfile-scanner b/deployments/containerfiles/Containerfile-scanner index 2511b17c..52bf3f72 100644 --- a/deployments/containerfiles/Containerfile-scanner +++ b/deployments/containerfiles/Containerfile-scanner @@ -7,18 +7,43 @@ FROM golang:1.25.7 AS builder ARG VERSION ARG COMMIT +# Versions for Protobuf and gRPC +ENV PROTOC_VERSION=29.3 +ENV PROTOC_GEN_GO_VERSION=v1.36.2 +ENV PROTOC_GEN_GO_GRPC_VERSION=v1.5.1 + +# Installing ProtoBuf 29.3 +RUN apt-get update && apt-get install -y --no-install-recommends \ + unzip \ + curl && \ + curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip && \ + unzip protoc-${PROTOC_VERSION}-linux-x86_64.zip -d /usr/local && \ + rm -f protoc-${PROTOC_VERSION}-linux-x86_64.zip && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# Installing protoc-gen-go and protoc-gen-go-grpc +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@${PROTOC_GEN_GO_VERSION} && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@${PROTOC_GEN_GO_GRPC_VERSION} + +# Adding /go/bin to path so the 'protoc-gen-go' can be found +ENV PATH="${PATH}:/go/bin" + # Code copy WORKDIR /app COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o cluster-iq-scanner -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT}" ./cmd/scanner/scanner.go +# gRPC code generation +RUN mkdir -p ./generated && \ + protoc --go_out=./generated --go-grpc_out=./generated ./cmd/scanner/proto/scanner.proto +# Scanner building +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o cluster-iq-scanner -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT}" ./cmd/scanner ## Run #################### FROM registry.access.redhat.com/ubi8/ubi-micro:8.10-15 # Labels -LABEL version="v0.5" +LABEL version="v0.6" LABEL description="ClusterIQ cloud provider Scanner" # Binary @@ -29,6 +54,9 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifi # Default config ENV CIQ_API_URL: "http://api:8080/api/v1" ENV CIQ_CREDS_FILE: "/credentials" +ENV CIQ_SCANNER_LISTEN_URL: "0.0.0.0:50052" ENV CIQ_LOG_LEVEL: "DEBUG" +EXPOSE 50052 + ENTRYPOINT ["/cluster-iq-scanner"] 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/deployments/helm/cluster-iq/templates/agent/configmap.yaml b/deployments/helm/cluster-iq/templates/agent/configmap.yaml index 3fd9d454..42e6f87e 100644 --- a/deployments/helm/cluster-iq/templates/agent/configmap.yaml +++ b/deployments/helm/cluster-iq/templates/agent/configmap.yaml @@ -8,6 +8,7 @@ metadata: data: CIQ_AGENT_INSTANT_SERVICE_LISTEN_URL: 0.0.0.0:{{ .Values.agent.service.port }} CIQ_API_URL: api:{{ .Values.api.service.port }} + CIQ_SCANNER_URL: 'scanner.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.scanner.service.port }}' CIQ_CREDS_FILE: /credentials/credentials CIQ_LOG_LEVEL: {{ .Values.agent.logLevel }} CIQ_AGENT_POLLING_SECONDS_INTERVAL: "{{ .Values.agent.pollingInterval }}" diff --git a/deployments/helm/cluster-iq/templates/database/configmap-init.yaml b/deployments/helm/cluster-iq/templates/database/configmap-init.yaml index e8282432..53a0f3ba 100644 --- a/deployments/helm/cluster-iq/templates/database/configmap-init.yaml +++ b/deployments/helm/cluster-iq/templates/database/configmap-init.yaml @@ -43,6 +43,14 @@ data: 'clusteriq' ); + -- pg_cron task for purging expired terminated clusters daily at midnight + SELECT cron.schedule_in_database( + 'purge_expired_clusters', + '0 0 * * *', + $$SELECT purge_expired_clusters();$$, + 'clusteriq' + ); + -- Function to check easier how the pg_cron tasks went CREATE OR REPLACE FUNCTION pg_cron_history(p_limit int DEFAULT 20) RETURNS TABLE( @@ -738,3 +746,27 @@ data: REFRESH MATERIALIZED VIEW m_instances_full_view_with_tags; END; $$ LANGUAGE plpgsql; + + -- Purges terminated clusters whose last_scan_ts is older than retention_days. + -- Cascading FKs handle deletion of instances, tags, expenses, targets, schedules, and action_runs. + -- Triggers handle deletion of associated events. + CREATE OR REPLACE FUNCTION purge_expired_clusters(retention_days INTEGER DEFAULT 365) + RETURNS INTEGER AS $$ + DECLARE + deleted_count INTEGER; + BEGIN + WITH deleted AS ( + DELETE FROM clusters + WHERE status = 'Terminated' + AND last_scan_ts < NOW() - (retention_days || ' days')::INTERVAL + RETURNING id + ) + SELECT COUNT(*) INTO deleted_count FROM deleted; + + IF deleted_count > 0 THEN + PERFORM refresh_materialized_views(); + END IF; + + RETURN deleted_count; + END; + $$ LANGUAGE plpgsql; diff --git a/deployments/helm/cluster-iq/templates/database/job.yaml b/deployments/helm/cluster-iq/templates/database/job.yaml index 0d478b3a..35f168d9 100644 --- a/deployments/helm/cluster-iq/templates/database/job.yaml +++ b/deployments/helm/cluster-iq/templates/database/job.yaml @@ -39,6 +39,9 @@ spec: - name: cron mountPath: /var/lib/pgsql/cron.sql subPath: cron.sql + env: + - name: CIQ_MAX_DATA_AGE + value: "{{ .Values.database.maxDataAge }}" envFrom: - secretRef: name: postgresql @@ -56,6 +59,9 @@ spec: echo "Creating PG_CRON tasks" psql postgresql://postgres:$POSTGRESQL_ADMIN_PASSWORD@{{ $dbHost }}:{{ .Values.database.service.port }}/postgres < /var/lib/pgsql/cron.sql && { echo "Ok"; } || { echo "Cron Tasks creation Failed"; exit 1; } + echo "Configuring purge retention (${CIQ_MAX_DATA_AGE:-365} days)" + psql postgresql://postgres:$POSTGRESQL_ADMIN_PASSWORD@{{ $dbHost }}:{{ .Values.database.service.port }}/postgres -c "UPDATE cron.job SET command = \$\$SELECT purge_expired_clusters(${CIQ_MAX_DATA_AGE:-365})\$\$ WHERE jobname = \$\$purge_expired_clusters\$\$;" + echo "Done!" restartPolicy: OnFailure {{- end }} diff --git a/deployments/helm/cluster-iq/templates/scanner/configmap.yaml b/deployments/helm/cluster-iq/templates/scanner/configmap.yaml index b666a74a..7c1a97e1 100644 --- a/deployments/helm/cluster-iq/templates/scanner/configmap.yaml +++ b/deployments/helm/cluster-iq/templates/scanner/configmap.yaml @@ -4,9 +4,10 @@ metadata: name: scanner labels: {{- include "cluster-iq.labels" . | nindent 4 }} - {{- include "cluster-iq.componentLabels" "api" | nindent 4 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} data: CIQ_API_URL: 'http://api.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.api.service.port }}/api/v1' + CIQ_SCANNER_LISTEN_URL: '0.0.0.0:{{ .Values.scanner.service.port }}' CIQ_CREDS_FILE: /credentials/credentials CIQ_LOG_LEVEL: {{ .Values.scanner.logLevel }} CIQ_SKIP_NO_OPENSHIFT_INSTANCES: "{{ .Values.scanner.skipNoOpenshiftInstances }}" diff --git a/deployments/helm/cluster-iq/templates/scanner/cronjob.yaml b/deployments/helm/cluster-iq/templates/scanner/cronjob.yaml deleted file mode 100644 index e1d6a635..00000000 --- a/deployments/helm/cluster-iq/templates/scanner/cronjob.yaml +++ /dev/null @@ -1,45 +0,0 @@ -kind: CronJob -apiVersion: batch/v1 -metadata: - name: scanner - labels: - {{- include "cluster-iq.labels" . | nindent 4 }} - {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} -spec: - schedule: 0 0 * * * - concurrencyPolicy: Allow - suspend: false - successfulJobsHistoryLimit: 1 - failedJobsHistoryLimit: 5 - jobTemplate: - spec: - template: - spec: - serviceAccountName: {{ include "cluster-iq.scannerServiceAccountName" . }} - volumes: - {{- if .Values.vault.enabled }} - - name: credentials - csi: - driver: secrets-store.csi.k8s.io - readOnly: true - volumeAttributes: - secretProviderClass: "cluster-iq-credentials-scanner" - {{- else }} - - name: credentials - secret: - secretName: credentials - {{- end }} - containers: - - name: scanner - image: "{{ .Values.scanner.image.repository }}:{{ .Values.scanner.image.tag | default .Chart.AppVersion }}" - envFrom: - - configMapRef: - name: scanner - volumeMounts: - - name: credentials - readOnly: true - mountPath: /credentials - imagePullPolicy: IfNotPresent - resources: - {{- toYaml .Values.scanner.resources | nindent 16 }} - restartPolicy: OnFailure diff --git a/deployments/helm/cluster-iq/templates/scanner/deployment.yaml b/deployments/helm/cluster-iq/templates/scanner/deployment.yaml new file mode 100644 index 00000000..0cf7b205 --- /dev/null +++ b/deployments/helm/cluster-iq/templates/scanner/deployment.yaml @@ -0,0 +1,64 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: scanner + labels: + {{- include "cluster-iq.labels" . | nindent 4 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + {{- include "cluster-iq.selectorLabels" . | nindent 6 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 6 }} + template: + metadata: + labels: + {{- include "cluster-iq.labels" . | nindent 8 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 8 }} + spec: + serviceAccountName: {{ include "cluster-iq.scannerServiceAccountName" . }} + containers: + - name: scanner + image: "{{ .Values.scanner.image.repository }}:{{ .Values.scanner.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.scanner.image.pullPolicy }} + envFrom: + - configMapRef: + name: scanner + ports: + - name: {{ .Values.scanner.service.name }} + containerPort: {{ .Values.scanner.service.port }} + protocol: TCP + startupProbe: + tcpSocket: + port: {{ .Values.scanner.service.port }} + {{- toYaml .Values.scanner.startupProbe | nindent 12 }} + readinessProbe: + tcpSocket: + port: {{ .Values.scanner.service.port }} + {{- toYaml .Values.scanner.readinessProbe | nindent 12 }} + livenessProbe: + tcpSocket: + port: {{ .Values.scanner.service.port }} + {{- toYaml .Values.scanner.livenessProbe | nindent 12 }} + resources: + {{- toYaml .Values.scanner.resources | nindent 12 }} + volumeMounts: + - name: credentials + readOnly: true + mountPath: /credentials + volumes: + {{- if .Values.vault.enabled }} + - name: credentials + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: "cluster-iq-credentials-scanner" + {{- else }} + - name: credentials + secret: + secretName: credentials + optional: false + {{- end }} diff --git a/deployments/helm/cluster-iq/templates/scanner/service.yaml b/deployments/helm/cluster-iq/templates/scanner/service.yaml new file mode 100644 index 00000000..406841d0 --- /dev/null +++ b/deployments/helm/cluster-iq/templates/scanner/service.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: scanner + labels: + {{- include "cluster-iq.labels" . | nindent 4 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} +spec: + type: {{ .Values.scanner.service.type }} + ports: + - port: {{ .Values.scanner.service.port }} + targetPort: {{ .Values.scanner.service.name }} + protocol: TCP + name: {{ .Values.scanner.service.name }} + selector: + {{- include "cluster-iq.selectorLabels" . | nindent 4 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} diff --git a/deployments/helm/cluster-iq/values.yaml b/deployments/helm/cluster-iq/values.yaml index f25825e9..be3a4750 100644 --- a/deployments/helm/cluster-iq/values.yaml +++ b/deployments/helm/cluster-iq/values.yaml @@ -255,6 +255,11 @@ scanner: # Overrides the image tag whose default is the chart appVersion. tag: "latest" + service: + type: ClusterIP + port: 50052 + name: scanner-grpc + resources: requests: memory: "128Mi" @@ -262,6 +267,26 @@ scanner: skipNoOpenshiftInstances: true + startupProbe: + initialDelaySeconds: 0 + periodSeconds: 1 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 5 + livenessProbe: + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + readinessProbe: + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 3 + #This section builds out the service account serviceAccount: # Specifies whether a service account should be created @@ -387,6 +412,9 @@ database: # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ replicaCount: 1 + # Maximum age in days for terminated clusters before they are purged from the database + maxDataAge: "365" + # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ image: repository: quay.io/ecosystem-appeng/cluster-iq-pgsql 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/generated/scanner/scanner.pb.go b/generated/scanner/scanner.pb.go new file mode 100644 index 00000000..44d135d3 --- /dev/null +++ b/generated/scanner/scanner.pb.go @@ -0,0 +1,377 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.31.0 +// protoc v3.19.6 +// source: cmd/scanner/proto/scanner.proto + +package scanner + +import ( + reflect "reflect" + sync "sync" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Message for requesting a scan +type ScanRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RunId int64 `protobuf:"varint,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + AccountIds []string `protobuf:"bytes,2,rep,name=account_ids,json=accountIds,proto3" json:"account_ids,omitempty"` + SelectAll bool `protobuf:"varint,3,opt,name=select_all,json=selectAll,proto3" json:"select_all,omitempty"` +} + +func (x *ScanRequest) Reset() { + *x = ScanRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanRequest) ProtoMessage() {} + +func (x *ScanRequest) ProtoReflect() protoreflect.Message { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanRequest.ProtoReflect.Descriptor instead. +func (*ScanRequest) Descriptor() ([]byte, []int) { + return file_cmd_scanner_proto_scanner_proto_rawDescGZIP(), []int{0} +} + +func (x *ScanRequest) GetRunId() int64 { + if x != nil { + return x.RunId + } + return 0 +} + +func (x *ScanRequest) GetAccountIds() []string { + if x != nil { + return x.AccountIds + } + return nil +} + +func (x *ScanRequest) GetSelectAll() bool { + if x != nil { + return x.SelectAll + } + return false +} + +// Message for answering to ScanRequests +type ScanResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error int32 `protobuf:"varint,1,opt,name=error,proto3" json:"error,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + AccountsScanned int32 `protobuf:"varint,3,opt,name=accounts_scanned,json=accountsScanned,proto3" json:"accounts_scanned,omitempty"` +} + +func (x *ScanResponse) Reset() { + *x = ScanResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanResponse) ProtoMessage() {} + +func (x *ScanResponse) ProtoReflect() protoreflect.Message { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanResponse.ProtoReflect.Descriptor instead. +func (*ScanResponse) Descriptor() ([]byte, []int) { + return file_cmd_scanner_proto_scanner_proto_rawDescGZIP(), []int{1} +} + +func (x *ScanResponse) GetError() int32 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *ScanResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ScanResponse) GetAccountsScanned() int32 { + if x != nil { + return x.AccountsScanned + } + return 0 +} + +// Message for health check requests +type HealthRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *HealthRequest) Reset() { + *x = HealthRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthRequest) ProtoMessage() {} + +func (x *HealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_cmd_scanner_proto_scanner_proto_rawDescGZIP(), []int{2} +} + +// Message for health check responses +type HealthResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ready bool `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"` +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_cmd_scanner_proto_scanner_proto_rawDescGZIP(), []int{3} +} + +func (x *HealthResponse) GetReady() bool { + if x != nil { + return x.Ready + } + return false +} + +var File_cmd_scanner_proto_scanner_proto protoreflect.FileDescriptor + +var file_cmd_scanner_proto_scanner_proto_rawDesc = []byte{ + 0x0a, 0x1f, 0x63, 0x6d, 0x64, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x12, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x22, 0x64, 0x0a, 0x0b, 0x53, 0x63, + 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x72, 0x75, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x72, 0x75, 0x6e, 0x49, 0x64, + 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, + 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x5f, 0x61, 0x6c, 0x6c, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x41, 0x6c, 0x6c, + 0x22, 0x69, 0x0a, 0x0c, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x12, 0x29, 0x0a, 0x10, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x5f, 0x73, 0x63, 0x61, + 0x6e, 0x6e, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x73, 0x53, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x22, 0x0f, 0x0a, 0x0d, 0x48, + 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x26, 0x0a, 0x0e, + 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, + 0x0a, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x72, + 0x65, 0x61, 0x64, 0x79, 0x32, 0x80, 0x01, 0x0a, 0x0e, 0x53, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x33, 0x0a, 0x04, 0x53, 0x63, 0x61, 0x6e, 0x12, + 0x14, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2e, + 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, 0x0a, 0x06, + 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x16, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, + 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, + 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x2f, 0x73, 0x63, 0x61, + 0x6e, 0x6e, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_cmd_scanner_proto_scanner_proto_rawDescOnce sync.Once + file_cmd_scanner_proto_scanner_proto_rawDescData = file_cmd_scanner_proto_scanner_proto_rawDesc +) + +func file_cmd_scanner_proto_scanner_proto_rawDescGZIP() []byte { + file_cmd_scanner_proto_scanner_proto_rawDescOnce.Do(func() { + file_cmd_scanner_proto_scanner_proto_rawDescData = protoimpl.X.CompressGZIP(file_cmd_scanner_proto_scanner_proto_rawDescData) + }) + return file_cmd_scanner_proto_scanner_proto_rawDescData +} + +var file_cmd_scanner_proto_scanner_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_cmd_scanner_proto_scanner_proto_goTypes = []interface{}{ + (*ScanRequest)(nil), // 0: scanner.ScanRequest + (*ScanResponse)(nil), // 1: scanner.ScanResponse + (*HealthRequest)(nil), // 2: scanner.HealthRequest + (*HealthResponse)(nil), // 3: scanner.HealthResponse +} +var file_cmd_scanner_proto_scanner_proto_depIdxs = []int32{ + 0, // 0: scanner.ScannerService.Scan:input_type -> scanner.ScanRequest + 2, // 1: scanner.ScannerService.Health:input_type -> scanner.HealthRequest + 1, // 2: scanner.ScannerService.Scan:output_type -> scanner.ScanResponse + 3, // 3: scanner.ScannerService.Health:output_type -> scanner.HealthResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_cmd_scanner_proto_scanner_proto_init() } +func file_cmd_scanner_proto_scanner_proto_init() { + if File_cmd_scanner_proto_scanner_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_cmd_scanner_proto_scanner_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cmd_scanner_proto_scanner_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cmd_scanner_proto_scanner_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HealthRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cmd_scanner_proto_scanner_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HealthResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_cmd_scanner_proto_scanner_proto_rawDesc, + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_cmd_scanner_proto_scanner_proto_goTypes, + DependencyIndexes: file_cmd_scanner_proto_scanner_proto_depIdxs, + MessageInfos: file_cmd_scanner_proto_scanner_proto_msgTypes, + }.Build() + File_cmd_scanner_proto_scanner_proto = out.File + file_cmd_scanner_proto_scanner_proto_rawDesc = nil + file_cmd_scanner_proto_scanner_proto_goTypes = nil + file_cmd_scanner_proto_scanner_proto_depIdxs = nil +} diff --git a/generated/scanner/scanner_grpc.pb.go b/generated/scanner/scanner_grpc.pb.go new file mode 100644 index 00000000..26e68926 --- /dev/null +++ b/generated/scanner/scanner_grpc.pb.go @@ -0,0 +1,149 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.19.6 +// source: cmd/scanner/proto/scanner.proto + +package scanner + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.62.0 or later. +const _ = grpc.SupportPackageIsVersion8 + +const ( + ScannerService_Scan_FullMethodName = "/scanner.ScannerService/Scan" + ScannerService_Health_FullMethodName = "/scanner.ScannerService/Health" +) + +// ScannerServiceClient is the client API for ScannerService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ScannerServiceClient interface { + Scan(ctx context.Context, in *ScanRequest, opts ...grpc.CallOption) (*ScanResponse, error) + Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) +} + +type scannerServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewScannerServiceClient(cc grpc.ClientConnInterface) ScannerServiceClient { + return &scannerServiceClient{cc} +} + +func (c *scannerServiceClient) Scan(ctx context.Context, in *ScanRequest, opts ...grpc.CallOption) (*ScanResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ScanResponse) + err := c.cc.Invoke(ctx, ScannerService_Scan_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *scannerServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthResponse) + err := c.cc.Invoke(ctx, ScannerService_Health_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ScannerServiceServer is the server API for ScannerService service. +// All implementations must embed UnimplementedScannerServiceServer +// for forward compatibility +type ScannerServiceServer interface { + Scan(context.Context, *ScanRequest) (*ScanResponse, error) + Health(context.Context, *HealthRequest) (*HealthResponse, error) + mustEmbedUnimplementedScannerServiceServer() +} + +// UnimplementedScannerServiceServer must be embedded to have forward compatible implementations. +type UnimplementedScannerServiceServer struct { +} + +func (UnimplementedScannerServiceServer) Scan(context.Context, *ScanRequest) (*ScanResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Scan not implemented") +} +func (UnimplementedScannerServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Health not implemented") +} +func (UnimplementedScannerServiceServer) mustEmbedUnimplementedScannerServiceServer() {} + +// UnsafeScannerServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ScannerServiceServer will +// result in compilation errors. +type UnsafeScannerServiceServer interface { + mustEmbedUnimplementedScannerServiceServer() +} + +func RegisterScannerServiceServer(s grpc.ServiceRegistrar, srv ScannerServiceServer) { + s.RegisterService(&ScannerService_ServiceDesc, srv) +} + +func _ScannerService_Scan_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ScanRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ScannerServiceServer).Scan(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ScannerService_Scan_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ScannerServiceServer).Scan(ctx, req.(*ScanRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ScannerService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HealthRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ScannerServiceServer).Health(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ScannerService_Health_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ScannerServiceServer).Health(ctx, req.(*HealthRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ScannerService_ServiceDesc is the grpc.ServiceDesc for ScannerService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ScannerService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "scanner.ScannerService", + HandlerType: (*ScannerServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Scan", + Handler: _ScannerService_Scan_Handler, + }, + { + MethodName: "Health", + Handler: _ScannerService_Health_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "cmd/scanner/proto/scanner.proto", +} diff --git a/internal/actions/action_operation.go b/internal/actions/action_operation.go index 83b6a8f3..d14afb80 100644 --- a/internal/actions/action_operation.go +++ b/internal/actions/action_operation.go @@ -10,6 +10,9 @@ const ( // PowerOffCluster represents an action to power off a cluster. PowerOff ActionOperation = "PowerOff" + + // Scan represents an action to scan cloud accounts for resource discovery. + Scan ActionOperation = "Scan" ) func NewPowerOnClusterAction(target ActionTarget, requester string, description *string) *InstantAction { diff --git a/internal/actions/action_target.go b/internal/actions/action_target.go index 74034491..2dbb699a 100644 --- a/internal/actions/action_target.go +++ b/internal/actions/action_target.go @@ -14,6 +14,15 @@ type ActionTarget struct { // Instances is a list of instance IDs associated with the target cluster. Instances []string `db:"instances"` + + // TargetType indicates the resource type being targeted (Account, Cluster, Instance). + TargetType string `db:"target_type"` + + // SelectAll when true, targets all resources of the given TargetType. + SelectAll bool `db:"select_all"` + + // TargetAccountIDs lists account IDs for scan-type actions. + TargetAccountIDs []string `db:"target_account_ids"` } // NewActionTarget creates and returns a new instance of ActionTarget. diff --git a/internal/api/handlers/account_handler.go b/internal/api/handlers/account_handler.go index bdccb103..2a3daeba 100644 --- a/internal/api/handlers/account_handler.go +++ b/internal/api/handlers/account_handler.go @@ -176,12 +176,10 @@ func (h *AccountHandler) GetAccountClustersByID(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "Account ID" -// @Success 200 {object} dto.InstanceListResponse +// @Success 200 {object} responsetypes.ListResponse[string] // @Failure 404 {object} responsetypes.GenericErrorResponse // @Failure 500 {object} responsetypes.GenericErrorResponse // @Router /accounts/{id}/expense_update [get] -// -// NOTE: Align the documented route with the actual router configuration. func (h *AccountHandler) GetExpensesUpdateInstances(c *gin.Context) { accountID := c.Param("id") @@ -201,8 +199,55 @@ func (h *AccountHandler) GetExpensesUpdateInstances(c *gin.Context) { return } - response := responsetypes.NewListResponse((&convert.ConverterImpl{}).ToInstanceDTOs(instances), len(instances)) + instanceIDs := make([]string, len(instances)) + for i, inst := range instances { + instanceIDs[i] = inst.InstanceID + } + + response := responsetypes.NewListResponse(instanceIDs, len(instanceIDs)) + c.JSON(http.StatusOK, response) +} + +// GetDailyCosts returns the daily cost evolution for an account (last 6 months). +// +// @Summary Get daily costs for an account +// @Description Return the aggregated daily costs for the specified account over the last 6 months. +// @Tags Accounts +// @Accept json +// @Produce json +// @Param id path string true "Account ID" +// @Success 200 {object} responsetypes.ListResponse[dto.DailyCostDTOResponse] +// @Failure 404 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /accounts/{id}/daily-costs [get] +func (h *AccountHandler) GetDailyCosts(c *gin.Context) { + accountID := c.Param("id") + + costs, err := h.service.GetDailyCosts(c.Request.Context(), accountID) + if err != nil { + h.logger.Error("error getting daily costs", zap.String("account_id", accountID), zap.Error(err)) + if errors.Is(err, repositories.ErrNotFound) { + c.JSON(http.StatusNotFound, responsetypes.GenericErrorResponse{ + Message: "Account not found", + }) + return + } + + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to retrieve daily costs", + }) + return + } + + items := make([]dto.DailyCostDTOResponse, len(costs)) + for i, cost := range costs { + items[i] = dto.DailyCostDTOResponse{ + Date: cost.Date.Format("2006-01-02"), + Amount: cost.Amount, + } + } + response := responsetypes.NewListResponse(items, len(items)) c.JSON(http.StatusOK, response) } diff --git a/internal/api/handlers/action_run_handler.go b/internal/api/handlers/action_run_handler.go new file mode 100644 index 00000000..6340f6b1 --- /dev/null +++ b/internal/api/handlers/action_run_handler.go @@ -0,0 +1,217 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + + responsetypes "github.com/RHEcosystemAppEng/cluster-iq/internal/api/response_types" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/convert" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/dto" + "github.com/RHEcosystemAppEng/cluster-iq/internal/repositories" + "github.com/RHEcosystemAppEng/cluster-iq/internal/services" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// ActionRunHandler wires HTTP endpoints to the ActionRunService. +type ActionRunHandler struct { + service services.ActionRunService + logger *zap.Logger +} + +// NewActionRunHandler returns an ActionRunHandler with its dependencies. +func NewActionRunHandler(service services.ActionRunService, logger *zap.Logger) *ActionRunHandler { + return &ActionRunHandler{ + service: service, + logger: logger, + } +} + +type actionRunFilterParams struct { + ScheduleID string `form:"schedule_id"` + Status string `form:"status"` +} + +func (f *actionRunFilterParams) toRepoFilters() map[string]interface{} { + filters := make(map[string]interface{}) + if f.ScheduleID != "" { + filters["schedule_id"] = f.ScheduleID + } + if f.Status != "" { + filters["status"] = f.Status + } + return filters +} + +type listActionRunsRequest struct { + dto.PaginationRequest + Filters actionRunFilterParams `form:"inline"` +} + +// List returns a paginated list of action runs. +// +// @Summary List action runs +// @Description Paginated retrieval of action execution history. +// @Tags ActionRuns +// @Accept json +// @Produce json +// @Param schedule_id query string false "Filter by schedule ID" +// @Param status query string false "Filter by status" +// @Param page query int false "Page number" default(1) +// @Param page_size query int false "Items per page" default(10) +// @Success 200 {object} dto.ActionRunListResponse +// @Failure 400 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /action-runs [get] +func (h *ActionRunHandler) List(c *gin.Context) { + var req listActionRunsRequest + + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, responsetypes.GenericErrorResponse{ + Message: "Invalid query parameters: " + err.Error(), + }) + return + } + + opts := models.ListOptions{ + PageSize: req.PageSize, + Offset: (req.Page - 1) * req.PageSize, + Filters: req.Filters.toRepoFilters(), + } + + runs, total, err := h.service.List(c.Request.Context(), opts) + if err != nil { + h.logger.Error("error listing action runs", zap.Error(err)) + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to list action runs", + }) + return + } + + response := responsetypes.NewListResponse((&convert.ConverterImpl{}).ToActionRunDTOs(runs), total) + + c.Header("X-Total-Count", strconv.Itoa(total)) + c.JSON(http.StatusOK, response) +} + +// Get returns an action run by ID. +// +// @Summary Get action run by ID +// @Description Return an action run record. +// @Tags ActionRuns +// @Accept json +// @Produce json +// @Param id path string true "Action run ID" +// @Success 200 {object} dto.ActionRunDTOResponse +// @Failure 404 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /action-runs/{id} [get] +func (h *ActionRunHandler) Get(c *gin.Context) { + runID := c.Param("id") + + run, err := h.service.Get(c.Request.Context(), runID) + if err != nil { + h.logger.Error("error getting action run", zap.String("run_id", runID), zap.Error(err)) + if errors.Is(err, repositories.ErrNotFound) { + c.JSON(http.StatusNotFound, responsetypes.GenericErrorResponse{ + Message: "Action run not found", + }) + return + } + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to retrieve action run", + }) + return + } + + c.JSON(http.StatusOK, (&convert.ConverterImpl{}).ToActionRunDTO(run)) +} + +// Create creates a new action run. +// +// @Summary Create action run +// @Description Create a new execution record for a scheduled action. +// @Tags ActionRuns +// @Accept json +// @Produce json +// @Param run body dto.ActionRunDTORequest true "Action run to create" +// @Success 201 {object} responsetypes.PostResponse +// @Failure 400 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /action-runs [post] +func (h *ActionRunHandler) Create(c *gin.Context) { + var req dto.ActionRunDTORequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, responsetypes.GenericErrorResponse{ + Message: "Invalid request body: " + err.Error(), + }) + return + } + + id, err := h.service.Create(c.Request.Context(), req.ScheduleID) + if err != nil { + h.logger.Error("error creating action run", zap.Error(err)) + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to create action run: " + err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, responsetypes.PostResponse{ + Count: 1, + Status: strconv.FormatInt(id, 10), + }) +} + +// Update updates an action run's status. +// +// @Summary Update action run +// @Description Update the status of an action run (called by scanner on completion). +// @Tags ActionRuns +// @Accept json +// @Produce json +// @Param id path string true "Action run ID" +// @Param run body dto.ActionRunDTORequest true "Updated status" +// @Success 200 {object} nil +// @Failure 400 {object} responsetypes.GenericErrorResponse +// @Failure 404 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /action-runs/{id} [patch] +func (h *ActionRunHandler) Update(c *gin.Context) { + runID := c.Param("id") + + var req dto.ActionRunDTORequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, responsetypes.GenericErrorResponse{ + Message: "Invalid request body: " + err.Error(), + }) + return + } + + if _, err := h.service.Get(c.Request.Context(), runID); err != nil { + h.logger.Error("error updating action run", zap.String("run_id", runID), zap.Error(err)) + if errors.Is(err, repositories.ErrNotFound) { + c.JSON(http.StatusNotFound, responsetypes.GenericErrorResponse{ + Message: "Action run not found", + }) + return + } + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to update action run", + }) + return + } + + if err := h.service.Update(c.Request.Context(), runID, req.Status, req.ErrorMsg); err != nil { + h.logger.Error("error updating action run", zap.String("run_id", runID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to update action run: " + err.Error(), + }) + return + } + + c.Status(http.StatusOK) +} diff --git a/internal/api/handlers/event_handler.go b/internal/api/handlers/event_handler.go index a964b4c3..1a24cd35 100644 --- a/internal/api/handlers/event_handler.go +++ b/internal/api/handlers/event_handler.go @@ -29,7 +29,7 @@ func NewEventHandler(service services.EventService, logger *zap.Logger) *EventHa // systemEventFilterParams defines the supported filter parameters type systemEventFilterParams struct { - TriggeredBy string `form:"triggered_by"` + Requester string `form:"requester"` ActionName string `form:"action"` ResourceType string `form:"resource_type"` Result string `form:"result"` @@ -39,8 +39,8 @@ type systemEventFilterParams struct { // toRepoFilters maps bound query params to repository filters. func (f *systemEventFilterParams) toRepoFilters() map[string]interface{} { filters := make(map[string]interface{}) - if f.TriggeredBy != "" { - filters["triggered_by"] = f.TriggeredBy + if f.Requester != "" { + filters["requester"] = f.Requester } if f.ActionName != "" { filters["action"] = f.ActionName @@ -71,7 +71,7 @@ type listSystemEventsRequest struct { // @Produce json // @Param page query int false "Page number" default(1) // @Param page_size query int false "Items per page" default(10) -// @Param triggered_by query string false "Triggered by" +// @Param requester query string false "Requester" // @Param action query string false "Action" // @Param resource_type query string false "Resource type" // @Param result query string false "Result" diff --git a/internal/clients/agent.go b/internal/clients/agent.go index f0d78593..8233fff8 100644 --- a/internal/clients/agent.go +++ b/internal/clients/agent.go @@ -64,6 +64,8 @@ func (a APIGRPCClient) ProcessInstantAction(ctx context.Context, action *actions return a.PowerOffCluster(ctx, action) case actions.PowerOn: return a.PowerOnCluster(ctx, action) + case actions.Scan: + return fmt.Errorf("scan operations are handled by the scanner service, not the agent") default: return fmt.Errorf("received InstantAction with unknown Operation") } diff --git a/internal/clients/scanner.go b/internal/clients/scanner.go new file mode 100644 index 00000000..56f8e244 --- /dev/null +++ b/internal/clients/scanner.go @@ -0,0 +1,64 @@ +package clients + +import ( + "context" + + pb "github.com/RHEcosystemAppEng/cluster-iq/generated/scanner" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// ScannerGRPCClient manages the gRPC client connection to the Scanner service. +type ScannerGRPCClient struct { + Client pb.ScannerServiceClient + conn *grpc.ClientConn + logger *zap.Logger +} + +// NewScannerGRPCClient initializes and returns a new ScannerGRPCClient. +func NewScannerGRPCClient(scannerURL string, logger *zap.Logger) (*ScannerGRPCClient, error) { + conn, err := grpc.NewClient(scannerURL, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, err + } + + return &ScannerGRPCClient{ + Client: pb.NewScannerServiceClient(conn), + conn: conn, + logger: logger, + }, nil +} + +// Close closes the underlying gRPC connection. +func (s *ScannerGRPCClient) Close() error { + return s.conn.Close() +} + +// Scan sends a scan request to the Scanner service. +func (s *ScannerGRPCClient) Scan(ctx context.Context, runID int64, accountIDs []string, selectAll bool) (*pb.ScanResponse, error) { + req := &pb.ScanRequest{ + RunId: runID, + AccountIds: accountIDs, + SelectAll: selectAll, + } + + s.logger.Info("Sending scan request to Scanner", + zap.Int64("run_id", runID), + zap.Strings("account_ids", accountIDs), + zap.Bool("select_all", selectAll), + ) + + resp, err := s.Client.Scan(ctx, req) + if err != nil { + return nil, err + } + + s.logger.Info("Scan response received", + zap.Int32("error_code", resp.Error), + zap.String("message", resp.Message), + zap.Int32("accounts_scanned", resp.AccountsScanned), + ) + + return resp, nil +} diff --git a/internal/cloud_executors/aws.go b/internal/cloud_executors/aws.go index f8dcf8a9..70270e40 100644 --- a/internal/cloud_executors/aws.go +++ b/internal/cloud_executors/aws.go @@ -58,6 +58,9 @@ func (e *AWSExecutor) ProcessAction(action actions.Action) error { case actions.PowerOff: return e.PowerOffCluster(target.GetInstances()) + case actions.Scan: + return fmt.Errorf("scan operations are handled by the scanner service, not the cloud executor") + default: // No registered ActionOperation return fmt.Errorf("cannot identify ActionOperation while processing an Action") } diff --git a/internal/config/agent_config.go b/internal/config/agent_config.go index 1c0bcb63..550fc27c 100644 --- a/internal/config/agent_config.go +++ b/internal/config/agent_config.go @@ -10,6 +10,8 @@ type ExecutorAgentServiceConfig struct { // APIURL refers to the ClusterIQ API Endpoint APIURL string `env:"CIQ_API_URL,required"` DBURL string `env:"CIQ_DB_URL,required"` + // ScannerURL refers to the Scanner gRPC endpoint + ScannerURL string `env:"CIQ_SCANNER_URL" envDefault:"scanner:50052"` // Credentials for accessing the cloud providers accounts Credentials CloudCredentialsConfig } diff --git a/internal/config/scanner_config.go b/internal/config/scanner_config.go index 97cc8143..ddc99aa5 100644 --- a/internal/config/scanner_config.go +++ b/internal/config/scanner_config.go @@ -6,6 +6,7 @@ import env "github.com/caarlos0/env/v11" type ScannerConfig struct { CloudCredentialsConfig APIURL string `env:"CIQ_API_URL,required"` + ListenURL string `env:"CIQ_SCANNER_LISTEN_URL" envDefault:"0.0.0.0:50052"` SkipNoOpenShiftInstances bool `env:"CIQ_SKIP_NO_OPENSHIFT_INSTANCES" envDefault:"true"` } diff --git a/internal/credentials/cloud_credentials.go b/internal/credentials/cloud_credentials.go index 12bc3a5d..c65c0be8 100644 --- a/internal/credentials/cloud_credentials.go +++ b/internal/credentials/cloud_credentials.go @@ -1,10 +1,17 @@ package credentials import ( + "errors" + "fmt" + "github.com/RHEcosystemAppEng/cluster-iq/internal/inventory" ini "gopkg.in/ini.v1" ) +var ( + ErrMissingCredentials = errors.New("missing credentials") +) + type AccountConfig struct { ID string Name string @@ -26,17 +33,26 @@ func ReadCloudAccounts(credsFile string) ([]AccountConfig, error) { cfg.DeleteSection(ini.DefaultSection) var accounts []AccountConfig + var skipped []error for _, section := range cfg.Sections() { + user := section.Key("user").String() + key := section.Key("key").String() + + if user == "" || key == "" { + skipped = append(skipped, fmt.Errorf("%w: account %q has empty user or key", ErrMissingCredentials, section.Name())) + continue + } + account := AccountConfig{ ID: section.Name(), - Name: section.Key("name").MustString(section.Name()), // If `name` is empty, the id is used as replacement + Name: section.Key("name").MustString(section.Name()), Provider: inventory.GetProvider(section.Key("provider").String()), - User: section.Key("user").String(), - Key: section.Key("key").String(), + User: user, + Key: key, BillingEnabled: section.Key("billing_enabled").MustBool(), } accounts = append(accounts, account) } - return accounts, nil + return accounts, errors.Join(skipped...) } diff --git a/internal/credentials/cloud_credentials_test.go b/internal/credentials/cloud_credentials_test.go index 002dc840..84ff3249 100644 --- a/internal/credentials/cloud_credentials_test.go +++ b/internal/credentials/cloud_credentials_test.go @@ -77,3 +77,67 @@ func testReadCloudAccounts_EmptyFile(t *testing.T) { assert.NoError(t, err) assert.Len(t, accounts, 0) } + +func TestReadCloudAccounts_MissingUser(t *testing.T) { + content := ` +[acc-1] +name = No User Account +provider = aws +key = secret +` + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "creds.ini") + err := os.WriteFile(file, []byte(content), 0600) + assert.NoError(t, err) + + accounts, err := ReadCloudAccounts(file) + + assert.Error(t, err) + assert.ErrorIs(t, err, ErrMissingCredentials) + assert.Len(t, accounts, 0) +} + +func TestReadCloudAccounts_MissingKey(t *testing.T) { + content := ` +[acc-1] +name = No Key Account +provider = aws +user = admin +` + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "creds.ini") + err := os.WriteFile(file, []byte(content), 0600) + assert.NoError(t, err) + + accounts, err := ReadCloudAccounts(file) + + assert.Error(t, err) + assert.ErrorIs(t, err, ErrMissingCredentials) + assert.Len(t, accounts, 0) +} + +func TestReadCloudAccounts_MixedValidAndInvalid(t *testing.T) { + content := ` +[valid-acc] +name = Valid Account +provider = aws +user = admin +key = secret + +[invalid-acc] +name = Invalid Account +provider = aws +user = admin +` + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "creds.ini") + err := os.WriteFile(file, []byte(content), 0600) + assert.NoError(t, err) + + accounts, err := ReadCloudAccounts(file) + + assert.Error(t, err) + assert.ErrorIs(t, err, ErrMissingCredentials) + assert.Len(t, accounts, 1) + assert.Equal(t, "valid-acc", accounts[0].ID) +} diff --git a/internal/db_client/db_client.go b/internal/db_client/db_client.go index 27edb834..fbb7bca7 100644 --- a/internal/db_client/db_client.go +++ b/internal/db_client/db_client.go @@ -92,6 +92,11 @@ func (d *DBClient) QueryRowContext(ctx context.Context, dest interface{}, query return d.db.GetContext(ctx, dest, query, args...) } +// QuerySelectContext executes a raw SQL query and scans the result rows into dest (a slice pointer). +func (d *DBClient) QuerySelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { + return d.db.SelectContext(ctx, dest, query, args...) +} + func (d *DBClient) SelectWithContext(ctx context.Context, dest interface{}, table string, opts models.ListOptions, orderColumn string, columns ...string) error { builder := d.NewSelectBuilder(columns...).From(table) diff --git a/internal/events/event_service/event_service.go b/internal/events/event_service/event_service.go index c9a04227..49a4913a 100644 --- a/internal/events/event_service/event_service.go +++ b/internal/events/event_service/event_service.go @@ -16,6 +16,7 @@ const ( ResultSuccess = "Success" ResultFailed = "Failed" ResultPending = "Pending" + ResultRunning = "Running" ) // Event severity levels @@ -32,7 +33,8 @@ type EventOptions struct { ResourceType string Result string Severity string - TriggeredBy string + Requester string + ScheduleID *int64 } // EventService to write events from every clusteriq component @@ -52,7 +54,7 @@ func NewEventService(dbClient *dbclient.DBClient, logger *zap.Logger) *EventServ // LogEvent creates a new events log entry and returns its ID. func (e *EventService) LogEvent(ctx context.Context, opts EventOptions) (int64, error) { event := events.Event{ - TriggeredBy: opts.TriggeredBy, + Requester: opts.Requester, Action: opts.Action, ResourceID: opts.ResourceID, ResourceType: opts.ResourceType, @@ -60,6 +62,7 @@ func (e *EventService) LogEvent(ctx context.Context, opts EventOptions) (int64, Description: opts.Description, Severity: opts.Severity, EventTimestamp: time.Now().UTC(), + ScheduleID: opts.ScheduleID, } eventID, err := e.repo.CreateEvent(ctx, event) e.logger.Debug("Tracking new event", zap.Int64("event_id", eventID)) @@ -102,6 +105,14 @@ type EventTracker struct { logger *zap.Logger } +// Running marks the tracked event as in progress. +// Uses context.Background() as event updates happen asynchronously. +func (t *EventTracker) Running() { + if err := t.service.UpdateEventStatus(context.Background(), t.eventID, ResultRunning); err != nil { + t.logger.Error("Failed to update event status", zap.Error(err)) + } +} + // Success marks the tracked event status as success. // Uses context.Background() as event updates happen asynchronously. func (t *EventTracker) Success() { diff --git a/internal/events/event_service/event_service_test.go b/internal/events/event_service/event_service_test.go index 5ae45fb9..0321fe13 100644 --- a/internal/events/event_service/event_service_test.go +++ b/internal/events/event_service/event_service_test.go @@ -93,7 +93,7 @@ func TestLogEvent(t *testing.T) { func testLogEvent_Success(t *testing.T) { desc := "something happened" opts := EventOptions{ - TriggeredBy: "scanner", + Requester: "scanner", Action: actions.ActionOperation("START"), ResourceID: "cluster-1", ResourceType: inventory.ClusterResourceType, @@ -121,7 +121,7 @@ func testLogEvent_Success(t *testing.T) { assert.NotNil(t, repo.lastCreateEventCtx) ev := repo.lastCreateEventEvent - assert.Equal(t, opts.TriggeredBy, ev.TriggeredBy) + assert.Equal(t, opts.Requester, ev.Requester) assert.Equal(t, opts.Action, ev.Action) assert.Equal(t, opts.ResourceID, ev.ResourceID) assert.Equal(t, opts.ResourceType, ev.ResourceType) @@ -136,7 +136,7 @@ func testLogEvent_Success(t *testing.T) { func testLogEvent_RepoError(t *testing.T) { opts := EventOptions{ - TriggeredBy: "api", + Requester: "api", Action: actions.ActionOperation("STOP"), ResourceID: "cluster-1", ResourceType: inventory.ClusterResourceType, @@ -208,7 +208,7 @@ func TestStartTracking(t *testing.T) { func testStartTracking_Success(t *testing.T) { opts := &EventOptions{ - TriggeredBy: "agent", + Requester: "agent", Action: actions.ActionOperation("START"), ResourceID: "cluster-1", ResourceType: inventory.ClusterResourceType, @@ -234,7 +234,7 @@ func testStartTracking_Success(t *testing.T) { func testStartTracking_LogEventError(t *testing.T) { opts := &EventOptions{ - TriggeredBy: "agent", + Requester: "agent", Action: actions.ActionOperation("STOP"), ResourceID: "cluster-1", ResourceType: inventory.ClusterResourceType, @@ -255,14 +255,46 @@ func testStartTracking_LogEventError(t *testing.T) { assert.Nil(t, tracker) } -// TestEventTracker verifies EventTracker updates status to Success/Failed and handles repo errors. +// TestEventTracker verifies EventTracker updates status to Running/Success/Failed and handles repo errors. func TestEventTracker(t *testing.T) { + t.Run("Tracker Running update ok", func(t *testing.T) { testEventTracker_Running_OK(t) }) + t.Run("Tracker Running update error", func(t *testing.T) { testEventTracker_Running_Error(t) }) t.Run("Tracker Success update ok", func(t *testing.T) { testEventTracker_Success_OK(t) }) t.Run("Tracker Success update error", func(t *testing.T) { testEventTracker_Success_Error(t) }) t.Run("Tracker Failed update ok", func(t *testing.T) { testEventTracker_Failed_OK(t) }) t.Run("Tracker Failed update error", func(t *testing.T) { testEventTracker_Failed_Error(t) }) } +func testEventTracker_Running_OK(t *testing.T) { + repo := &mockEventRepo{ + updateEventStatusFn: func(ctx context.Context, eventID int64, result string) error { + return nil + }, + } + + svc := &EventService{repo: repo, logger: zap.NewNop()} + tracker := &EventTracker{eventID: 1, service: svc, logger: zap.NewNop()} + + assert.NotPanics(t, func() { tracker.Running() }) + assert.Equal(t, 1, repo.updateEventStatusCalls) + assert.Equal(t, ResultRunning, repo.lastUpdateStatusResult) +} + +func testEventTracker_Running_Error(t *testing.T) { + repo := &mockEventRepo{ + updateEventStatusFn: func(ctx context.Context, eventID int64, result string) error { + return errTest + }, + } + + svc := &EventService{repo: repo, logger: zap.NewNop()} + tracker := &EventTracker{eventID: 1, service: svc, logger: zap.NewNop()} + + assert.NotPanics(t, func() { tracker.Running() }) + assert.Equal(t, 1, repo.updateEventStatusCalls) + assert.Equal(t, ResultRunning, repo.lastUpdateStatusResult) +} + func testEventTracker_Success_OK(t *testing.T) { repo := &mockEventRepo{ updateEventStatusFn: func(ctx context.Context, eventID int64, result string) error { diff --git a/internal/events/events.go b/internal/events/events.go index f793121c..676b3c13 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -25,5 +25,7 @@ type Event struct { // Log severity level (e.g., "info", "warning", "error"). Severity string `db:"severity"` // User or system entity responsible for the action. - TriggeredBy string `db:"triggered_by"` + Requester string `db:"requester"` + // ID of the scheduled action that generated this event. + ScheduleID *int64 `db:"schedule_id"` } diff --git a/internal/inventory/account.go b/internal/inventory/account.go index bd9fa813..e6a022f1 100644 --- a/internal/inventory/account.go +++ b/internal/inventory/account.go @@ -130,12 +130,3 @@ func (a *Account) DisableBilling() { func (a *Account) IsBillingEnabled() bool { return a.billingEnabled } - -// PrintAccount prints account info and every cluster on it by stdout -func (a Account) PrintAccount() { - fmt.Printf("\t - Account: %s[%s] #Clusters: %d\n", a.AccountName, a.AccountID, len(a.Clusters)) - - for _, cluster := range a.Clusters { - cluster.PrintCluster() - } -} diff --git a/internal/inventory/account_test.go b/internal/inventory/account_test.go index 1e31a83b..69a89b88 100644 --- a/internal/inventory/account_test.go +++ b/internal/inventory/account_test.go @@ -207,27 +207,3 @@ func testIsBillingEnabled_False(t *testing.T) { assert.False(t, account.billingEnabled, account.IsBillingEnabled()) } -func TestPrintAccount(t *testing.T) { - t.Run("Print Account ", func(t *testing.T) { testPrintAccount_Correct(t) }) - t.Run("Print Account No clusters", func(t *testing.T) { testPrintAccount_NoClusters(t) }) -} - -func testPrintAccount_Correct(t *testing.T) { - account, err := NewAccount("0000-11A", "testAccount", AWSProvider, "user", "password") - assert.Nil(t, err) - assert.NotNil(t, account) - - cluster, err := NewCluster("testCluster-1", "XXXX1", AWSProvider, "eu-west-1", "https://url.com", "John Doe") - assert.Nil(t, err) - account.AddCluster(cluster) - - account.PrintAccount() -} - -func testPrintAccount_NoClusters(t *testing.T) { - account, err := NewAccount("0000-11A", "testAccount", AWSProvider, "user", "password") - assert.Nil(t, err) - assert.NotNil(t, account) - - account.PrintAccount() -} diff --git a/internal/inventory/cluster.go b/internal/inventory/cluster.go index 120d9b4b..2ee9e91a 100644 --- a/internal/inventory/cluster.go +++ b/internal/inventory/cluster.go @@ -224,12 +224,3 @@ func (c Cluster) InstancesCount() int { func GenerateClusterID(name string, infraID string) string { return name + "-" + infraID } - -// PrintCluster prints cluster info -func (c Cluster) PrintCluster() { - fmt.Printf("\t\tCluster:[%s] -- Status: %s, Region: %s, Provider: %s, #Instances: %d\n", c.ClusterName, c.Status, c.Region, c.Provider, c.InstancesCount()) - - for _, instance := range c.Instances { - instance.PrintInstance() - } -} diff --git a/internal/inventory/cluster_test.go b/internal/inventory/cluster_test.go index 02566d6d..ea1f4a84 100644 --- a/internal/inventory/cluster_test.go +++ b/internal/inventory/cluster_test.go @@ -412,7 +412,7 @@ func testInstancesCount(t *testing.T) { cluster, err := NewCluster("testCluster", "i1", AWSProvider, "us-east-1", "https://console", "user") assert.NotNil(t, cluster) assert.Nil(t, err) - assert.Equal(t, len(cluster.Instances), 0) + assert.Equal(t, 0, cluster.InstancesCount()) } // TestGenerateClusterID tests GenerateClusterID function for 100% coverage @@ -420,14 +420,3 @@ func TestGenerateClusterID(t *testing.T) { assert.Equal(t, "test-infra", GenerateClusterID("test", "infra")) } -// TestPrintCluster tests PrintCluster function for 100% coverage -func TestPrintCluster(t *testing.T) { - c, _ := NewCluster("name", "infra", AWSProvider, "region", "link", "owner") - c.PrintCluster() - - now := time.Now() - c.Instances = []Instance{ - {Status: Running, CreatedAt: now.Add(-24 * time.Hour)}, - } - c.PrintCluster() -} diff --git a/internal/inventory/instance.go b/internal/inventory/instance.go index b7737a7f..a7ee6e5b 100644 --- a/internal/inventory/instance.go +++ b/internal/inventory/instance.go @@ -2,7 +2,6 @@ package inventory import ( "errors" - "fmt" "time" ) @@ -110,21 +109,3 @@ func (i *Instance) AddExpense(expense *Expense) error { return nil } - -// String as ToString func -func (i Instance) String() string { - return fmt.Sprintf("(%s): [%s][%s][%s][%s][%s][%d]", - i.InstanceName, - i.Provider, - i.InstanceType, - i.AvailabilityZone, - i.Status, - i.ClusterID, - len(i.Expenses), - ) -} - -// PrintInstance prints Instance details -func (i Instance) PrintInstance() { - fmt.Printf("\t\t\tInstance: %s\n", i.String()) -} diff --git a/internal/inventory/instance_test.go b/internal/inventory/instance_test.go index 2e44a248..c593235c 100644 --- a/internal/inventory/instance_test.go +++ b/internal/inventory/instance_test.go @@ -1,7 +1,6 @@ package inventory import ( - "strings" "testing" "time" @@ -125,27 +124,3 @@ func testAddExpense_WithNegativeAmount(t *testing.T) { assert.Zero(t, len(i.Tags)) } -// TestInstance_String verifies String method returns expected format -func TestInstance_String(t *testing.T) { - i := Instance{ - InstanceID: "i-123", - InstanceName: "test", - Provider: AWSProvider, - InstanceType: "t2.micro", - AvailabilityZone: "us-east-1a", - Status: Running, - ClusterID: "cluster-x", - Expenses: []Expense{{Amount: 5}}, - } - - str := i.String() - if !(strings.Contains(str, "test") && strings.Contains(str, "AWS") && strings.Contains(str, "t2.micro")) { - t.Errorf("unexpected output from String(): %s", str) - } -} - -// TestPrintInstance verifies PrintInstance runs without panic -func TestPrintInstance(t *testing.T) { - i := Instance{InstanceID: "i-456", InstanceName: "node1"} - i.PrintInstance() -} diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go index 7e9c0915..5d32ec23 100644 --- a/internal/inventory/inventory.go +++ b/internal/inventory/inventory.go @@ -44,10 +44,22 @@ func (i *Inventory) AddAccount(account *Account) error { return nil } -// PrintInventory prints the entire Inventory content -func (i Inventory) PrintInventory() { - fmt.Printf("Inventory created at: %s\nAccounts:\n", i.CreatedAt) +// TotalClusters returns the total number of clusters across all accounts +func (i Inventory) TotalClusters() int { + total := 0 for _, account := range i.Accounts { - account.PrintAccount() + total += len(account.Clusters) } + return total +} + +// TotalInstances returns the total number of instances across all accounts and clusters +func (i Inventory) TotalInstances() int { + total := 0 + for _, account := range i.Accounts { + for _, cluster := range account.Clusters { + total += len(cluster.Instances) + } + } + return total } diff --git a/internal/inventory/inventory_test.go b/internal/inventory/inventory_test.go index 21a979b8..65d3283c 100644 --- a/internal/inventory/inventory_test.go +++ b/internal/inventory/inventory_test.go @@ -79,15 +79,42 @@ func testAddAccount_Repeated(t *testing.T) { assert.ErrorContains(t, err, ErrorAddingAccountToInventory.Error()) } -func TestPrintInventory(t *testing.T) { +func TestTotalClusters(t *testing.T) { inv := NewInventory() - acc := Account{ - AccountID: "id-account", - AccountName: "testAccount", - Provider: UnknownProvider, - } - inv.AddAccount(&acc) + acc1 := &Account{AccountID: "acc-1", Clusters: map[string]*Cluster{ + "c1": {ClusterID: "c1"}, + "c2": {ClusterID: "c2"}, + }} + acc2 := &Account{AccountID: "acc-2", Clusters: map[string]*Cluster{ + "c3": {ClusterID: "c3"}, + }} + + inv.AddAccount(acc1) + inv.AddAccount(acc2) + + assert.Equal(t, 3, inv.TotalClusters()) +} + +func TestTotalInstances(t *testing.T) { + inv := NewInventory() + + acc := &Account{AccountID: "acc-1", Clusters: map[string]*Cluster{ + "c1": {ClusterID: "c1", Instances: []Instance{{InstanceID: "i1"}, {InstanceID: "i2"}}}, + "c2": {ClusterID: "c2", Instances: []Instance{{InstanceID: "i3"}}}, + }} + + inv.AddAccount(acc) + + assert.Equal(t, 3, inv.TotalInstances()) +} + +func TestTotalClusters_Empty(t *testing.T) { + inv := NewInventory() + assert.Equal(t, 0, inv.TotalClusters()) +} - inv.PrintInventory() +func TestTotalInstances_Empty(t *testing.T) { + inv := NewInventory() + assert.Equal(t, 0, inv.TotalInstances()) } diff --git a/internal/inventory/summary.go b/internal/inventory/summary.go index 949bbf21..0fe68fed 100644 --- a/internal/inventory/summary.go +++ b/internal/inventory/summary.go @@ -4,10 +4,14 @@ import "time" // OverviewSummary represents the comprehensive overview of the system's inventory. type OverviewSummary struct { - Clusters ClustersSummary - Instances InstancesSummary - Providers ProvidersSummary - Scanner Scanner + Clusters ClustersSummary + Instances InstancesSummary + Providers ProvidersSummary + Scanner Scanner + TopRegions []TopItem + TopOwners []TopItem + ClustersByPartner []TopItem + CostPerAccount []AccountCost } // ClustersSummary provides a summary of cluster counts by status. @@ -37,6 +41,18 @@ type ProviderDetails struct { ClusterCount int } +// TopItem represents a ranked item with a name and cluster count. +type TopItem struct { + Name string `db:"name"` + ClusterCount int `db:"cluster_count"` +} + +// AccountCost represents an account with its current month cost. +type AccountCost struct { + AccountName string `db:"account_name"` + CurrentMonthCost float64 `db:"current_month_so_far_cost"` +} + // Scanner provides information about the last inventory scan. type Scanner struct { LastScanTimestamp time.Time diff --git a/internal/inventory/types.go b/internal/inventory/types.go index 2945ecbc..952502ee 100644 --- a/internal/inventory/types.go +++ b/internal/inventory/types.go @@ -9,6 +9,7 @@ const ( ClusterPowerOffAction = "PowerOff" // Resource types + AccountResourceType = "Account" ClusterResourceType = "Cluster" InstanceResourceType = "Instance" ) diff --git a/internal/models/convert/generated.go b/internal/models/convert/generated.go index 1384b4bf..f43a36c3 100644 --- a/internal/models/convert/generated.go +++ b/internal/models/convert/generated.go @@ -45,10 +45,17 @@ func (c *ConverterImpl) ToActionDTO(source db.ActionDBResponse) dto.ActionDTORes dtoActionDTOResponse.Operation = source.Operation dtoActionDTOResponse.Status = source.Status dtoActionDTOResponse.Enabled = source.Enabled - dtoActionDTOResponse.ClusterID = source.ClusterID - dtoActionDTOResponse.Region = source.Region + dtoActionDTOResponse.TargetType = source.TargetType + dtoActionDTOResponse.SelectAll = source.SelectAll + dtoActionDTOResponse.ClusterID = NullString(source.ClusterID) + dtoActionDTOResponse.ClusterName = NullString(source.ClusterName) + dtoActionDTOResponse.Region = NullString(source.Region) dtoActionDTOResponse.AccountID = source.AccountID dtoActionDTOResponse.Instances = StringArray(source.Instances) + dtoActionDTOResponse.TargetAccountIDs = StringArray(source.TargetAccountIDs) + dtoActionDTOResponse.TargetAccountNames = StringArray(source.TargetAccountNames) + dtoActionDTOResponse.Requester = NullString(source.Requester) + dtoActionDTOResponse.Description = NullStringPtr(source.Description) return dtoActionDTOResponse } func (c *ConverterImpl) ToActionDTOs(source []db.ActionDBResponse) []dto.ActionDTOResponse { @@ -61,6 +68,26 @@ func (c *ConverterImpl) ToActionDTOs(source []db.ActionDBResponse) []dto.ActionD } return dtoActionDTOResponseList } +func (c *ConverterImpl) ToActionRunDTO(source db.ActionRunDBResponse) dto.ActionRunDTOResponse { + var dtoActionRunDTOResponse dto.ActionRunDTOResponse + dtoActionRunDTOResponse.ID = source.ID + dtoActionRunDTOResponse.ScheduleID = source.ScheduleID + dtoActionRunDTOResponse.StartedAt = NullTime(source.StartedAt) + dtoActionRunDTOResponse.FinishedAt = NullTime(source.FinishedAt) + dtoActionRunDTOResponse.Status = source.Status + dtoActionRunDTOResponse.ErrorMsg = NullString(source.ErrorMsg) + return dtoActionRunDTOResponse +} +func (c *ConverterImpl) ToActionRunDTOs(source []db.ActionRunDBResponse) []dto.ActionRunDTOResponse { + var dtoActionRunDTOResponseList []dto.ActionRunDTOResponse + if source != nil { + dtoActionRunDTOResponseList = make([]dto.ActionRunDTOResponse, len(source)) + for i := 0; i < len(source); i++ { + dtoActionRunDTOResponseList[i] = c.ToActionRunDTO(source[i]) + } + } + return dtoActionRunDTOResponseList +} func (c *ConverterImpl) ToClusterDTO(source db.ClusterDBResponse) dto.ClusterDTOResponse { var dtoClusterDTOResponse dto.ClusterDTOResponse dtoClusterDTOResponse.ClusterID = source.ClusterID @@ -105,11 +132,15 @@ func (c *ConverterImpl) ToClusterEventDTO(source db.ClusterEventDBResponse) dto. dtoClusterEventDTOResponse.EventTimestamp = Time(source.EventTimestamp) dtoClusterEventDTOResponse.Result = actions.ActionStatus(source.Result) dtoClusterEventDTOResponse.Severity = source.Severity - dtoClusterEventDTOResponse.TriggeredBy = source.TriggeredBy + dtoClusterEventDTOResponse.Requester = source.Requester if source.Description != nil { xstring2 := *source.Description dtoClusterEventDTOResponse.Description = &xstring2 } + if source.ScheduleID != nil { + xint64 := *source.ScheduleID + dtoClusterEventDTOResponse.ScheduleID = &xint64 + } return dtoClusterEventDTOResponse } func (c *ConverterImpl) ToClusterEventDTOs(source []db.ClusterEventDBResponse) []dto.ClusterEventDTOResponse { @@ -172,8 +203,10 @@ func (c *ConverterImpl) ToInstanceDTOs(source []db.InstanceDBResponse) []dto.Ins func (c *ConverterImpl) ToSystemEventDTO(source db.SystemEventDBResponse) dto.SystemEventDTOResponse { var dtoSystemEventDTOResponse dto.SystemEventDTOResponse dtoSystemEventDTOResponse.ClusterEventDTOResponse = c.ToClusterEventDTO(source.ClusterEventDBResponse) - dtoSystemEventDTOResponse.AccountID = source.AccountID - dtoSystemEventDTOResponse.Provider = source.Provider + dtoSystemEventDTOResponse.ResourceName = NullString(source.ResourceName) + dtoSystemEventDTOResponse.AccountID = NullString(source.AccountID) + dtoSystemEventDTOResponse.AccountName = NullString(source.AccountName) + dtoSystemEventDTOResponse.Provider = NullString(source.Provider) return dtoSystemEventDTOResponse } func (c *ConverterImpl) ToSystemEventDTOs(source []db.SystemEventDBResponse) []dto.SystemEventDTOResponse { diff --git a/internal/models/convert/mapper.go b/internal/models/convert/mapper.go index 975953a6..2c07b64f 100644 --- a/internal/models/convert/mapper.go +++ b/internal/models/convert/mapper.go @@ -16,6 +16,7 @@ import ( // goverter:extend Time // goverter:extend NullTime // goverter:extend NullString +// goverter:extend NullStringPtr // goverter:extend StringArray // goverter:extend TagDBResponsesToDTO type Converter interface { @@ -45,10 +46,13 @@ type Converter interface { ToSystemEventDTOs(src []db.SystemEventDBResponse) []dto.SystemEventDTOResponse // Action - // goverter:ignore Requester Description ToActionDTO(src db.ActionDBResponse) dto.ActionDTOResponse ToActionDTOs(src []db.ActionDBResponse) []dto.ActionDTOResponse + // ActionRun + ToActionRunDTO(src db.ActionRunDBResponse) dto.ActionRunDTOResponse + ToActionRunDTOs(src []db.ActionRunDBResponse) []dto.ActionRunDTOResponse + // Instance ToInstanceDTO(src db.InstanceDBResponse) dto.InstanceDTOResponse ToInstanceDTOs(src []db.InstanceDBResponse) []dto.InstanceDTOResponse @@ -75,6 +79,14 @@ func NullString(s sql.NullString) string { return "" } +// NullStringPtr handles sql.NullString to *string conversion +func NullStringPtr(s sql.NullString) *string { + if s.Valid { + return &s.String + } + return nil +} + // StringArray handles pq.StringArray to []string conversion func StringArray(arr pq.StringArray) []string { return arr diff --git a/internal/models/db/action.go b/internal/models/db/action.go index a2a31000..6e5f1634 100644 --- a/internal/models/db/action.go +++ b/internal/models/db/action.go @@ -9,15 +9,22 @@ import ( // ActionDBResponse represents the database schema for action details, // linking each field to a corresponding column in the database. type ActionDBResponse struct { - ID string `db:"id"` - Type string `db:"type"` - Time sql.NullTime `db:"time"` - CronExp sql.NullString `db:"cron_exp"` - Operation string `db:"operation"` - Status string `db:"status"` - Enabled bool `db:"enabled"` - ClusterID string `db:"cluster_id"` - Region string `db:"region"` - AccountID string `db:"account_id"` - Instances pq.StringArray `db:"instances"` + ID string `db:"id"` + Type string `db:"type"` + Time sql.NullTime `db:"time"` + CronExp sql.NullString `db:"cron_exp"` + Operation string `db:"operation"` + Status string `db:"status"` + Enabled bool `db:"enabled"` + TargetType string `db:"target_type"` + SelectAll bool `db:"select_all"` + ClusterID sql.NullString `db:"cluster_id"` + ClusterName sql.NullString `db:"cluster_name"` + Region sql.NullString `db:"region"` + AccountID string `db:"account_id"` + Instances pq.StringArray `db:"instances"` + TargetAccountIDs pq.StringArray `db:"target_account_ids"` + TargetAccountNames pq.StringArray `db:"target_account_names"` + Requester sql.NullString `db:"requester"` + Description sql.NullString `db:"description"` } diff --git a/internal/models/db/action_run.go b/internal/models/db/action_run.go new file mode 100644 index 00000000..d66a5f6f --- /dev/null +++ b/internal/models/db/action_run.go @@ -0,0 +1,13 @@ +package db + +import "database/sql" + +// ActionRunDBResponse represents the database schema for action execution history. +type ActionRunDBResponse struct { + ID string `db:"id"` + ScheduleID string `db:"schedule_id"` + StartedAt sql.NullTime `db:"started_at"` + FinishedAt sql.NullTime `db:"finished_at"` + Status string `db:"status"` + ErrorMsg sql.NullString `db:"error_msg"` +} diff --git a/internal/models/db/action_test.go b/internal/models/db/action_test.go index 723a9cb7..2ef561b0 100644 --- a/internal/models/db/action_test.go +++ b/internal/models/db/action_test.go @@ -29,8 +29,8 @@ func testActionDBResponse_ToActionDTOResponse_WithValidFields(t *testing.T) { Operation: "START", Status: "Pending", Enabled: true, - ClusterID: "cluster-1", - Region: "eu-west-1", + ClusterID: sql.NullString{String: "cluster-1", Valid: true}, + Region: sql.NullString{String: "eu-west-1", Valid: true}, AccountID: "acc-1", Instances: pq.StringArray{"i-1", "i-2"}, } @@ -44,8 +44,8 @@ func testActionDBResponse_ToActionDTOResponse_WithValidFields(t *testing.T) { assert.Equal(t, model.Operation, dto.Operation) assert.Equal(t, model.Status, dto.Status) assert.Equal(t, model.Enabled, dto.Enabled) - assert.Equal(t, model.ClusterID, dto.ClusterID) - assert.Equal(t, model.Region, dto.Region) + assert.Equal(t, model.ClusterID.String, dto.ClusterID) + assert.Equal(t, model.Region.String, dto.Region) assert.Equal(t, model.AccountID, dto.AccountID) assert.Equal(t, []string{"i-1", "i-2"}, dto.Instances) } @@ -61,8 +61,8 @@ func testActionDBResponse_ToActionDTOResponse_WithInvalidFields(t *testing.T) { Operation: "STOP", Status: "Failed", Enabled: false, - ClusterID: "cluster-2", - Region: "us-east-1", + ClusterID: sql.NullString{String: "cluster-2", Valid: true}, + Region: sql.NullString{String: "us-east-1", Valid: true}, AccountID: "acc-2", Instances: pq.StringArray{"i-9"}, } @@ -79,8 +79,8 @@ func testActionDBResponse_ToActionDTOResponse_WithInvalidFields(t *testing.T) { assert.Equal(t, model.Operation, dto.Operation) assert.Equal(t, model.Status, dto.Status) assert.Equal(t, model.Enabled, dto.Enabled) - assert.Equal(t, model.ClusterID, dto.ClusterID) - assert.Equal(t, model.Region, dto.Region) + assert.Equal(t, model.ClusterID.String, dto.ClusterID) + assert.Equal(t, model.Region.String, dto.Region) assert.Equal(t, model.AccountID, dto.AccountID) assert.Equal(t, []string{"i-9"}, dto.Instances) } @@ -103,8 +103,8 @@ func testToActionDTOResponseList_Correct(t *testing.T) { Operation: "START", Status: "Pending", Enabled: true, - ClusterID: "cluster-1", - Region: "eu-west-1", + ClusterID: sql.NullString{String: "cluster-1", Valid: true}, + Region: sql.NullString{String: "eu-west-1", Valid: true}, AccountID: "acc-1", Instances: pq.StringArray{"i-1"}, }, @@ -116,8 +116,8 @@ func testToActionDTOResponseList_Correct(t *testing.T) { Operation: "STOP", Status: "Running", Enabled: false, - ClusterID: "cluster-2", - Region: "us-east-1", + ClusterID: sql.NullString{String: "cluster-2", Valid: true}, + Region: sql.NullString{String: "us-east-1", Valid: true}, AccountID: "acc-2", Instances: pq.StringArray{"i-2"}, }, diff --git a/internal/models/db/events.go b/internal/models/db/events.go index f787924c..0b1737a2 100644 --- a/internal/models/db/events.go +++ b/internal/models/db/events.go @@ -1,6 +1,7 @@ package db import ( + "database/sql" "time" "github.com/RHEcosystemAppEng/cluster-iq/internal/actions" @@ -11,19 +12,22 @@ import ( type ClusterEventDBResponse struct { ID int64 `db:"id"` EventTimestamp time.Time `db:"event_timestamp"` - TriggeredBy string `db:"triggered_by"` + Requester string `db:"requester"` Action string `db:"action"` ResourceID *string `db:"resource_id"` ResourceType string `db:"resource_type"` Result actions.ActionStatus `db:"result"` Description *string `db:"description,omitempty"` Severity string `db:"severity"` + ScheduleID *int64 `db:"schedule_id"` } // SystemEventDBResponse represents the database schema for system event details, // extending ClusterEventDBResponse with account and provider information. type SystemEventDBResponse struct { ClusterEventDBResponse - AccountID string `db:"account_id"` - Provider string `db:"provider"` + ResourceName sql.NullString `db:"resource_name"` + AccountID sql.NullString `db:"account_id"` + AccountName sql.NullString `db:"account_name"` + Provider sql.NullString `db:"provider"` } diff --git a/internal/models/db/events_test.go b/internal/models/db/events_test.go index fdd73855..d05bbbc0 100644 --- a/internal/models/db/events_test.go +++ b/internal/models/db/events_test.go @@ -1,6 +1,7 @@ package db_test import ( + "database/sql" "testing" "time" @@ -30,7 +31,7 @@ func testClusterEventDBResponse_ToClusterEventDTOResponse_Correct(t *testing.T) model := db.ClusterEventDBResponse{ ID: 1, EventTimestamp: now, - TriggeredBy: "api", + Requester: "api", Action: "START", ResourceID: &resID, ResourceType: inventory.ClusterResourceType, @@ -43,7 +44,7 @@ func testClusterEventDBResponse_ToClusterEventDTOResponse_Correct(t *testing.T) assert.Equal(t, model.ID, dto.ID) assert.Equal(t, model.EventTimestamp, dto.EventTimestamp) - assert.Equal(t, model.TriggeredBy, dto.TriggeredBy) + assert.Equal(t, model.Requester, dto.Requester) assert.Equal(t, model.Action, dto.Action) assert.Equal(t, model.ResourceID, dto.ResourceID) assert.Equal(t, model.ResourceType, dto.ResourceType) @@ -60,7 +61,7 @@ func testClusterEventDBResponse_ToClusterEventDTOResponse_NilDescription(t *test model := db.ClusterEventDBResponse{ ID: 2, EventTimestamp: now, - TriggeredBy: "scanner", + Requester: "scanner", Action: "STOP", ResourceID: &resID, ResourceType: inventory.ClusterResourceType, @@ -89,8 +90,8 @@ func testToClusterEventDTOResponseList_Correct(t *testing.T) { c1 := "c1" c2 := "c2" models := []db.ClusterEventDBResponse{ - {ID: 1, EventTimestamp: now, TriggeredBy: "api", Action: "START", ResourceID: &c1, ResourceType: inventory.ClusterResourceType, Result: "Success", Severity: "Info"}, - {ID: 2, EventTimestamp: now.Add(-time.Minute), TriggeredBy: "agent", Action: "STOP", ResourceID: &c2, ResourceType: inventory.ClusterResourceType, Result: "Failed", Severity: "Error"}, + {ID: 1, EventTimestamp: now, Requester: "api", Action: "START", ResourceID: &c1, ResourceType: inventory.ClusterResourceType, Result: "Success", Severity: "Info"}, + {ID: 2, EventTimestamp: now.Add(-time.Minute), Requester: "agent", Action: "STOP", ResourceID: &c2, ResourceType: inventory.ClusterResourceType, Result: "Failed", Severity: "Error"}, } dtos := conv.ToClusterEventDTOs(models) @@ -113,34 +114,41 @@ func testSystemEventDBResponse_ToSystemEventDTOResponse_Correct(t *testing.T) { resID := "cluster-10" conv := &convert.ConverterImpl{} + schedID := int64(42) model := db.SystemEventDBResponse{ ClusterEventDBResponse: db.ClusterEventDBResponse{ ID: 10, EventTimestamp: now, - TriggeredBy: "scheduler", + Requester: "scheduler", Action: "START", ResourceID: &resID, ResourceType: inventory.ClusterResourceType, Result: "Pending", Description: &desc, Severity: "Warning", + ScheduleID: &schedID, }, - AccountID: "acc-1", - Provider: "AWS", + ResourceName: sql.NullString{String: "my-cluster", Valid: true}, + AccountID: sql.NullString{String: "acc-1", Valid: true}, + AccountName: sql.NullString{String: "My Account", Valid: true}, + Provider: sql.NullString{String: "AWS", Valid: true}, } dto := conv.ToSystemEventDTO(model) assert.Equal(t, int64(10), dto.ID) assert.Equal(t, now, dto.EventTimestamp) - assert.Equal(t, "scheduler", dto.TriggeredBy) + assert.Equal(t, "scheduler", dto.Requester) assert.Equal(t, "START", dto.Action) assert.Equal(t, &resID, dto.ResourceID) assert.Equal(t, inventory.ClusterResourceType, dto.ResourceType) assert.Equal(t, actions.StatusPending, dto.Result) assert.Equal(t, &desc, dto.Description) assert.Equal(t, "Warning", dto.Severity) + assert.Equal(t, &schedID, dto.ScheduleID) + assert.Equal(t, "my-cluster", dto.ResourceName) assert.Equal(t, "acc-1", dto.AccountID) + assert.Equal(t, "My Account", dto.AccountName) assert.Equal(t, "AWS", dto.Provider) } @@ -162,29 +170,33 @@ func testToSystemEventDTOResponseList_Correct(t *testing.T) { ClusterEventDBResponse: db.ClusterEventDBResponse{ ID: 1, EventTimestamp: now, - TriggeredBy: "api", + Requester: "api", Action: "START", ResourceID: &sc1, ResourceType: inventory.ClusterResourceType, Result: "Success", Severity: "Info", }, - AccountID: "acc-1", - Provider: "AWS", + ResourceName: sql.NullString{String: "cluster-a", Valid: true}, + AccountID: sql.NullString{String: "acc-1", Valid: true}, + AccountName: sql.NullString{String: "Account 1", Valid: true}, + Provider: sql.NullString{String: "AWS", Valid: true}, }, { ClusterEventDBResponse: db.ClusterEventDBResponse{ ID: 2, EventTimestamp: now.Add(-time.Minute), - TriggeredBy: "agent", + Requester: "agent", Action: "STOP", ResourceID: &sc2, ResourceType: inventory.ClusterResourceType, Result: "Failed", Severity: "Error", }, - AccountID: "acc-2", - Provider: "GCP", + ResourceName: sql.NullString{String: "cluster-b", Valid: true}, + AccountID: sql.NullString{String: "acc-2", Valid: true}, + AccountName: sql.NullString{String: "Account 2", Valid: true}, + Provider: sql.NullString{String: "GCP", Valid: true}, }, } @@ -192,9 +204,13 @@ func testToSystemEventDTOResponseList_Correct(t *testing.T) { assert.Len(t, dtos, 2) assert.Equal(t, int64(1), dtos[0].ID) + assert.Equal(t, "cluster-a", dtos[0].ResourceName) assert.Equal(t, "acc-1", dtos[0].AccountID) + assert.Equal(t, "Account 1", dtos[0].AccountName) assert.Equal(t, "AWS", dtos[0].Provider) assert.Equal(t, int64(2), dtos[1].ID) + assert.Equal(t, "cluster-b", dtos[1].ResourceName) assert.Equal(t, "acc-2", dtos[1].AccountID) + assert.Equal(t, "Account 2", dtos[1].AccountName) assert.Equal(t, "GCP", dtos[1].Provider) } diff --git a/internal/models/db/expense.go b/internal/models/db/expense.go index f87380d3..40a01b3f 100644 --- a/internal/models/db/expense.go +++ b/internal/models/db/expense.go @@ -11,3 +11,9 @@ type ExpenseDBResponse struct { Amount float64 `db:"amount"` Date time.Time `db:"date"` } + +// DailyCostDBResponse represents a single day's aggregated cost for an account. +type DailyCostDBResponse struct { + Date time.Time `db:"date"` + Amount float64 `db:"amount"` +} diff --git a/internal/models/db/instance.go b/internal/models/db/instance.go index cdb7f80f..291f4ec6 100644 --- a/internal/models/db/instance.go +++ b/internal/models/db/instance.go @@ -26,3 +26,9 @@ type InstanceDBResponse struct { CurrentMonthSoFarCost float64 `db:"current_month_so_far_cost"` Tags TagDBResponses `db:"tags_json"` } + +// InstancePendingExpenseDB maps the instances_pending_expense_update view. +type InstancePendingExpenseDB struct { + AccountID string `db:"account_id"` + InstanceID string `db:"instance_id"` +} diff --git a/internal/models/dto/account_dto.go b/internal/models/dto/account_dto.go index 9d254b97..9afca226 100644 --- a/internal/models/dto/account_dto.go +++ b/internal/models/dto/account_dto.go @@ -70,6 +70,12 @@ type AccountDTOResponse struct { CurrentMonthSoFarCost float64 `json:"currentMonthSoFarCost"` } // @name AccountResponse +// DailyCostDTOResponse represents a single day's aggregated cost. +type DailyCostDTOResponse struct { + Date string `json:"date"` + Amount float64 `json:"amount"` +} // @name DailyCostResponse + // AccountPatchRequest represents mutable fields for partial account updates. // Only fields present in the request will be updated (using pointers to distinguish null from empty). type AccountPatchRequest struct { diff --git a/internal/models/dto/action_dto.go b/internal/models/dto/action_dto.go index 37f4b770..fc950b52 100644 --- a/internal/models/dto/action_dto.go +++ b/internal/models/dto/action_dto.go @@ -32,6 +32,17 @@ func (a ActionDTORequest) ToModelAction() actions.Action { Instances: a.Instances, } + if actions.ActionOperation(a.Operation) == actions.Scan { + target.TargetType = "Account" + if a.AccountID != "" { + target.TargetAccountIDs = []string{a.AccountID} + } else { + target.SelectAll = true + } + } else { + target.TargetType = "Cluster" + } + switch actions.ActionType(a.Type) { case actions.ScheduledActionType: action := actions.NewScheduledAction( @@ -89,28 +100,36 @@ func ToModelActionList(dtos []ActionDTORequest) *[]actions.Action { // ActionDTOResponse represents the data transfer object for an action response, // containing action details including schedule, cron expression, and target resources. type ActionDTOResponse struct { - ID string `json:"id"` - Type string `json:"type"` - Time time.Time `json:"time"` - CronExp string `json:"cronExpression"` - Operation string `json:"operation"` - Status string `json:"status"` - Enabled bool `json:"enabled"` - ClusterID string `json:"clusterId"` - Region string `json:"region"` - AccountID string `json:"accountId"` - Instances []string `json:"instances"` - Requester string `json:"requester"` - Description *string `json:"description"` + ID string `json:"id"` + Type string `json:"type"` + Time time.Time `json:"time"` + CronExp string `json:"cronExpression"` + Operation string `json:"operation"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + TargetType string `json:"targetType"` + SelectAll bool `json:"selectAll"` + ClusterID string `json:"clusterId"` + ClusterName string `json:"clusterName"` + Region string `json:"region"` + AccountID string `json:"accountId"` + Instances []string `json:"instances"` + TargetAccountIDs []string `json:"targetAccountIds"` + TargetAccountNames []string `json:"targetAccountNames"` + Requester string `json:"requester"` + Description *string `json:"description"` } // @name ActionResponse // ToModelAction converts ActionDTOResponse to actions.Action func (a ActionDTOResponse) ToModelAction() actions.Action { target := actions.ActionTarget{ - AccountID: a.AccountID, - Region: a.Region, - ClusterID: a.ClusterID, - Instances: a.Instances, + AccountID: a.AccountID, + Region: a.Region, + ClusterID: a.ClusterID, + Instances: a.Instances, + TargetType: a.TargetType, + SelectAll: a.SelectAll, + TargetAccountIDs: a.TargetAccountIDs, } switch actions.ActionType(a.Type) { diff --git a/internal/models/dto/action_run_dto.go b/internal/models/dto/action_run_dto.go new file mode 100644 index 00000000..02c9d801 --- /dev/null +++ b/internal/models/dto/action_run_dto.go @@ -0,0 +1,20 @@ +package dto + +import "time" + +// ActionRunDTORequest represents a request to create or update an action run. +type ActionRunDTORequest struct { + ScheduleID string `json:"scheduleId" binding:"required"` + Status string `json:"status"` + ErrorMsg string `json:"errorMsg"` +} // @name ActionRunRequest + +// ActionRunDTOResponse represents the response for an action execution record. +type ActionRunDTOResponse struct { + ID string `json:"id"` + ScheduleID string `json:"scheduleId"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + Status string `json:"status"` + ErrorMsg string `json:"errorMsg"` +} // @name ActionRunResponse diff --git a/internal/models/dto/event_dto.go b/internal/models/dto/event_dto.go index 210fd143..7287d47b 100644 --- a/internal/models/dto/event_dto.go +++ b/internal/models/dto/event_dto.go @@ -16,7 +16,7 @@ type EventDTORequest struct { EventTimestamp time.Time `json:"timestamp"` Result string `json:"result"` Severity string `json:"severity"` - TriggeredBy string `json:"triggeredBy"` + Requester string `json:"requester"` Description *string `json:"description,omitempty"` } // @name EventRequest @@ -30,7 +30,7 @@ func (e EventDTORequest) ToModelEvent() *events.Event { ResourceType: e.ResourceType, Result: e.Result, Severity: e.Severity, - TriggeredBy: e.TriggeredBy, + Requester: e.Requester, } } // @name EventResponse @@ -43,13 +43,16 @@ type ClusterEventDTOResponse struct { EventTimestamp time.Time `json:"timestamp"` Result actions.ActionStatus `json:"result"` Severity string `json:"severity"` - TriggeredBy string `json:"triggeredBy"` + Requester string `json:"requester"` Description *string `json:"description,omitempty"` + ScheduleID *int64 `json:"scheduleId,omitempty"` } // @name ClusterEventResponse // SystemEvent represents a system-level event, extending a cluster event with account details. type SystemEventDTOResponse struct { ClusterEventDTOResponse - AccountID string `json:"accountId"` - Provider string `json:"provider"` + ResourceName string `json:"resourceName"` + AccountID string `json:"accountId"` + AccountName string `json:"accountName"` + Provider string `json:"provider"` } // @name SystemEventResponse diff --git a/internal/models/dto/event_dto_test.go b/internal/models/dto/event_dto_test.go index b6a90d7a..fccf8730 100644 --- a/internal/models/dto/event_dto_test.go +++ b/internal/models/dto/event_dto_test.go @@ -27,7 +27,7 @@ func testEventDTORequest_ToModelEvent_Correct(t *testing.T) { EventTimestamp: now, Result: "Success", Severity: "Info", - TriggeredBy: "scanner", + Requester: "scanner", Description: &desc, } @@ -42,7 +42,7 @@ func testEventDTORequest_ToModelEvent_Correct(t *testing.T) { assert.Equal(t, dto.ResourceType, event.ResourceType) assert.Equal(t, dto.Result, event.Result) assert.Equal(t, dto.Severity, event.Severity) - assert.Equal(t, dto.TriggeredBy, event.TriggeredBy) + assert.Equal(t, dto.Requester, event.Requester) } // TestEventDTORequest_ToModelEvent_NilDescription verifies nil description handling. @@ -61,7 +61,7 @@ func testEventDTORequest_ToModelEvent_NilDescription(t *testing.T) { EventTimestamp: now, Result: "Failed", Severity: "Error", - TriggeredBy: "agent", + Requester: "agent", Description: nil, } @@ -90,7 +90,7 @@ func testClusterEventDTOResponse_Struct(t *testing.T) { EventTimestamp: now, Result: "Success", Severity: "Info", - TriggeredBy: "api", + Requester: "api", Description: &desc, } @@ -101,7 +101,7 @@ func testClusterEventDTOResponse_Struct(t *testing.T) { assert.Equal(t, now, dto.EventTimestamp) assert.Equal(t, actions.StatusSuccess, dto.Result) assert.Equal(t, "Info", dto.Severity) - assert.Equal(t, "api", dto.TriggeredBy) + assert.Equal(t, "api", dto.Requester) assert.Equal(t, &desc, dto.Description) } @@ -119,7 +119,7 @@ func testSystemEventDTOResponse_Struct(t *testing.T) { EventTimestamp: now, Result: "Failed", Severity: "Error", - TriggeredBy: "scheduler", + Requester: "scheduler", Description: &desc, }, AccountID: "acc-1", @@ -133,7 +133,7 @@ func testSystemEventDTOResponse_Struct(t *testing.T) { assert.Equal(t, now, dto.EventTimestamp) assert.Equal(t, actions.StatusFailed, dto.Result) assert.Equal(t, "Error", dto.Severity) - assert.Equal(t, "scheduler", dto.TriggeredBy) + assert.Equal(t, "scheduler", dto.Requester) assert.Equal(t, &desc, dto.Description) assert.Equal(t, "acc-1", dto.AccountID) assert.Equal(t, "AWS", dto.Provider) diff --git a/internal/models/dto/overview_dto.go b/internal/models/dto/overview_dto.go index 1f3dacb0..288069f5 100644 --- a/internal/models/dto/overview_dto.go +++ b/internal/models/dto/overview_dto.go @@ -6,12 +6,28 @@ import ( "github.com/RHEcosystemAppEng/cluster-iq/internal/inventory" ) +// TopItem represents a ranked item with a name and cluster count. +type TopItem struct { + Name string `json:"name"` + ClusterCount int `json:"clusterCount"` +} // @name TopItem + +// AccountCost represents an account with its current month cost. +type AccountCost struct { + AccountName string `json:"accountName"` + CurrentMonthCost float64 `json:"currentMonthCost"` +} // @name AccountCost + // OverviewSummary represents the comprehensive overview of the system's inventory. type OverviewSummary struct { - Clusters ClusterSummary `json:"clusters"` - Instances InstancesSummary `json:"instances"` - Providers ProvidersSummary `json:"providers"` - Scanner Scanner `json:"scanner"` + Clusters ClusterSummary `json:"clusters"` + Instances InstancesSummary `json:"instances"` + Providers ProvidersSummary `json:"providers"` + Scanner Scanner `json:"scanner"` + TopRegions []TopItem `json:"topRegions"` + TopOwners []TopItem `json:"topOwners"` + ClustersByPartner []TopItem `json:"clustersByPartner"` + CostPerAccount []AccountCost `json:"costPerAccount"` } // @name OverviewSummary // ClusterSummary provides a summary of cluster counts by status. @@ -49,10 +65,14 @@ type Scanner struct { // ToOverviewSummaryDTO converts an inventory OverviewSummary to a DTO. func ToOverviewSummaryDTO(model inventory.OverviewSummary) OverviewSummary { return OverviewSummary{ - Clusters: toClusterSummaryDTO(model.Clusters), - Instances: toInstancesSummaryDTO(model.Instances), - Providers: toProvidersSummaryDTO(model.Providers), - Scanner: toScannerDTO(model.Scanner), + Clusters: toClusterSummaryDTO(model.Clusters), + Instances: toInstancesSummaryDTO(model.Instances), + Providers: toProvidersSummaryDTO(model.Providers), + Scanner: toScannerDTO(model.Scanner), + TopRegions: toTopItemsDTO(model.TopRegions), + TopOwners: toTopItemsDTO(model.TopOwners), + ClustersByPartner: toTopItemsDTO(model.ClustersByPartner), + CostPerAccount: toAccountCostsDTO(model.CostPerAccount), } } @@ -92,3 +112,25 @@ func toScannerDTO(model inventory.Scanner) Scanner { LastScanTimestamp: model.LastScanTimestamp, } } + +func toTopItemsDTO(items []inventory.TopItem) []TopItem { + result := make([]TopItem, len(items)) + for i, item := range items { + result[i] = TopItem{ + Name: item.Name, + ClusterCount: item.ClusterCount, + } + } + return result +} + +func toAccountCostsDTO(costs []inventory.AccountCost) []AccountCost { + result := make([]AccountCost, len(costs)) + for i, cost := range costs { + result[i] = AccountCost{ + AccountName: cost.AccountName, + CurrentMonthCost: cost.CurrentMonthCost, + } + } + return result +} diff --git a/internal/models/dto/overview_dto_test.go b/internal/models/dto/overview_dto_test.go index c1d9def1..b37d6944 100644 --- a/internal/models/dto/overview_dto_test.go +++ b/internal/models/dto/overview_dto_test.go @@ -35,6 +35,10 @@ func testToOverviewSummaryDTO_Correct(t *testing.T) { Scanner: inventory.Scanner{ LastScanTimestamp: now, }, + TopRegions: []inventory.TopItem{{Name: "us-east-1", ClusterCount: 8}}, + TopOwners: []inventory.TopItem{{Name: "jsmith", ClusterCount: 5}}, + ClustersByPartner: []inventory.TopItem{{Name: "Acme", ClusterCount: 3}}, + CostPerAccount: []inventory.AccountCost{{AccountName: "my-account", CurrentMonthCost: 1234.56}}, } dto := ToOverviewSummaryDTO(model) @@ -54,8 +58,21 @@ func testToOverviewSummaryDTO_Correct(t *testing.T) { assert.Equal(t, 5, dto.Providers.Azure.AccountCount) assert.Equal(t, 6, dto.Providers.Azure.ClusterCount) - // inventory.Scanner uses *time.Time, DTO uses time.Time assert.Equal(t, now, dto.Scanner.LastScanTimestamp) + + assert.Len(t, dto.TopRegions, 1) + assert.Equal(t, "us-east-1", dto.TopRegions[0].Name) + assert.Equal(t, 8, dto.TopRegions[0].ClusterCount) + + assert.Len(t, dto.TopOwners, 1) + assert.Equal(t, "jsmith", dto.TopOwners[0].Name) + + assert.Len(t, dto.ClustersByPartner, 1) + assert.Equal(t, "Acme", dto.ClustersByPartner[0].Name) + + assert.Len(t, dto.CostPerAccount, 1) + assert.Equal(t, "my-account", dto.CostPerAccount[0].AccountName) + assert.InDelta(t, 1234.56, dto.CostPerAccount[0].CurrentMonthCost, 0.01) } // TestToClusterSummaryDTO verifies toClusterSummaryDTO conversion. @@ -133,3 +150,47 @@ func testToScannerDTO_Correct(t *testing.T) { assert.Equal(t, now, dto.LastScanTimestamp) } + +// TestToTopItemsDTO verifies toTopItemsDTO conversion. +func TestToTopItemsDTO(t *testing.T) { + t.Run("Convert TopItems", func(t *testing.T) { + items := []inventory.TopItem{ + {Name: "us-east-1", ClusterCount: 8}, + {Name: "eu-west-1", ClusterCount: 3}, + } + result := toTopItemsDTO(items) + + assert.Len(t, result, 2) + assert.Equal(t, "us-east-1", result[0].Name) + assert.Equal(t, 8, result[0].ClusterCount) + assert.Equal(t, "eu-west-1", result[1].Name) + assert.Equal(t, 3, result[1].ClusterCount) + }) + + t.Run("Convert empty TopItems", func(t *testing.T) { + result := toTopItemsDTO([]inventory.TopItem{}) + assert.Len(t, result, 0) + }) +} + +// TestToAccountCostsDTO verifies toAccountCostsDTO conversion. +func TestToAccountCostsDTO(t *testing.T) { + t.Run("Convert AccountCosts", func(t *testing.T) { + costs := []inventory.AccountCost{ + {AccountName: "prod", CurrentMonthCost: 5000.50}, + {AccountName: "dev", CurrentMonthCost: 1200.00}, + } + result := toAccountCostsDTO(costs) + + assert.Len(t, result, 2) + assert.Equal(t, "prod", result[0].AccountName) + assert.InDelta(t, 5000.50, result[0].CurrentMonthCost, 0.01) + assert.Equal(t, "dev", result[1].AccountName) + assert.InDelta(t, 1200.00, result[1].CurrentMonthCost, 0.01) + }) + + t.Run("Convert empty AccountCosts", func(t *testing.T) { + result := toAccountCostsDTO([]inventory.AccountCost{}) + assert.Len(t, result, 0) + }) +} diff --git a/internal/models/dto/scheduled_action_dto.go b/internal/models/dto/scheduled_action_dto.go index 723847fb..4fd3be30 100644 --- a/internal/models/dto/scheduled_action_dto.go +++ b/internal/models/dto/scheduled_action_dto.go @@ -4,10 +4,10 @@ import "time" // ActionTarget represents the target resource information for an action. type ActionTarget struct { - AccountName string `json:"accountName"` - Region string `json:"region"` - ClusterID string `json:"clusterId"` - Instances []string `json:"instances"` + AccountID string `json:"accountId"` + Region string `json:"region"` + ClusterID string `json:"clusterId"` + Instances []string `json:"instances"` } // @name ActionTarget // ScheduledAction represents the data transfer object for a scheduled action. diff --git a/internal/models/dto/wrappers.go b/internal/models/dto/wrappers.go index 5ae29bd7..14daf5a1 100644 --- a/internal/models/dto/wrappers.go +++ b/internal/models/dto/wrappers.go @@ -45,3 +45,9 @@ type SystemEventListResponse struct { type ClusterEventListResponse struct { responsetypes.ListResponse[ClusterEventDTOResponse] } // @name ClusterEventListResponse + +// ActionRunListResponse wraps a paginated list of ActionRunDTOResponse items +// for OpenAPI schema generation. +type ActionRunListResponse struct { + responsetypes.ListResponse[ActionRunDTOResponse] +} // @name ActionRunListResponse diff --git a/internal/repositories/account_repository.go b/internal/repositories/account_repository.go index 8d86995d..953b1fc7 100644 --- a/internal/repositories/account_repository.go +++ b/internal/repositories/account_repository.go @@ -50,8 +50,10 @@ type AccountRepository interface { CountAccounts(ctx context.Context, opts models.ListOptions) (int, error) GetAccountByID(ctx context.Context, accountID string) (db.AccountDBResponse, error) GetAccountClustersByID(ctx context.Context, accountID string) ([]db.ClusterDBResponse, error) - GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstanceDBResponse, error) + GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) GetScannerTimestamp(ctx context.Context) (time.Time, error) + GetCostPerAccount(ctx context.Context) ([]inventory.AccountCost, error) + GetDailyCosts(ctx context.Context, accountID string) ([]db.DailyCostDBResponse, error) CreateAccount(ctx context.Context, accounts []inventory.Account) error UpdateAccount(ctx context.Context, accountID string, patch dto.AccountPatchRequest) error DeleteAccount(ctx context.Context, accountID string) error @@ -153,10 +155,6 @@ func (r *accountRepositoryImpl) GetAccountClustersByID(ctx context.Context, acco return clusters, err } - if len(clusters) == 0 { - return clusters, ErrNoClustersInAccount - } - return clusters, nil } @@ -167,8 +165,8 @@ func (r *accountRepositoryImpl) GetAccountClustersByID(ctx context.Context, acco // Returns: // - A slice of inventory.Instance objects. // - An error if the query fails. -func (r *accountRepositoryImpl) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstanceDBResponse, error) { - instances := []db.InstanceDBResponse{} +func (r *accountRepositoryImpl) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) { + instances := []db.InstancePendingExpenseDB{} opts := models.ListOptions{ PageSize: 0, @@ -185,6 +183,43 @@ func (r *accountRepositoryImpl) GetExpenseUpdateInstances(ctx context.Context, a return instances, nil } +// GetCostPerAccount returns all accounts with their current month cost. +func (r *accountRepositoryImpl) GetCostPerAccount(ctx context.Context) ([]inventory.AccountCost, error) { + var costs []inventory.AccountCost + query := `SELECT account_name, current_month_so_far_cost + FROM m_accounts_full_view + ORDER BY current_month_so_far_cost DESC` + if err := r.db.QuerySelectContext(ctx, &costs, query); err != nil { + return nil, fmt.Errorf("failed to get cost per account: %w", err) + } + return costs, nil +} + +// GetDailyCosts returns the aggregated daily costs for an account over the last 6 months. +func (r *accountRepositoryImpl) GetDailyCosts(ctx context.Context, accountID string) ([]db.DailyCostDBResponse, error) { + if _, err := r.GetAccountByID(ctx, accountID); err != nil { + return nil, err + } + + var costs []db.DailyCostDBResponse + query := ` + SELECT e.date, SUM(e.amount) AS amount + FROM expenses e + JOIN instances i ON e.instance_id = i.id + JOIN clusters c ON i.cluster_id = c.id + JOIN accounts a ON c.account_id = a.id + WHERE a.account_id = $1 + AND e.date >= CURRENT_DATE - INTERVAL '6 months' + GROUP BY e.date + ORDER BY e.date ASC` + + if err := r.db.QuerySelectContext(ctx, &costs, query, accountID); err != nil { + return nil, fmt.Errorf("failed to get daily costs for account %s: %w", accountID, err) + } + + return costs, nil +} + // Create inserts multiple accounts into the database in a transaction. // // Parameters: diff --git a/internal/repositories/action_repository.go b/internal/repositories/action_repository.go index d79771aa..6197ab8d 100644 --- a/internal/repositories/action_repository.go +++ b/internal/repositories/action_repository.go @@ -33,60 +33,31 @@ const ( enabled = false WHERE id = $1 ` - // InsertAction inserts a new action returning the ID - InsertActionsQuery = ` - INSERT INTO schedule ( - type, - time, - operation, - target, - status, - enabled - ) VALUES ( - :type, - (SELECT now()), - :operation, - (SELECT id FROM clusters WHERE cluster_id = :target.cluster_id), - :status, - :enabled - ) RETURNING id + + InsertTargetQuery = `INSERT INTO targets (target_type, select_all) VALUES ($1, $2) RETURNING id` + + LinkTargetClusterQuery = `INSERT INTO target_clusters (target_id, cluster_id) SELECT $1, id FROM clusters WHERE cluster_id = $2` + + LinkTargetAccountQuery = `INSERT INTO target_accounts (target_id, account_id) SELECT $1, id FROM accounts WHERE account_id = $2` + + InsertScheduledActionWithTargetQuery = ` + INSERT INTO schedule (type, time, operation, target, status, enabled, requester, description) + VALUES ('scheduled_action', $1, $2, $3, $4, $5, $6, $7) + RETURNING id ` - // InsertScheduledActionQuery inserts new scheduled actions on the DB - InsertScheduledActionsQuery = ` - INSERT INTO schedule ( - type, - time, - operation, - target, - status, - enabled - ) VALUES ( - 'scheduled_action', - :time, - :operation, - (SELECT id FROM clusters WHERE cluster_id=:target.cluster_id), - :status, - :enabled - ) + + InsertCronActionWithTargetQuery = ` + INSERT INTO schedule (type, cron_exp, operation, target, status, enabled, requester, description) + VALUES ('cron_action', $1, $2, $3, $4, $5, $6, $7) + RETURNING id ` - // InsertCronActionQuery inserts new Cron actions on the DB - InsertCronActionsQuery = ` - INSERT INTO schedule ( - type, - cron_exp, - operation, - target, - status, - enabled - ) VALUES ( - 'cron_action', - :cron_exp, - :operation, - (SELECT id FROM clusters WHERE cluster_id=:target.cluster_id), - :status, - :enabled - ) + + InsertInstantActionWithTargetQuery = ` + INSERT INTO schedule (type, time, operation, target, status, enabled, requester, description) + VALUES ('instant_action', NOW(), $1, $2, $3, $4, $5, $6) + RETURNING id ` + // UpdateActionQuery updates a single action on the DB UpdateActionQuery = ` UPDATE schedule @@ -192,11 +163,7 @@ func (r *actionRepositoryImpl) GetByID(ctx context.Context, actionID string) (db // // Returns: // - An error if the insert fails -// -// TODO: Temporal fix returning TX from DBClient to manage both insertions in the same sql transaction func (r *actionRepositoryImpl) Create(ctx context.Context, newActions []actions.Action) (err error) { - schedActions, cronActions := actions.SplitActionsByType(newActions) - tx, err := r.db.NewTx(ctx) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) @@ -207,33 +174,97 @@ func (r *actionRepositoryImpl) Create(ctx context.Context, newActions []actions. } }() - // Writing Scheduled Actions - if len(schedActions) > 0 { - if _, err := tx.NamedExecContext(ctx, InsertScheduledActionsQuery, schedActions); err != nil { - return fmt.Errorf("failed to insert scheduled actions: %w", err) + for _, action := range newActions { + targetID, targetErr := createTargetForAction(ctx, tx, action) + if targetErr != nil { + return fmt.Errorf("failed to create target: %w", targetErr) } - } - // Writing Cron Actions - if len(cronActions) > 0 { - if _, err := tx.NamedExecContext(ctx, InsertCronActionsQuery, cronActions); err != nil { - return fmt.Errorf("failed to insert cron actions: %w", err) + switch a := action.(type) { + case *actions.ScheduledAction: + _, err = tx.ExecContext(ctx, InsertScheduledActionWithTargetQuery, + a.When, a.Operation, targetID, a.Status, a.Enabled, a.Requester, a.Description) + case *actions.CronAction: + _, err = tx.ExecContext(ctx, InsertCronActionWithTargetQuery, + a.Expression, a.Operation, targetID, a.Status, a.Enabled, a.Requester, a.Description) + case *actions.InstantAction: + _, err = tx.ExecContext(ctx, InsertInstantActionWithTargetQuery, + a.Operation, targetID, a.Status, a.Enabled, a.Requester, a.Description) + default: + return fmt.Errorf("unsupported action type for batch create: %T", action) + } + if err != nil { + return fmt.Errorf("failed to insert schedule: %w", err) } } - // Commit the transaction return tx.Commit() } -// AddEvent inserts a new audit event into the database and returns the event ID. func (r *actionRepositoryImpl) CreateAction(ctx context.Context, action actions.Action) (int64, error) { - var returnedValue int64 - returnedValue, err := r.db.InsertWithReturnWithContext(ctx, InsertActionsQuery, action) + tx, err := r.db.NewTx(ctx) if err != nil { - return -1, err + return -1, fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + targetID, err := createTargetForAction(ctx, tx, action) + if err != nil { + return -1, fmt.Errorf("failed to create target: %w", err) + } + + var scheduleID int64 + err = tx.QueryRowContext(ctx, InsertInstantActionWithTargetQuery, + action.GetActionOperation(), targetID, action.(*actions.InstantAction).Status, action.(*actions.InstantAction).Enabled, + action.GetRequester(), action.GetDescription(), + ).Scan(&scheduleID) + if err != nil { + return -1, fmt.Errorf("failed to insert action: %w", err) + } + + if err = tx.Commit(); err != nil { + return -1, fmt.Errorf("failed to commit action: %w", err) + } + + return scheduleID, nil +} + +func createTargetForAction(ctx context.Context, tx interface { + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) +}, action actions.Action) (int64, error) { + target := action.GetTarget() + + targetType := target.TargetType + if targetType == "" { + targetType = "Cluster" + } + + var targetID int64 + if err := tx.QueryRowContext(ctx, InsertTargetQuery, targetType, target.SelectAll).Scan(&targetID); err != nil { + return 0, fmt.Errorf("failed to insert target: %w", err) + } + + switch targetType { + case "Cluster": + if target.ClusterID != "" { + if _, err := tx.ExecContext(ctx, LinkTargetClusterQuery, targetID, target.ClusterID); err != nil { + return 0, fmt.Errorf("failed to link target cluster: %w", err) + } + } + case "Account": + for _, accountID := range target.TargetAccountIDs { + if _, err := tx.ExecContext(ctx, LinkTargetAccountQuery, targetID, accountID); err != nil { + return 0, fmt.Errorf("failed to link target account: %w", err) + } + } } - return returnedValue, nil + return targetID, nil } // Delete removes an actions.ScheduledAction action from the DB based on its ID diff --git a/internal/repositories/action_run_repository.go b/internal/repositories/action_run_repository.go new file mode 100644 index 00000000..1bed2316 --- /dev/null +++ b/internal/repositories/action_run_repository.go @@ -0,0 +1,105 @@ +package repositories + +import ( + "context" + "database/sql" + "errors" + "fmt" + + dbclient "github.com/RHEcosystemAppEng/cluster-iq/internal/db_client" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/db" +) + +const ( + ActionRunsTable = "action_runs" + + InsertActionRunQuery = ` + INSERT INTO action_runs (schedule_id) + VALUES ($1) + RETURNING id + ` + + UpdateActionRunQuery = ` + UPDATE action_runs + SET + status = $1, + finished_at = NOW(), + error_msg = $2 + WHERE id = $3 + ` +) + +var _ ActionRunRepository = (*actionRunRepositoryImpl)(nil) + +// ActionRunRepository defines the interface for action run data access operations. +type ActionRunRepository interface { + List(ctx context.Context, opts models.ListOptions) ([]db.ActionRunDBResponse, int, error) + GetByID(ctx context.Context, runID string) (db.ActionRunDBResponse, error) + Create(ctx context.Context, scheduleID string) (int64, error) + Update(ctx context.Context, runID string, status string, errorMsg string) error +} + +type actionRunRepositoryImpl struct { + db *dbclient.DBClient +} + +func NewActionRunRepository(db *dbclient.DBClient) ActionRunRepository { + return &actionRunRepositoryImpl{db: db} +} + +func (r *actionRunRepositoryImpl) List(ctx context.Context, opts models.ListOptions) ([]db.ActionRunDBResponse, int, error) { + runs := []db.ActionRunDBResponse{} + + if err := r.db.SelectWithContext(ctx, &runs, ActionRunsTable, opts, "id", "*"); err != nil { + return runs, 0, fmt.Errorf("failed to list action runs: %w", err) + } + + return runs, len(runs), nil +} + +func (r *actionRunRepositoryImpl) GetByID(ctx context.Context, runID string) (db.ActionRunDBResponse, error) { + var run db.ActionRunDBResponse + + opts := models.ListOptions{ + Filters: map[string]interface{}{ + "id": runID, + }, + } + + if err := r.db.GetWithContext(ctx, &run, ActionRunsTable, opts, "id", "*"); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return run, ErrNotFound + } + return run, err + } + + return run, nil +} + +func (r *actionRunRepositoryImpl) Create(ctx context.Context, scheduleID string) (int64, error) { + var id int64 + err := r.db.QueryRowContext(ctx, &id, InsertActionRunQuery, scheduleID) + if err != nil { + return -1, fmt.Errorf("failed to create action run: %w", err) + } + return id, nil +} + +func (r *actionRunRepositoryImpl) Update(ctx context.Context, runID string, status string, errorMsg string) error { + tx, err := r.db.NewTx(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + if _, err = tx.ExecContext(ctx, UpdateActionRunQuery, status, errorMsg, runID); err != nil { + return fmt.Errorf("failed to update action run: %w", err) + } + + return tx.Commit() +} diff --git a/internal/repositories/cluster_repository.go b/internal/repositories/cluster_repository.go index e2fd1cb6..81a38f93 100644 --- a/internal/repositories/cluster_repository.go +++ b/internal/repositories/cluster_repository.go @@ -74,6 +74,9 @@ type ClusterRepository interface { GetClustersOnAccount(ctx context.Context, accountName string) ([]db.ClusterDBResponse, error) GetInstancesOnCluster(ctx context.Context, clusterID string) ([]db.InstanceDBResponse, error) GetClustersOverview(ctx context.Context) (inventory.ClustersSummary, error) + GetTopRegions(ctx context.Context, limit int) ([]inventory.TopItem, error) + GetTopOwners(ctx context.Context, limit int) ([]inventory.TopItem, error) + GetClustersByPartner(ctx context.Context) ([]inventory.TopItem, error) CreateClusters(ctx context.Context, clusters []inventory.Cluster) error UpdateCluster(ctx context.Context, clusterID string, patch dto.ClusterPatchRequest) error UpdateClusterStatusByClusterID(ctx context.Context, status string, clusterID string) error @@ -281,6 +284,52 @@ func (r *clusterRepositoryImpl) GetClustersOverview(ctx context.Context) (invent return countsDB, nil } +// GetTopRegions returns the top N regions by cluster count, excluding terminated clusters. +func (r *clusterRepositoryImpl) GetTopRegions(ctx context.Context, limit int) ([]inventory.TopItem, error) { + var items []inventory.TopItem + query := `SELECT region AS name, COUNT(*) AS cluster_count + FROM clusters + WHERE status != 'Terminated' + GROUP BY region + ORDER BY cluster_count DESC + LIMIT $1` + if err := r.db.QuerySelectContext(ctx, &items, query, limit); err != nil { + return nil, fmt.Errorf("failed to get top regions: %w", err) + } + return items, nil +} + +// GetTopOwners returns the top N owners by cluster count, excluding terminated clusters. +func (r *clusterRepositoryImpl) GetTopOwners(ctx context.Context, limit int) ([]inventory.TopItem, error) { + var items []inventory.TopItem + query := `SELECT owner AS name, COUNT(*) AS cluster_count + FROM clusters + WHERE status != 'Terminated' AND owner != '' + GROUP BY owner + ORDER BY cluster_count DESC + LIMIT $1` + if err := r.db.QuerySelectContext(ctx, &items, query, limit); err != nil { + return nil, fmt.Errorf("failed to get top owners: %w", err) + } + return items, nil +} + +// GetClustersByPartner returns cluster counts grouped by the Partner tag. +func (r *clusterRepositoryImpl) GetClustersByPartner(ctx context.Context) ([]inventory.TopItem, error) { + var items []inventory.TopItem + query := `SELECT t.value AS name, COUNT(DISTINCT c.cluster_id) AS cluster_count + FROM tags t + JOIN instances i ON t.instance_id = i.id + JOIN clusters c ON i.cluster_id = c.id + WHERE t.key = 'Partner' AND c.status != 'Terminated' + GROUP BY t.value + ORDER BY cluster_count DESC` + if err := r.db.QuerySelectContext(ctx, &items, query); err != nil { + return nil, fmt.Errorf("failed to get clusters by partner: %w", err) + } + return items, nil +} + // CreateClusters inserts a list of clusters into the database in a transaction. // // Parameters: diff --git a/internal/repositories/errors.go b/internal/repositories/errors.go index 5e014292..8eb3555c 100644 --- a/internal/repositories/errors.go +++ b/internal/repositories/errors.go @@ -4,6 +4,3 @@ import "errors" // ErrNotFound is returned when a resource is not found in the database. var ErrNotFound = errors.New("requested resource not found") - -// ErrNoClustersInAccount is returned when an account exists but has no associated clusters. -var ErrNoClustersInAccount = errors.New("no clusters found for this account") diff --git a/internal/repositories/event_repository.go b/internal/repositories/event_repository.go index 96373fb1..db08e9ad 100644 --- a/internal/repositories/event_repository.go +++ b/internal/repositories/event_repository.go @@ -21,16 +21,17 @@ const ( InsertEventQuery = ` INSERT INTO events( event_timestamp, - triggered_by, + requester, action, resource_id, resource_type, result, description, - severity + severity, + schedule_id ) VALUES ( CURRENT_TIMESTAMP, - :triggered_by, + :requester, :action, ( CASE @@ -38,12 +39,15 @@ const ( THEN (SELECT id FROM clusters c WHERE c.cluster_id = :resource_id) WHEN :resource_type = 'Instance' THEN (SELECT id FROM instances i WHERE i.instance_id = :resource_id) + WHEN :resource_type = 'Account' + THEN (SELECT id FROM accounts a WHERE a.account_id = :resource_id) END ), :resource_type, :result, :description, - :severity + :severity, + :schedule_id ) RETURNING id ` // UpdateEventStatusQuery updates the result status of an audit log entry based on its ID. diff --git a/internal/services/account_service.go b/internal/services/account_service.go index 4066da40..d6e3c2a5 100644 --- a/internal/services/account_service.go +++ b/internal/services/account_service.go @@ -16,7 +16,8 @@ type AccountService interface { List(ctx context.Context, options models.ListOptions) ([]db.AccountDBResponse, int, error) GetByID(ctx context.Context, accountID string) (db.AccountDBResponse, error) GetAccountClustersByID(ctx context.Context, accountID string) ([]db.ClusterDBResponse, error) - GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstanceDBResponse, error) + GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) + GetDailyCosts(ctx context.Context, accountID string) ([]db.DailyCostDBResponse, error) Create(ctx context.Context, accounts []inventory.Account) error Update(ctx context.Context, accountID string, patch dto.AccountPatchRequest) error Delete(ctx context.Context, accountID string) error @@ -57,7 +58,7 @@ func (s *accountServiceImpl) GetAccountClustersByID(ctx context.Context, account } // GetExpenseUpdateInstances retrieves instances with outdated billing information. -func (s *accountServiceImpl) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstanceDBResponse, error) { +func (s *accountServiceImpl) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) { instances, err := s.repo.GetExpenseUpdateInstances(ctx, accountID) if err != nil { return instances, fmt.Errorf("get expense update instances for account %s: %w", accountID, err) @@ -65,6 +66,15 @@ func (s *accountServiceImpl) GetExpenseUpdateInstances(ctx context.Context, acco return instances, nil } +// GetDailyCosts retrieves the aggregated daily costs for an account over the last 6 months. +func (s *accountServiceImpl) GetDailyCosts(ctx context.Context, accountID string) ([]db.DailyCostDBResponse, error) { + costs, err := s.repo.GetDailyCosts(ctx, accountID) + if err != nil { + return costs, fmt.Errorf("get daily costs for account %s: %w", accountID, err) + } + return costs, nil +} + // Create creates one or more new accounts. func (s *accountServiceImpl) Create(ctx context.Context, accounts []inventory.Account) error { if err := s.repo.CreateAccount(ctx, accounts); err != nil { diff --git a/internal/services/action_run_service.go b/internal/services/action_run_service.go new file mode 100644 index 00000000..ed4b909e --- /dev/null +++ b/internal/services/action_run_service.go @@ -0,0 +1,55 @@ +package services + +import ( + "context" + "fmt" + + "github.com/RHEcosystemAppEng/cluster-iq/internal/models" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/db" + "github.com/RHEcosystemAppEng/cluster-iq/internal/repositories" +) + +// ActionRunService defines the interface for action run business logic. +type ActionRunService interface { + List(ctx context.Context, options models.ListOptions) ([]db.ActionRunDBResponse, int, error) + Get(ctx context.Context, runID string) (db.ActionRunDBResponse, error) + Create(ctx context.Context, scheduleID string) (int64, error) + Update(ctx context.Context, runID string, status string, errorMsg string) error +} + +var _ ActionRunService = (*actionRunServiceImpl)(nil) + +type actionRunServiceImpl struct { + repo repositories.ActionRunRepository +} + +func NewActionRunService(repo repositories.ActionRunRepository) ActionRunService { + return &actionRunServiceImpl{repo: repo} +} + +func (s *actionRunServiceImpl) List(ctx context.Context, options models.ListOptions) ([]db.ActionRunDBResponse, int, error) { + return s.repo.List(ctx, options) +} + +func (s *actionRunServiceImpl) Get(ctx context.Context, runID string) (db.ActionRunDBResponse, error) { + run, err := s.repo.GetByID(ctx, runID) + if err != nil { + return run, fmt.Errorf("get action run %s: %w", runID, err) + } + return run, nil +} + +func (s *actionRunServiceImpl) Create(ctx context.Context, scheduleID string) (int64, error) { + id, err := s.repo.Create(ctx, scheduleID) + if err != nil { + return -1, fmt.Errorf("create action run: %w", err) + } + return id, nil +} + +func (s *actionRunServiceImpl) Update(ctx context.Context, runID string, status string, errorMsg string) error { + if err := s.repo.Update(ctx, runID, status, errorMsg); err != nil { + return fmt.Errorf("update action run %s: %w", runID, err) + } + return nil +} diff --git a/internal/services/overview_service.go b/internal/services/overview_service.go index 14e39bda..6292ee2d 100644 --- a/internal/services/overview_service.go +++ b/internal/services/overview_service.go @@ -9,6 +9,8 @@ import ( "github.com/RHEcosystemAppEng/cluster-iq/internal/repositories" ) +const defaultTopLimit = 5 + // OverviewService defines the interface for overview-related business logic. type OverviewService interface { GetOverview(ctx context.Context) (inventory.OverviewSummary, error) @@ -60,6 +62,30 @@ func (s *overviewServiceImpl) GetOverview(ctx context.Context) (inventory.Overvi } overview.Scanner.LastScanTimestamp = scannerTimestamp + topRegions, err := s.clusterRepo.GetTopRegions(ctx, defaultTopLimit) + if err != nil { + return inventory.OverviewSummary{}, fmt.Errorf("failed to get top regions: %w", err) + } + overview.TopRegions = topRegions + + topOwners, err := s.clusterRepo.GetTopOwners(ctx, defaultTopLimit) + if err != nil { + return inventory.OverviewSummary{}, fmt.Errorf("failed to get top owners: %w", err) + } + overview.TopOwners = topOwners + + clustersByPartner, err := s.clusterRepo.GetClustersByPartner(ctx) + if err != nil { + return inventory.OverviewSummary{}, fmt.Errorf("failed to get clusters by partner: %w", err) + } + overview.ClustersByPartner = clustersByPartner + + costPerAccount, err := s.accountRepo.GetCostPerAccount(ctx) + if err != nil { + return inventory.OverviewSummary{}, fmt.Errorf("failed to get cost per account: %w", err) + } + overview.CostPerAccount = costPerAccount + return overview, nil } diff --git a/internal/stocker/aws_billing_stocker.go b/internal/stocker/aws_billing_stocker.go index f8ae4a4f..cc8c197a 100644 --- a/internal/stocker/aws_billing_stocker.go +++ b/internal/stocker/aws_billing_stocker.go @@ -19,19 +19,17 @@ type AWSBillingStocker struct { logger *zap.Logger // AWS connection interface conn *cp.AWSConnection - // List of instances to obtain its expenses - Instances []inventory.Instance + // List of instance IDs to obtain their expenses + InstanceIDs []string } // NewAWSBillingStocker create and returns a pointer to a new AWSBillingStocker instance -func NewAWSBillingStocker(account *inventory.Account, logger *zap.Logger, instances []inventory.Instance) *AWSBillingStocker { - // Check if there are instances to get billing information - if len(instances) == 0 { - logger.Error("No instances to get billing information") +func NewAWSBillingStocker(account *inventory.Account, logger *zap.Logger, instanceIDs []string) *AWSBillingStocker { + if len(instanceIDs) == 0 { + logger.Info("No instances pending billing update, skipping billing stocker") return nil } - // Leaving the region empty forces to the AWSConnection to use the default region until a new one is configured conn, err := cp.NewAWSConnection(account.User(), account.Password(), "", cp.WithCostExplorer()) if err != nil { logger.Error("Error creating a new AWSBillingStocker", zap.String("account", account.AccountName), zap.Error(err)) @@ -39,10 +37,10 @@ func NewAWSBillingStocker(account *inventory.Account, logger *zap.Logger, instan } return &AWSBillingStocker{ - Account: account, - logger: logger, - Instances: instances, - conn: conn, + Account: account, + logger: logger, + InstanceIDs: instanceIDs, + conn: conn, } } @@ -59,9 +57,9 @@ func (s *AWSBillingStocker) MakeStock() error { cluster := s.Account.Clusters[i] for j := range cluster.Instances { instance := &cluster.Instances[j] - for _, targetInstance := range s.Instances { - if targetInstance.InstanceID == instance.InstanceID { - s.logger.Info("Getting expenses for instance", zap.String("instance_id", targetInstance.InstanceID)) + for _, targetID := range s.InstanceIDs { + if targetID == instance.InstanceID { + s.logger.Info("Getting expenses for instance", zap.String("instance_id", targetID)) err := s.getInstanceExpenses(instance) if err != nil { s.logger.Error("Error querying billing info for an instance", @@ -69,7 +67,6 @@ func (s *AWSBillingStocker) MakeStock() error { zap.String("instance_id", instance.InstanceID), zap.String("error", err.Error()), ) - // Continue to the next region even if an error occurs continue } break @@ -83,11 +80,11 @@ func (s *AWSBillingStocker) MakeStock() error { // getInstanceExpenses gets from the AWS CostExplorer API the expenses of a given Instance. func (s *AWSBillingStocker) getInstanceExpenses(instance *inventory.Instance) error { - // Logic for Setting the period to fetch the Expenses within - // End date is equivalent to today's date - startDate := time.Now().AddDate(0, 0, -14).Format("2006-01-02") - // Start date is equivalent to today's date - endDate := time.Now().Format("2006-01-02") + // 14-day rolling window: the maximum range AWS Cost Explorer supports at daily resource-level granularity. + // UTC is required because AWS Cost Explorer uses UTC internally for date boundaries. + now := time.Now().UTC() + startDate := now.AddDate(0, 0, -14).Format("2006-01-02") + endDate := now.Format("2006-01-02") s.logger.Debug("Getting expenses for instance", zap.String("account", s.Account.AccountName), @@ -136,8 +133,12 @@ func (s *AWSBillingStocker) getInstanceExpenses(instance *inventory.Instance) er return err } - // Getting Expense Date as Time + // AWS Cost Explorer DateInterval uses pattern (\d{4}-\d{2}-\d{2})(T\d{2}:\d{2}:\d{2}Z)? + // DAILY granularity typically returns "YYYY-MM-DD" but may include "T00:00:00Z". expenseDate, err := time.Parse(time.RFC3339, *resultByTime.TimePeriod.Start) + if err != nil { + expenseDate, err = time.Parse("2006-01-02", *resultByTime.TimePeriod.Start) + } if err != nil { s.logger.Error("Error parsing start date", zap.String("account", s.Account.AccountName), @@ -146,11 +147,9 @@ func (s *AWSBillingStocker) getInstanceExpenses(instance *inventory.Instance) er return err } + // NewExpense always returns a valid pointer; negative amounts are clamped to 0.0 + // by the constructor. AWS Cost Explorer does not return negative costs for instances. expense := inventory.NewExpense(instance.InstanceID, amount, expenseDate) - if expense == nil { - s.logger.Error("error creating expense during billing scan. Check if amount is lower than 0.0") - continue - } if err := instance.AddExpense(expense); err != nil { s.logger.Error("error when adding an expense to an instance", zap.String("instance_id", instance.InstanceID), @@ -165,11 +164,6 @@ func (s *AWSBillingStocker) getInstanceExpenses(instance *inventory.Instance) er return nil } -// PrintStock prints the stock (account) of the AWSBillingStocker as a string -func (s AWSBillingStocker) PrintStock() { - s.Account.PrintAccount() -} - // GetAccount returns the account configured for this stocker func (s AWSBillingStocker) GetAccount() inventory.Account { return *s.Account diff --git a/internal/stocker/aws_stocker.go b/internal/stocker/aws_stocker.go index 1fdb7d54..6995fec1 100644 --- a/internal/stocker/aws_stocker.go +++ b/internal/stocker/aws_stocker.go @@ -71,11 +71,6 @@ func (s *AWSStocker) MakeStock() error { return nil } -// PrintStock Prints the Account Stock -func (s AWSStocker) PrintStock() { - s.Account.PrintAccount() -} - // GetAccount Returns the Account was scanned on this stocker func (s AWSStocker) GetAccount() inventory.Account { return *s.Account diff --git a/internal/stocker/azure_stocker.go b/internal/stocker/azure_stocker.go index 9bc6f086..de953c0c 100644 --- a/internal/stocker/azure_stocker.go +++ b/internal/stocker/azure_stocker.go @@ -26,11 +26,6 @@ func (s AzureStocker) MakeStock() error { return fmt.Errorf("AzureStocker.MakeStock not implemented") } -// PrintStock prints by stdout the account object belongs to this stocker -func (s AzureStocker) PrintStock() { - s.Account.PrintAccount() -} - // GetAccount resturns the scanned results on this stocker instance func (s AzureStocker) GetAccount() inventory.Account { return s.Account diff --git a/internal/stocker/gcp_stocker.go b/internal/stocker/gcp_stocker.go index 23587eb6..47052c02 100644 --- a/internal/stocker/gcp_stocker.go +++ b/internal/stocker/gcp_stocker.go @@ -26,11 +26,6 @@ func (s GCPStocker) MakeStock() error { return fmt.Errorf("GCPStocker.MakeStock not implemented") } -// PrintStock prints by stdout the account object belongs to this stocker -func (s GCPStocker) PrintStock() { - s.Account.PrintAccount() -} - // GetAccount resturns the scanned results on this stocker instance func (s GCPStocker) GetAccount() inventory.Account { return s.Account diff --git a/internal/stocker/stocker.go b/internal/stocker/stocker.go index a843e3e1..44dbb183 100644 --- a/internal/stocker/stocker.go +++ b/internal/stocker/stocker.go @@ -5,6 +5,5 @@ import "github.com/RHEcosystemAppEng/cluster-iq/internal/inventory" // Stocker interface type Stocker interface { MakeStock() error - PrintStock() GetAccount() inventory.Account } diff --git a/test/integration/api_action_runs_integration_test.go b/test/integration/api_action_runs_integration_test.go new file mode 100644 index 00000000..ebb226fd --- /dev/null +++ b/test/integration/api_action_runs_integration_test.go @@ -0,0 +1,256 @@ +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + responsetypes "github.com/RHEcosystemAppEng/cluster-iq/internal/api/response_types" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/dto" +) + +const ( + APIActionRunsURL = APIBaseURL + "/action-runs" +) + +func TestAPIActionRuns(t *testing.T) { + waitForAPIReady(t) + + if err := refreshInventory(); err != nil { + t.Fatal("Error refreshing inventory") + } + + t.Run("List Action Runs", func(t *testing.T) { testListActionRuns(t) }) + t.Run("List Action Runs with Pagination", func(t *testing.T) { testListActionRunsWithPagination(t) }) + t.Run("List Action Runs filtered by Status", func(t *testing.T) { testListActionRunsFilteredByStatus(t) }) + t.Run("Get Action Run By ID Success", func(t *testing.T) { testGetActionRunByID_Exists(t) }) + t.Run("Get Action Run By ID Not Found", func(t *testing.T) { testGetActionRunByID_NoExists(t) }) + t.Run("Post Action Run", func(t *testing.T) { testPostActionRun(t) }) + t.Run("Update Action Run", func(t *testing.T) { testUpdateActionRun(t) }) + t.Run("Update Action Run Not Found", func(t *testing.T) { testUpdateActionRun_NoExists(t) }) +} + +func testListActionRuns(t *testing.T) { + expectedCount := 2 + expectedHTTPCode := http.StatusOK + + resp, err := http.Get(APIActionRunsURL) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.ListResponse[dto.ActionRunDTOResponse] + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Count != expectedCount { + t.Fatalf("Expected Count: '%d', got: '%d'", expectedCount, response.Count) + } + + if len := len(response.Items); len != expectedCount { + t.Fatalf("Expected Items: '%d', got: '%d'", expectedCount, len) + } +} + +func testListActionRunsWithPagination(t *testing.T) { + expectedCount := 1 + expectedHTTPCode := http.StatusOK + + resp, err := http.Get(APIActionRunsURL + "?page=1&page_size=1") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.ListResponse[dto.ActionRunDTOResponse] + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Count != expectedCount { + t.Fatalf("Expected Count: '%d', got: '%d'", expectedCount, response.Count) + } + + if len := len(response.Items); len != expectedCount { + t.Fatalf("Expected Items: '%d', got: '%d'", expectedCount, len) + } +} + +func testListActionRunsFilteredByStatus(t *testing.T) { + expectedCount := 1 + expectedHTTPCode := http.StatusOK + + resp, err := http.Get(APIActionRunsURL + "?status=Running") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.ListResponse[dto.ActionRunDTOResponse] + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Count != expectedCount { + t.Fatalf("Expected Count: '%d', got: '%d'", expectedCount, response.Count) + } + + if len := len(response.Items); len != expectedCount { + t.Fatalf("Expected Items: '%d', got: '%d'", expectedCount, len) + } + + if response.Items[0].Status != "Running" { + t.Fatalf("Expected Status: 'Running', got: '%s'", response.Items[0].Status) + } +} + +func testGetActionRunByID_Exists(t *testing.T) { + expectedRunID := "1" + expectedHTTPCode := http.StatusOK + + resp, err := http.Get(APIActionRunsURL + "/" + expectedRunID) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response dto.ActionRunDTOResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.ID != expectedRunID { + t.Fatalf("Expected ID: '%s', got: '%s'", expectedRunID, response.ID) + } +} + +func testGetActionRunByID_NoExists(t *testing.T) { + expectedMsg := "Action run not found" + expectedHTTPCode := http.StatusNotFound + + resp, err := http.Get(APIActionRunsURL + "/" + "9999") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.GenericErrorResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Message != expectedMsg { + t.Fatalf("Expected Message: '%s', got: '%s'", expectedMsg, response.Message) + } +} + +func testPostActionRun(t *testing.T) { + expectedHTTPCode := http.StatusCreated + expectedCount := 1 + + payload := dto.ActionRunDTORequest{ + ScheduleID: "1", + } + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal data in post request: %v", err) + } + + resp, err := http.Post(APIActionRunsURL, "application/json", bytes.NewBuffer(b)) + if err != nil { + t.Fatalf("Failed to make POST request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.PostResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Count != expectedCount { + t.Fatalf("Expected Count: '%d', got '%d'", expectedCount, response.Count) + } +} + +func testUpdateActionRun(t *testing.T) { + expectedHTTPCode := http.StatusOK + + payload := dto.ActionRunDTORequest{ + ScheduleID: "1", + Status: "Failed", + ErrorMsg: "test error", + } + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal data in patch request: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, APIActionRunsURL+"/1", bytes.NewBuffer(b)) + if err != nil { + t.Fatalf("Failed to create PATCH request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to make PATCH request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) +} + +func testUpdateActionRun_NoExists(t *testing.T) { + expectedMsg := "Action run not found" + expectedHTTPCode := http.StatusNotFound + + payload := dto.ActionRunDTORequest{ + ScheduleID: "1", + Status: "Failed", + ErrorMsg: "test error", + } + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal data in patch request: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, APIActionRunsURL+"/9999", bytes.NewBuffer(b)) + if err != nil { + t.Fatalf("Failed to create PATCH request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to make PATCH request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.GenericErrorResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Message != expectedMsg { + t.Fatalf("Expected Message: '%s', got: '%s'", expectedMsg, response.Message) + } +} diff --git a/test/integration/api_events_integration_test.go b/test/integration/api_events_integration_test.go index 34435187..ae3f1f27 100644 --- a/test/integration/api_events_integration_test.go +++ b/test/integration/api_events_integration_test.go @@ -102,7 +102,7 @@ func testPostEvents(t *testing.T) { EventTimestamp: time.Now(), Result: "Pending", Severity: "info", - TriggeredBy: "tester", + Requester: "tester", Description: nil, } b, err := json.Marshal(event) @@ -148,7 +148,7 @@ func testUpdateEvent(t *testing.T) { EventTimestamp: time.Now(), Result: "Success", Severity: "info", - TriggeredBy: "tester", + Requester: "tester", Description: nil, } b, err := json.Marshal(event) 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", diff --git a/test/integration/api_overview_integration_test.go b/test/integration/api_overview_integration_test.go index 3ab7390a..2fcc61a3 100644 --- a/test/integration/api_overview_integration_test.go +++ b/test/integration/api_overview_integration_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "reflect" + "slices" "testing" "time" @@ -55,26 +56,45 @@ func testGetOverview(t *testing.T) { Scanner: dto.Scanner{ LastScanTimestamp: lastScanTS, }, + TopRegions: []dto.TopItem{}, + TopOwners: []dto.TopItem{}, + ClustersByPartner: []dto.TopItem{}, + CostPerAccount: []dto.AccountCost{ + {AccountName: "aws-account-demo", CurrentMonthCost: 0}, + {AccountName: "azure-sub-demo", CurrentMonthCost: 0}, + {AccountName: "gcp-project-demo", CurrentMonthCost: 0}, + }, } - // Getting accounts data resp, err := http.Get(APIOverviewURL) if err != nil { t.Fatalf("Failed to make request: %v", err) } defer resp.Body.Close() - // Check response code checkHTTPResponseCode(t, resp, expectedHTTPCode) - // Decode the JSON response var response dto.OverviewSummary if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { t.Fatalf("Failed to decode response body: %v", err) } - // Comparing data + sortAccountCosts(response.CostPerAccount) + sortAccountCosts(expectedOverviewResponse.CostPerAccount) + if !reflect.DeepEqual(response, expectedOverviewResponse) { t.Fatalf("Expected Overview: '%+v', got: '%+v'", expectedOverviewResponse, response) } } + +func sortAccountCosts(costs []dto.AccountCost) { + slices.SortFunc(costs, func(a, b dto.AccountCost) int { + if a.AccountName < b.AccountName { + return -1 + } + if a.AccountName > b.AccountName { + return 1 + } + return 0 + }) +}