diff --git a/.agents/skills/qovery-console-standards/AGENTS.md b/.agents/skills/qovery-console-standards/AGENTS.md index f271b8dfdf8..c477865956a 100644 --- a/.agents/skills/qovery-console-standards/AGENTS.md +++ b/.agents/skills/qovery-console-standards/AGENTS.md @@ -33,10 +33,18 @@ libs/ - `data-access` libs contain API wiring only. - `feature` libs contain business components. - `ui` libs contain reusable presentation components. -- Utilities that can be shared must live in `@qovery/shared/util-js` and be imported directly (never re-export via data-access). +- Reusable generic helpers belong in the shared library that owns their concern; use `@qovery/shared/util-js` when no specialized shared library fits. Import them directly, never re-export them via data-access. - Respect Nx architecture (no circular imports). - Use Nx generators to create new components/libs. +### Code Placement + +- Before adding a folder or abstraction, follow comparable code in the same domain. A generic name alone does not make a helper shared. +- Domain feature libs: hooks in `src/lib/hooks/use-/`, domain-specific pure helpers in `src/lib/utils/`. Do not create a second `hooks/` directory inside a feature folder. +- For generic helpers, prefer the existing shared library that owns their concern; otherwise use `@qovery/shared/util-js`. Reuse across callers in one domain does not make a domain-specific helper generic. +- Create a dedicated library only for a cohesive concept (types, behavior, or UI) reused across domains with no existing owner. Choose its Nx layer from its real dependencies and callers. +- `@qovery/shared/console-shared` is legacy: add nothing new to it. Existing usages can stay; do not migrate them as part of an unrelated change. + --- ## 2. React & TypeScript Standards @@ -56,7 +64,7 @@ libs/ - Destructure props directly in parameters. - Props interface named `Props`. - Named exports only (no default exports). -- Use `clsx` or `twMerge` from `@qovery/shared/util-js` for conditional classes. +- Use `clsx` (from `clsx`) for conditional classes and `twMerge` (from `@qovery/shared/util-js`) to resolve conflicting Tailwind classes, such as defaults and a caller's `className`. - Prefer Radix UI components when available. ### Route Parameters @@ -94,7 +102,7 @@ libs/ - TailwindCSS only (no CSS modules or styled-components in new code). - Use utilities from `@qovery/shared/ui` when available. - Mobile-first responsive design. -- Use `clsx` or `twMerge` from `@qovery/shared/util-js` for conditional classes. +- Use `clsx` (from `clsx`) for conditional classes and `twMerge` (from `@qovery/shared/util-js`) to resolve conflicting Tailwind classes, such as defaults and a caller's `className`. ### Component Styling @@ -249,3 +257,36 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` - All tests must pass. - Code must be properly formatted. - Snapshot changes must be intentional and reviewed. + +--- + +## 8. Implementation & Review Decisions + +### Keep Shared Changes Necessary + +- Every new prop, abstraction, or shared-component change needs a concrete caller or behavior today. No props or tokens for hypothetical future needs. +- Prefer the existing API when it covers the need. When extending, keep existing callers working and check the behavior in their flows, not only in the new screen. + +### Separate Business Rules from Rendering + +- Keep payload construction, API parsing, and business transformations in domain hooks or helpers that can be tested on their own, not in rendering components. +- Prefer typed API fields over parsing opaque payloads. If the contract cannot change, parse and validate once at the domain boundary. +- An action's label, icon, and description must describe the same operation. A neutral icon shared by several operations is fine. +- Name helpers after the resource or capability they identify, not after a version, rollout, or feature flag. + +### Choose State Ownership Explicitly + +- Derive values from props or state instead of copying them into state. +- Use controlled inputs when the caller owns the value; use default values only for initialization. +- Reserve effects for synchronization with external systems. See the `react-useeffect` skill for alternatives. + +### Validate and Review Proportionally + +- Test the behavior and boundaries the change affects, including invalid or legacy API data when relevant. Extract complex pure logic for direct tests; small expressions do not need extraction. +- Classify each finding as a **bug** (give the concrete trigger and consequence), a **convention** departure (cite the rule or comparable code), or an optional **suggestion**. Only bugs are blocking by default. +- Check current callers and comparable implementations before recommending a new abstraction or shared change. +- Treat automated review findings and "resolved" labels as leads, not proof: check the fix against the current diff before closing a finding. + +### Write Actionable Review Rules + +When adding a rule, state its scope, the behavior to flag, why it matters, and legitimate exceptions. Prefer checks lint does not already enforce, and avoid generalizing a rule from a single PR. diff --git a/.agents/skills/qovery-console-standards/SKILL.md b/.agents/skills/qovery-console-standards/SKILL.md index 9c26353ee00..6ca640fa781 100644 --- a/.agents/skills/qovery-console-standards/SKILL.md +++ b/.agents/skills/qovery-console-standards/SKILL.md @@ -9,7 +9,7 @@ metadata: # Qovery Console Standards -Comprehensive coding standards for the Qovery Console React/TypeScript monorepo. Contains 7 rules covering architecture, React patterns, styling, testing, naming, development workflow, and pre-commit quality gates. +Comprehensive coding standards for the Qovery Console React/TypeScript monorepo. Contains 8 rules covering architecture, React patterns, styling, testing, naming, development workflow, pre-commit quality gates, and implementation and review decisions. ## When to Apply @@ -24,15 +24,16 @@ Reference these guidelines when: ## Rule Categories -| Rule | File | Scope | -| -------------------- | ------------------------------- | --------------------------------------------------------- | -| Project Architecture | `project-architecture.md` | Nx monorepo structure, lib organization, layer boundaries | -| React & TypeScript | `react-typescript-standards.md` | Import rules, component patterns, state management | -| Styling | `styling-standards.md` | TailwindCSS, Radix UI, responsive design, accessibility | -| Testing | `testing-standards.md` | Test setup, structure, mocking, coverage | -| Naming Conventions | `naming-conventions.md` | File, variable, component, and constant naming | -| Development Commands | `development-commands.md` | Daily workflow, Nx commands, package management, git | -| Pre-commit Workflow | `pre-commit-workflow.md` | Conventional commits, format/test/lint checks | +| Rule | File | Scope | +| ----------------------- | ------------------------------- | --------------------------------------------------------------- | +| Project Architecture | `project-architecture.md` | Nx monorepo structure, lib organization, layer boundaries | +| React & TypeScript | `react-typescript-standards.md` | Import rules, component patterns, state management | +| Styling | `styling-standards.md` | TailwindCSS, Radix UI, responsive design, accessibility | +| Testing | `testing-standards.md` | Test setup, structure, mocking, coverage | +| Naming Conventions | `naming-conventions.md` | File, variable, component, and constant naming | +| Development Commands | `development-commands.md` | Daily workflow, Nx commands, package management, git | +| Pre-commit Workflow | `pre-commit-workflow.md` | Conventional commits, format/test/lint checks | +| Implementation & Review | `implementation-review.md` | Code placement, shared changes, API boundaries, state ownership | ## Quick Reference @@ -41,13 +42,14 @@ Reference these guidelines when: - Nx monorepo: `apps/` and `libs/` (domains, pages, shared) - `data-access` = API logic, `feature` = business components, `ui` = reusable presentation - No circular imports; use Nx generators for new libs +- Domain hooks in `src/lib/hooks/use-/`, helpers in `src/lib/utils/`; nothing new in `shared/console-shared` (legacy) ### React & TypeScript - Inline type imports: `import { type Foo, bar } from './module'` - Functional components only, no `React.FC`, destructure props in signature - Route-scoped components read URL identifiers with TanStack Router `useParams({ strict: false })`; do not forward route IDs through props solely for data access -- React Query for server state, `clsx`/`twMerge` for conditional classes +- React Query for server state, `clsx` for conditional classes, `twMerge` for conflicting Tailwind classes ### Styling @@ -66,6 +68,12 @@ Reference these guidelines when: - Snake-case files (`user-profile.tsx`), PascalCase components - `Props` suffix for interfaces, `UPPER_SNAKE_CASE` for constants +### Implementation & Review + +- Shared changes need a concrete caller; keep existing callers working +- Business transformations in domain hooks/helpers, not in rendering +- Classify review findings: bug (trigger + consequence), convention, or optional suggestion + ### Workflow - Yarn Berry only (never npm), `nvm use` @@ -80,6 +88,11 @@ Read individual rule files for detailed guidelines: rules/project-architecture.md rules/react-typescript-standards.md rules/styling-standards.md +rules/testing-standards.md +rules/naming-conventions.md +rules/development-commands.md +rules/pre-commit-workflow.md +rules/implementation-review.md ``` ## Full Compiled Document diff --git a/.agents/skills/qovery-console-standards/rules/implementation-review.md b/.agents/skills/qovery-console-standards/rules/implementation-review.md new file mode 100644 index 00000000000..4171d8810fc --- /dev/null +++ b/.agents/skills/qovery-console-standards/rules/implementation-review.md @@ -0,0 +1,38 @@ +--- +title: Implementation and Review Decisions +impact: HIGH +tags: [architecture, review, api, state] +--- + +# Implementation and Review Decisions + +Code placement and library ownership are covered in [project-architecture.md](project-architecture.md#code-placement). + +## Keep Shared Changes Necessary + +- Every new prop, abstraction, or shared-component change needs a concrete caller or behavior today. No props or tokens for hypothetical future needs. +- Prefer the existing API when it covers the need. When extending, keep existing callers working and check the behavior in their flows, not only in the new screen. + +## Separate Business Rules from Rendering + +- Keep payload construction, API parsing, and business transformations in domain hooks or helpers that can be tested on their own, not in rendering components. +- Prefer typed API fields over parsing opaque payloads. If the contract cannot change, parse and validate once at the domain boundary. +- An action's label, icon, and description must describe the same operation. A neutral icon shared by several operations is fine. +- Name helpers after the resource or capability they identify, not after a version, rollout, or feature flag. + +## Choose State Ownership Explicitly + +- Derive values from props or state instead of copying them into state. +- Use controlled inputs when the caller owns the value; use default values only for initialization. +- Reserve effects for synchronization with external systems. See the `react-useeffect` skill for alternatives. + +## Validate and Review Proportionally + +- Test the behavior and boundaries the change affects, including invalid or legacy API data when relevant. Extract complex pure logic for direct tests; small expressions do not need extraction. +- Classify each finding as a **bug** (give the concrete trigger and consequence), a **convention** departure (cite the rule or comparable code), or an optional **suggestion**. Only bugs are blocking by default. +- Check current callers and comparable implementations before recommending a new abstraction or shared change. +- Treat automated review findings and "resolved" labels as leads, not proof: check the fix against the current diff before closing a finding. + +## Write Actionable Review Rules + +When adding a rule, state its scope, the behavior to flag, why it matters, and legitimate exceptions. Prefer checks lint does not already enforce, and avoid generalizing a rule from a single PR. diff --git a/.agents/skills/qovery-console-standards/rules/project-architecture.md b/.agents/skills/qovery-console-standards/rules/project-architecture.md index 6774d415001..c81b4692258 100644 --- a/.agents/skills/qovery-console-standards/rules/project-architecture.md +++ b/.agents/skills/qovery-console-standards/rules/project-architecture.md @@ -35,7 +35,15 @@ libs/ - `data-access` libs contain API wiring only - `feature` libs contain business components - `ui` libs contain reusable presentation components -- Utilities that can be shared must live in `@qovery/shared/util-js` and be imported directly (never re-export via data-access) +- Reusable generic helpers belong in the shared library that owns their concern; use `@qovery/shared/util-js` when no specialized shared library fits. Import them directly, never re-export them via data-access + +## Code Placement + +- Before adding a folder or abstraction, follow comparable code in the same domain. A generic name alone does not make a helper shared. +- Domain feature libs: hooks in `src/lib/hooks/use-/`, domain-specific pure helpers in `src/lib/utils/`. Do not create a second `hooks/` directory inside a feature folder. +- For generic helpers, prefer the existing shared library that owns their concern; otherwise use `@qovery/shared/util-js`. Reuse across callers in one domain does not make a domain-specific helper generic. +- Create a dedicated library only for a cohesive concept (types, behavior, or UI) reused across domains with no existing owner. Choose its Nx layer from its real dependencies and callers. +- `@qovery/shared/console-shared` is legacy: add nothing new to it. Existing usages can stay; do not migrate them as part of an unrelated change. ## Important Rules diff --git a/.agents/skills/qovery-console-standards/rules/react-typescript-standards.md b/.agents/skills/qovery-console-standards/rules/react-typescript-standards.md index 347ea1bda48..10b5edcb0aa 100644 --- a/.agents/skills/qovery-console-standards/rules/react-typescript-standards.md +++ b/.agents/skills/qovery-console-standards/rules/react-typescript-standards.md @@ -21,7 +21,7 @@ tags: [react, typescript, components, imports, state] - Destructure props directly in parameters - Props interface named `Props` - Named exports only (no default exports) -- Use `clsx` or `twMerge` from `@qovery/shared/util-js` for conditional classes +- Use `clsx` (from `clsx`) for conditional classes and `twMerge` (from `@qovery/shared/util-js`) to resolve conflicting Tailwind classes, such as defaults and a caller's `className` - Prefer Radix UI components when available ## Route Parameters diff --git a/.agents/skills/qovery-console-standards/rules/styling-standards.md b/.agents/skills/qovery-console-standards/rules/styling-standards.md index d086fcc35ba..66938e1a16b 100644 --- a/.agents/skills/qovery-console-standards/rules/styling-standards.md +++ b/.agents/skills/qovery-console-standards/rules/styling-standards.md @@ -11,7 +11,7 @@ tags: [tailwind, css, styling, accessibility, radix, responsive] - TailwindCSS only (no CSS modules or styled-components in new code) - Use utilities from `@qovery/shared/ui` when available - Mobile-first responsive design -- Use `clsx` or `twMerge` from `@qovery/shared/util-js` for conditional classes +- Use `clsx` (from `clsx`) for conditional classes and `twMerge` (from `@qovery/shared/util-js`) to resolve conflicting Tailwind classes, such as defaults and a caller's `className` ## Component Styling diff --git a/.agents/skills/qovery-ui/SKILL.md b/.agents/skills/qovery-ui/SKILL.md index 7735b237153..20894210762 100644 --- a/.agents/skills/qovery-ui/SKILL.md +++ b/.agents/skills/qovery-ui/SKILL.md @@ -38,7 +38,7 @@ Design review and implementation guidance for the Qovery Console. Read `.claude/ ## /audit -**Load:** `references/colors.md`, `references/spacing-layout.md`, `references/anti-patterns.md`, `references/accessibility.md` +**Load:** `references/colors.md`, `references/spacing-layout.md`, `references/anti-patterns.md`, `references/accessibility.md`, `references/components.md` Four-phase review. Label every finding: @@ -46,6 +46,8 @@ Four-phase review. Label every finding: - `[important]` — degrades experience, likely to cause design review rejection - `[nit]` — minor inconsistency, low impact +A component substitution or refactor is not `[blocking]` just because it is preferred: state the user impact and check that the proposed shared component fits the interaction. + **Phase 1 — Token hygiene:** hardcoded values, legacy tokens, raw Tailwind palette classes used semantically. **Phase 2 — Semantic color:** status colors used decoratively, color-only state, brand as page wash. **Phase 3 — Spacing & density:** off-grid values, whitespace inflation, button height overrides. @@ -81,6 +83,8 @@ Fix without changing functionality. Output a Before / After / Why table, one row **Load:** `references/components.md` +Also use this reference when extending shared components or reviewing a new interactive flow. Follow its reuse, compatibility, Storybook, and interaction checks for the states affected by the change. + 1. Identify if a shared component already exists — always prefer it 2. Return the import path and correct usage with props 3. If nothing fits, explain why before scaffolding new diff --git a/.agents/skills/qovery-ui/references/components.md b/.agents/skills/qovery-ui/references/components.md index adbadabea9d..b8ce6bfb5a6 100644 --- a/.agents/skills/qovery-ui/references/components.md +++ b/.agents/skills/qovery-ui/references/components.md @@ -2,6 +2,23 @@ All shared components live in `libs/shared/ui/src/lib/components/`. Check here before building anything new. +## Reuse and Extend Shared UI + +- Check `shared/ui` and its existing callers first. Reuse a component when its **behavior** fits; visual similarity alone does not justify swapping in a component with different interaction semantics. +- Nothing new in `shared/console-shared` (legacy). Generic presentation goes to `shared/ui`, business UI stays in its domain. Do not move business logic into the design system just to avoid `console-shared`. +- Extend a shared component only for a concrete need its API cannot cover. Keep the extension generic and existing callers working. +- Multiple styling variants: follow the existing CVA pattern. Simple conditions do not need a variant. +- Classes: `clsx` for conditional composition, `twMerge` (`@qovery/shared/util-js`) when defaults can conflict with a caller's `className`. No nested template strings. +- Add or update Storybook stories for new shared components and new visual states or variants. + +## Verify the Interaction + +- Loading, failure, validation, and saving states give clear feedback. +- Clickable controls look clickable. "Draft being checked" is distinguishable from "changes ready to save". +- Read-only fields stay read-only in nested editors and dialogs. +- Leaving with unsaved changes has deliberate behavior; leave-page protection must not block navigation within the draft. +- Test tooltips and popovers from their actual keyboard focus target, including controls nested in clickable rows or links. A focusable parent does not make a hover tooltip keyboard-accessible. + --- ## Decision Guide diff --git a/apps/console/src/app/components/auth-page/auth-page-utils.spec.ts b/apps/console/src/app/components/auth-page/auth-page-utils.spec.ts index 5ab8793ffee..f7a6b5941d5 100644 --- a/apps/console/src/app/components/auth-page/auth-page-utils.spec.ts +++ b/apps/console/src/app/components/auth-page/auth-page-utils.spec.ts @@ -3,6 +3,7 @@ import { authPageBeforeLoad, clearAuthEntry, readAuthEntry } from './auth-page-u describe('auth entry', () => { beforeEach(() => { sessionStorage.clear() + localStorage.clear() }) afterEach(() => { @@ -22,6 +23,18 @@ describe('auth entry', () => { expect(readAuthEntry()).toEqual({ screen: 'signup', redirect: '/organization/123/overview' }) }) + it('remembers SAML SSO as the last used login of a `?connection=` auto-login', async () => { + localStorage.setItem('lastUsedLogin', 'github') + + await authPageBeforeLoad({ + page: 'login', + auth: { isAuthenticated: false, login: jest.fn().mockResolvedValue(undefined) }, + search: { connection: 'acme' }, + }) + + expect(localStorage.getItem('lastUsedLogin')).toBe('saml_sso') + }) + it('falls back to the login page and the root when nothing was remembered', () => { expect(readAuthEntry()).toEqual({ screen: 'login', redirect: '/' }) }) diff --git a/apps/console/src/app/components/auth-page/auth-page-utils.ts b/apps/console/src/app/components/auth-page/auth-page-utils.ts index cf93580bb60..735be1bd4fc 100644 --- a/apps/console/src/app/components/auth-page/auth-page-utils.ts +++ b/apps/console/src/app/components/auth-page/auth-page-utils.ts @@ -1,6 +1,7 @@ import { redirect } from '@tanstack/react-router' import { z } from 'zod' import { AuthEnum, getSsoConnectionName } from '@qovery/shared/auth' +import { getLocalStorageItem, setLocalStorageItem } from '@qovery/shared/util-hooks' import { type AuthPage } from './auth-page-tracking' export const LAST_USED_LOGIN_STORAGE_KEY = 'lastUsedLogin' @@ -50,12 +51,20 @@ export function clearAuthEntry() { export function getStoredLastUsedLogin() { try { - return window.localStorage.getItem(LAST_USED_LOGIN_STORAGE_KEY) ?? undefined + return getLocalStorageItem(LAST_USED_LOGIN_STORAGE_KEY) ?? undefined } catch { return undefined } } +function storeLastUsedLogin(lastUsedLogin: string) { + try { + setLocalStorageItem(LAST_USED_LOGIN_STORAGE_KEY, lastUsedLogin) + } catch (error) { + console.error(error) + } +} + export type TrackedAuthProvider = 'google' | 'github' | 'saml' | 'bitbucket' | 'gitlab' | 'microsoft' const TRACKED_PROVIDERS: Record = { @@ -87,6 +96,7 @@ export async function authPageBeforeLoad({ const connection = search.connection && getSsoConnectionName(search.connection) if (connection) { rememberAuthEntry(page, search.redirect) + storeLastUsedLogin(SAML_SSO_LOGIN) try { await auth.login(getSafeRedirect(search.redirect), connection) } catch (error) { diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 3ad1beaacba..6a7e6a19f53 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -70,10 +70,12 @@ import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdCloudSh import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/settings/route' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/route' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route' +import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/settings/index' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/deployment-rules/index' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/index' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/index' +import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/index' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/index' import { Route as AuthenticatedOrganizationOrganizationIdSettingsRolesEditRoleIdRouteImport } from './routes/_authenticated/organization/$organizationId/settings/roles/edit/$roleId' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsGeneralRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/settings/general' @@ -96,6 +98,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSetting import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons' +import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/index' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployments' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdAutomationRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/automation' @@ -648,6 +651,14 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute = + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteImport.update( + { + id: '/cluster/$clusterId/profile', + path: '/cluster/$clusterId/profile', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsIndexRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsIndexRouteImport.update( { @@ -683,6 +694,15 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute = + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRouteImport.update( + { + id: '/', + path: '/', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRoute = AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRouteImport.update( { @@ -879,6 +899,15 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute = + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRouteImport.update( + { + id: '/$componentKey', + path: '/$componentKey', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRouteImport.update( { @@ -1974,6 +2003,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute '/organization/$organizationId/alerts/': typeof AuthenticatedOrganizationOrganizationIdAlertsIndexRoute '/organization/$organizationId/settings/': typeof AuthenticatedOrganizationOrganizationIdSettingsIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren '/organization/$organizationId/cluster/$clusterId/settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteWithChildren '/organization/$organizationId/cluster/create/$slug': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteWithChildren '/organization/$organizationId/project/$projectId/settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRouteWithChildren @@ -1989,6 +2019,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/organization/$organizationId/settings/agents/': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute '/organization/$organizationId/settings/roles': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile/$componentKey': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute '/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute @@ -2011,6 +2042,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/settings/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsGeneralRoute '/organization/$organizationId/settings/roles/edit/$roleId': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesEditRoleIdRoute '/organization/$organizationId/cluster/$clusterId/deployments': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute '/organization/$organizationId/cluster/$clusterId/settings/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute '/organization/$organizationId/cluster/create/$slug/': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute '/organization/$organizationId/project/$projectId/deployment-rules': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesIndexRoute @@ -2189,6 +2221,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/organization/$organizationId/settings/agents': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute '/organization/$organizationId/settings/roles': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile/$componentKey': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute '/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute @@ -2211,6 +2244,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/settings/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsGeneralRoute '/organization/$organizationId/settings/roles/edit/$roleId': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesEditRoleIdRoute '/organization/$organizationId/cluster/$clusterId/deployments': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRoute + '/organization/$organizationId/cluster/$clusterId/profile': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute '/organization/$organizationId/cluster/$clusterId/settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute '/organization/$organizationId/cluster/create/$slug': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute '/organization/$organizationId/project/$projectId/deployment-rules': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesIndexRoute @@ -2369,6 +2403,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/$clusterId/': typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute '/_authenticated/organization/$organizationId/alerts/': typeof AuthenticatedOrganizationOrganizationIdAlertsIndexRoute '/_authenticated/organization/$organizationId/settings/': typeof AuthenticatedOrganizationOrganizationIdSettingsIndexRoute + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren '/_authenticated/organization/$organizationId/cluster/$clusterId/settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteWithChildren '/_authenticated/organization/$organizationId/cluster/create/$slug': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteWithChildren '/_authenticated/organization/$organizationId/project/$projectId/settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRouteWithChildren @@ -2384,6 +2419,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/_authenticated/organization/$organizationId/settings/agents/': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute '/_authenticated/organization/$organizationId/settings/roles/': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute @@ -2406,6 +2442,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/settings/general': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsGeneralRoute '/_authenticated/organization/$organizationId/settings/roles/edit/$roleId': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesEditRoleIdRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdDeploymentsIndexRoute + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugIndexRoute '/_authenticated/organization/$organizationId/project/$projectId/deployment-rules/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdDeploymentRulesIndexRoute @@ -2579,6 +2616,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/$clusterId' | '/organization/$organizationId/alerts/' | '/organization/$organizationId/settings/' + | '/organization/$organizationId/cluster/$clusterId/profile' | '/organization/$organizationId/cluster/$clusterId/settings' | '/organization/$organizationId/cluster/create/$slug' | '/organization/$organizationId/project/$projectId/settings' @@ -2594,6 +2632,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId' | '/organization/$organizationId/settings/agents/' | '/organization/$organizationId/settings/roles' + | '/organization/$organizationId/cluster/$clusterId/profile/$componentKey' | '/organization/$organizationId/cluster/$clusterId/settings/addons' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/organization/$organizationId/cluster/$clusterId/settings/credentials' @@ -2616,6 +2655,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/settings/general' | '/organization/$organizationId/settings/roles/edit/$roleId' | '/organization/$organizationId/cluster/$clusterId/deployments' + | '/organization/$organizationId/cluster/$clusterId/profile/' | '/organization/$organizationId/cluster/$clusterId/settings/' | '/organization/$organizationId/cluster/create/$slug/' | '/organization/$organizationId/project/$projectId/deployment-rules' @@ -2794,6 +2834,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId' | '/organization/$organizationId/settings/agents' | '/organization/$organizationId/settings/roles' + | '/organization/$organizationId/cluster/$clusterId/profile/$componentKey' | '/organization/$organizationId/cluster/$clusterId/settings/addons' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/organization/$organizationId/cluster/$clusterId/settings/credentials' @@ -2816,6 +2857,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/settings/general' | '/organization/$organizationId/settings/roles/edit/$roleId' | '/organization/$organizationId/cluster/$clusterId/deployments' + | '/organization/$organizationId/cluster/$clusterId/profile' | '/organization/$organizationId/cluster/$clusterId/settings' | '/organization/$organizationId/cluster/create/$slug' | '/organization/$organizationId/project/$projectId/deployment-rules' @@ -2973,6 +3015,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/$clusterId/' | '/_authenticated/organization/$organizationId/alerts/' | '/_authenticated/organization/$organizationId/settings/' + | '/_authenticated/organization/$organizationId/cluster/$clusterId/profile' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings' | '/_authenticated/organization/$organizationId/cluster/create/$slug' | '/_authenticated/organization/$organizationId/project/$projectId/settings' @@ -2988,6 +3031,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/' | '/_authenticated/organization/$organizationId/settings/agents/' | '/_authenticated/organization/$organizationId/settings/roles/' + | '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials' @@ -3010,6 +3054,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/settings/general' | '/_authenticated/organization/$organizationId/settings/roles/edit/$roleId' | '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/' + | '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/' | '/_authenticated/organization/$organizationId/cluster/create/$slug/' | '/_authenticated/organization/$organizationId/project/$projectId/deployment-rules/' @@ -3573,6 +3618,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute } + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile': { + id: '/_authenticated/organization/$organizationId/cluster/$clusterId/profile' + path: '/cluster/$clusterId/profile' + fullPath: '/organization/$organizationId/cluster/$clusterId/profile' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/settings/': { id: '/_authenticated/organization/$organizationId/project/$projectId/settings/' path: '/' @@ -3601,6 +3653,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsIndexRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/': { + id: '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/' + path: '/' + fullPath: '/organization/$organizationId/cluster/$clusterId/profile/' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute + } '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/': { id: '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/' path: '/cluster/$clusterId/deployments' @@ -3755,6 +3814,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey': { + id: '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey' + path: '/$componentKey' + fullPath: '/organization/$organizationId/cluster/$clusterId/profile/$componentKey' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/' path: '/project/$projectId/environment/$environmentId' @@ -4718,6 +4784,24 @@ const AuthenticatedOrganizationOrganizationIdSettingsRouteRouteWithChildren = AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren, ) +interface AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteChildren { + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute +} + +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteChildren: AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteChildren = + { + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute: + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileComponentKeyRoute, + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute: + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileIndexRoute, + } + +const AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren = + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute._addFileChildren( + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteChildren, + ) + interface AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteChildren { AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute @@ -5285,6 +5369,7 @@ interface AuthenticatedOrganizationOrganizationIdRouteRouteChildren { AuthenticatedOrganizationOrganizationIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdIndexRoute AuthenticatedOrganizationOrganizationIdClusterNewRoute: typeof AuthenticatedOrganizationOrganizationIdClusterNewRoute AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute: typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdSettingsRouteRouteWithChildren @@ -5351,6 +5436,8 @@ const AuthenticatedOrganizationOrganizationIdRouteRouteChildren: AuthenticatedOr AuthenticatedOrganizationOrganizationIdClusterNewRoute, AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute: AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute, + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRoute: + AuthenticatedOrganizationOrganizationIdClusterClusterIdProfileRouteRouteWithChildren, AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute: AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteWithChildren, AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx index d64c36585a5..98d1d4e6880 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx @@ -88,6 +88,7 @@ function ClusterDeploymentLogsPage({ triggeredBy={deployment?.auditing_data.triggered_by} actionStatus={deployment?.action_status} totalDuration={deployment?.total_duration} + operation={deployment?.operation} /> {deployment?.action_status === 'ONGOING' && deployment?.reason === 'MAINTENANCE' && ( diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey.tsx new file mode 100644 index 00000000000..7559880c93b --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey.tsx @@ -0,0 +1,48 @@ +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' +import { ClusterProfileFeature, resolveProfileSelection, useClusterProfileTree } from '@qovery/domains/clusters/feature' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey' +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { componentKey } = Route.useParams() + const { search } = Route.useSearch() + const navigate = Route.useNavigate() + const { profileTree, isLoading } = useClusterProfileTree({ organizationId, clusterId }) + // Disabled layers and components with nothing to configure cannot be opened: the URL follows what is displayed. + const resolvedComponentKey = resolveProfileSelection(profileTree, componentKey).component?.key + + if (!isLoading && resolvedComponentKey && resolvedComponentKey !== componentKey) { + return ( + previous} + replace + /> + ) + } + + return ( + + navigate({ + to: '/organization/$organizationId/cluster/$clusterId/profile/$componentKey', + params: (previous) => ({ ...previous, componentKey: nextComponentKey }), + search: (previous) => previous, + }) + } + onSearchChange={(nextSearch) => + navigate({ search: (previous) => ({ ...previous, search: nextSearch || undefined }), replace: true }) + } + /> + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/index.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/index.tsx new file mode 100644 index 00000000000..6b16d2f8a04 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/index.tsx @@ -0,0 +1,35 @@ +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' +import { ClusterProfileFeature, resolveProfileSelection, useClusterProfileTree } from '@qovery/domains/clusters/feature' +import { LoaderSpinner } from '@qovery/shared/ui' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/$clusterId/profile/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { profileTree, isLoading, isError } = useClusterProfileTree({ organizationId, clusterId }) + const componentKey = resolveProfileSelection(profileTree).component?.key + + if (isLoading) { + return ( +
+ +
+ ) + } + + // Without a component to open, the feature shows the error or empty state. + if (isError || !componentKey) { + return + } + + return ( + previous} + replace + /> + ) +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx new file mode 100644 index 00000000000..87296f8db54 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/profile/route.tsx @@ -0,0 +1,39 @@ +import { Navigate, Outlet, createFileRoute, useParams } from '@tanstack/react-router' +import { z } from 'zod' +import { useCluster, useIsOperatorAttached } from '@qovery/domains/clusters/feature' +import { LoaderSpinner } from '@qovery/shared/ui' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/$clusterId/profile')({ + component: RouteComponent, + validateSearch: z.object({ search: z.string().optional() }), +}) + +function RouteComponent() { + useDocumentTitle('Cluster - Profile') + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { data: cluster, isLoading: isClusterLoading } = useCluster({ organizationId, clusterId }) + // Only Engine v2 clusters, attached to the Qovery Operator, have a profile. + const isOperatorAttached = useIsOperatorAttached(cluster) + + // Redirecting before the flags, the cluster and its Operator status are loaded would turn away users who have access. + if (isClusterLoading || (cluster && isOperatorAttached === undefined)) { + return ( +
+ +
+ ) + } + + if (!isOperatorAttached && organizationId && clusterId) { + return ( + + ) + } + + return +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/new.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/new.tsx index e955a38d1ce..31789bf2153 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/new.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/new.tsx @@ -1,5 +1,6 @@ import { createFileRoute, useParams } from '@tanstack/react-router' -import { ClusterNew } from '@qovery/domains/clusters/feature' +import { useFeatureFlagEnabled } from 'posthog-js/react' +import { ClusterAdd, ClusterNew } from '@qovery/domains/clusters/feature' import { Heading, Icon, Link, Section } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -9,7 +10,8 @@ export const Route = createFileRoute('/_authenticated/organization/$organization function RouteComponent() { const { organizationId = '' } = useParams({ strict: false }) - useDocumentTitle('Create new cluster') + const isEngineV2Enabled = Boolean(useFeatureFlagEnabled('engine-v2-platform-configuration')) + useDocumentTitle(isEngineV2Enabled ? 'Add cluster' : 'Create new cluster') return (
@@ -20,12 +22,12 @@ function RouteComponent() { className="mb-2 text-xs" > - Back to clusters + {isEngineV2Enabled ? 'Back to cluster list' : 'Back to clusters'}
- Install cluster + {isEngineV2Enabled ? 'Add cluster' : 'Install cluster'}
- + {isEngineV2Enabled ? : }
) } diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx index 5280f68562b..20e8980de12 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx @@ -1,7 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useDeployEnvironment } from '@qovery/domains/environments/feature' import { VariableList } from '@qovery/domains/variables/feature' -import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes' import { toast } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -16,8 +15,9 @@ function RouteComponent() { useDocumentTitle('Built-in variables - Environment') const { mutate: deployEnvironment } = useDeployEnvironment({ + organizationId, projectId, - logsLink: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL(), + environmentId, }) const toasterCallback = () => { diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx index 19533863201..0c4cfc3c4c8 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx @@ -4,7 +4,6 @@ import { useFeatureFlagEnabled } from 'posthog-js/react' import { useEffect } from 'react' import { useDeployEnvironment } from '@qovery/domains/environments/feature' import { ExternalSecretsTab, SecretManagerFeatureFlagEntryPoint } from '@qovery/domains/variables/feature' -import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes' import { toast } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -29,8 +28,9 @@ function RouteComponent() { }, [organizationId, projectId, environmentId]) const { mutate: deployEnvironment } = useDeployEnvironment({ + organizationId, projectId, - logsLink: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL(), + environmentId, }) const redeployEnvironmentAction = () => { diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx index fc00f4873d0..0843a1d7d64 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx @@ -1,7 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useDeployEnvironment } from '@qovery/domains/environments/feature' import { VariableList, VariablesActionToolbar } from '@qovery/domains/variables/feature' -import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes' import { toast } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -16,8 +15,9 @@ function RouteComponent() { useDocumentTitle('Custom variables - Environment') const { mutate: deployEnvironment } = useDeployEnvironment({ + organizationId, projectId, - logsLink: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL(), + environmentId, }) const toasterCallback = () => { diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx index a7d05cc0ce5..96c264313c7 100644 --- a/apps/console/src/routes/_authenticated/organization/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/route.tsx @@ -4,7 +4,7 @@ import posthog from 'posthog-js' import { useFeatureFlagEnabled } from 'posthog-js/react' import { type BlueprintDetailsResponse, type Cluster } from 'qovery-typescript-axios' import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useClusters } from '@qovery/domains/clusters/feature' +import { useClusters, useIsOperatorAttached } from '@qovery/domains/clusters/feature' import { useEnvironment } from '@qovery/domains/environments/feature' import { useProject } from '@qovery/domains/projects/feature' import { @@ -118,6 +118,12 @@ const CLUSTER_TABS: NavigationTab[] = [ iconName: 'terminal', routeId: '/_authenticated/organization/$organizationId/cluster/$clusterId/cloud-shell', }, + { + id: 'profile', + label: 'Profile', + iconName: 'layer-group', + routeId: '/_authenticated/organization/$organizationId/cluster/$clusterId/profile', + }, { id: 'settings', label: 'Settings', @@ -393,6 +399,8 @@ function useNavigationContext(): NavigationContext | null { }) const hasAlerting = clusters.some((cluster) => cluster.metrics_parameters?.configuration?.alerting?.enabled) const currentCluster = clusters.find((cluster) => cluster.id === environment?.cluster_id) + // Only Engine v2 clusters, attached to the Qovery Operator, have a profile. + const isProfileAvailable = Boolean(useIsOperatorAttached(clusters.find((cluster) => cluster.id === params.clusterId))) const blueprintId = service && isBlueprintService(service) && isTerraform(service) ? service.blueprint_id : '' const { data: blueprint } = useBlueprint({ blueprintId, @@ -423,9 +431,11 @@ function useNavigationContext(): NavigationContext | null { ? getServiceTabs(service, currentCluster, isAgenticWorkflowEnabled, blueprint) : context.type === 'organization' ? context.tabs.filter((tab) => hasAlerting || tab.id !== 'alerts') - : context.type === 'environment' - ? context.tabs.filter((tab) => isAgenticWorkflowEnabled || tab.id !== 'automation') - : context.tabs + : context.type === 'cluster' + ? context.tabs.filter((tab) => isProfileAvailable || tab.id !== 'profile') + : context.type === 'environment' + ? context.tabs.filter((tab) => isAgenticWorkflowEnabled || tab.id !== 'automation') + : context.tabs return { type: context.type, @@ -546,6 +556,7 @@ const fullWidthRouteIds: FileRouteTypes['id'][] = [ '/_authenticated/organization/$organizationId/cluster/$clusterId/cluster-logs', '/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId', '/_authenticated/organization/$organizationId/cluster/$clusterId/cloud-shell', + '/_authenticated/organization/$organizationId/cluster/$clusterId/profile', '/_authenticated/organization/$organizationId/cluster/$clusterId/settings', '/_authenticated/organization/$organizationId/project/$projectId/settings', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/settings', diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index c19f5e87ce8..9541d8e35ec 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -44,6 +44,7 @@ export default defineConfig(({ mode }) => { tanstackRouter({ target: 'react', autoCodeSplitting: true, + routeFileIgnorePattern: '\\.(spec|test)\\.tsx?$', }), viteStaticCopy({ targets: [ diff --git a/libs/domains/audit-logs/feature/src/lib/audit-logs/audit-logs.spec.tsx b/libs/domains/audit-logs/feature/src/lib/audit-logs/audit-logs.spec.tsx index 68d26715f93..766ec71a6a0 100644 --- a/libs/domains/audit-logs/feature/src/lib/audit-logs/audit-logs.spec.tsx +++ b/libs/domains/audit-logs/feature/src/lib/audit-logs/audit-logs.spec.tsx @@ -200,3 +200,14 @@ describe('AuditLogs layering', () => { expect(tableHeader).toHaveClass('z-dropdown') }) }) + +describe('AuditLogs event filter', () => { + it('should list the Operator update event types in the Event filter', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByText('Event')) + + const menuItems = screen.getAllByTestId('menuItem').map((item) => item.textContent) + expect(menuItems).toEqual(expect.arrayContaining(['Operator update', 'Maintenance operator'])) + }) +}) diff --git a/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx b/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx index 765cbda2d95..5b83acacbf9 100644 --- a/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx +++ b/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx @@ -127,6 +127,28 @@ describe('RowEvent', () => { } ) + it.each([ + [OrganizationEventType.OPERATOR_UPDATE, 'Operator Update', 'fa-rocket-launch'], + [OrganizationEventType.MAINTENANCE_OPERATOR, 'Maintenance · Operator Update', 'fa-wrench'], + [OrganizationEventType.TRIGGER_DEPLOY, 'Trigger Deploy', 'fa-rocket-launch'], + [OrganizationEventType.MAINTENANCE, 'Maintenance', 'fa-wrench'], + ])('should render %s with its label and icon', (eventType, label, iconClass) => { + renderWithProviders( + + ) + + expect(screen.getByTestId('tag')).toHaveTextContent(new RegExp(`^${label}$`)) + expect(screen.getByTestId('row-event').querySelector(`.${iconClass}`)).toBeInTheDocument() + }) + it('should render Agent task for the agentic workflow target type', () => { renderWithProviders( { - return eventName.split('_').map(upperCaseFirstLetter).join(' ') -} +const formatEventName = (eventType?: OrganizationEventType) => + match(eventType) + .with(OrganizationEventType.MAINTENANCE_OPERATOR, () => 'Maintenance · Operator Update') + .otherwise((type) => (type ?? '').split('_').map(upperCaseFirstLetter).join(' ')) const serviceOverviewUrl = (organizationId: string, projectId: string, environmentId: string, serviceId: string) => `/organization/${organizationId}/project/${projectId}/environment/${environmentId}/service/${serviceId}/overview` @@ -174,6 +175,7 @@ export function RowEvent(props: RowEventProps) { OrganizationEventType.TRIGGER_STOP, OrganizationEventType.TRIGGER_TERRAFORM_FORCE_UNLOCK, OrganizationEventType.TRIGGER_TERRAFORM_MIGRATE_STATE, + OrganizationEventType.OPERATOR_UPDATE, () => ) .with(OrganizationEventType.DELETE, OrganizationEventType.DELETED, () => ( @@ -181,7 +183,9 @@ export function RowEvent(props: RowEventProps) { )) .with(OrganizationEventType.CLONE, () => ) .with(OrganizationEventType.DRY_RUN, () => ) - .with(OrganizationEventType.MAINTENANCE, () => ) + .with(OrganizationEventType.MAINTENANCE, OrganizationEventType.MAINTENANCE_OPERATOR, () => ( + + )) .with(OrganizationEventType.FORCE_RUN_SUCCEEDED, () => ( )) @@ -235,7 +239,7 @@ export function RowEvent(props: RowEventProps) { - {formatEventName(event.event_type ?? '')} + {formatEventName(event.event_type)} {event.sub_target_type && ( diff --git a/libs/domains/clusters/data-access/src/index.ts b/libs/domains/clusters/data-access/src/index.ts index e8ca0eb7ad5..8e8229f53d9 100644 --- a/libs/domains/clusters/data-access/src/index.ts +++ b/libs/domains/clusters/data-access/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/domains-clusters-data-access' +export * from './lib/platform-configuration/platform-configuration' export { isGcpCluster } from './lib/cluster-checks/is-gcp-cluster' export { isAwsCluster } from './lib/cluster-checks/is-aws-cluster' export { excludeSecretManagerAccess } from './lib/secret-manager/exclude-secret-manager-access' diff --git a/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts b/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts index e2e1e85d328..d639e790b66 100644 --- a/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts +++ b/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts @@ -9,17 +9,22 @@ import { type ClusterDeleteMode, ClusterDeploymentHistoryApi, type ClusterDnsProviderRequest, + ClusterOperatorApi, + type ClusterOperatorUpdateRequest, type ClusterRequest, type ClusterRoutingTableRequest, ClustersApi, KubernetesEnum, OrganizationMainCallsApi, SecretManagerAccessApi, + type SelfManagedClusterRequest, } from 'qovery-typescript-axios' import { type ClusterMetricsDto, type ClusterStatusDto } from 'qovery-ws-typescript-axios' +import { isHttpStatus } from '@qovery/shared/utils' const clusterApi = new ClustersApi() const clusterDeploymentHistoryApi = new ClusterDeploymentHistoryApi() +const clusterOperatorApi = new ClusterOperatorApi() const argoCdApi = new ArgoCDApi() const secretManagerApi = new SecretManagerAccessApi() const organizationApi = new OrganizationMainCallsApi() @@ -50,6 +55,26 @@ export const clusters = createQueryKeys('clusters', { return response.data }, }), + operatorStatus: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ + queryKey: [organizationId, clusterId], + async queryFn() { + try { + const response = await clusterOperatorApi.getClusterOperatorStatus(organizationId, clusterId) + return response.data + } catch (error) { + // No Operator state: the cluster is not enrolled in the Engine v2 Operator path. + if (isHttpStatus(error, 404)) return null + throw error + } + }, + }), + operatorBootstrap: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ + queryKey: [organizationId, clusterId], + async queryFn() { + const response = await clusterOperatorApi.getClusterOperatorBootstrap(organizationId, clusterId) + return response.data + }, + }), eksAnywhereCommits: ({ organizationId, clusterId }: { organizationId: string; clusterId: string }) => ({ queryKey: [organizationId, clusterId], async queryFn() { @@ -203,6 +228,33 @@ export const clusters = createQueryKeys('clusters', { }) export const mutations = { + async updateClusterOperator({ + organizationId, + clusterId, + chartVersion, + imageVersion, + }: { + organizationId: string + clusterId: string + chartVersion: ClusterOperatorUpdateRequest['chart_version'] + imageVersion?: ClusterOperatorUpdateRequest['image_version'] + }) { + const response = await clusterOperatorApi.updateClusterOperator(organizationId, clusterId, { + chart_version: chartVersion, + image_version: imageVersion, + }) + return response.data + }, + async createSelfManagedCluster({ + organizationId, + clusterRequest, + }: { + organizationId: string + clusterRequest: SelfManagedClusterRequest + }) { + const response = await clusterApi.createSelfManagedCluster(organizationId, clusterRequest) + return response.data + }, async createCluster({ organizationId, clusterRequest }: { organizationId: string; clusterRequest: ClusterRequest }) { const response = await clusterApi.createCluster(organizationId, clusterRequest) return response.data diff --git a/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts new file mode 100644 index 00000000000..72bd8473781 --- /dev/null +++ b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts @@ -0,0 +1,105 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory' +import { + type ClusterPlatformConfigurationRequest, + type PlatformCloudVendor, + type PlatformClusterMode, + type PlatformComponentConfigurationPreviewRequest, + PlatformConfigurationApi, +} from 'qovery-typescript-axios' +import { isHttpStatus } from '@qovery/shared/utils' + +const platformConfigurationApi = new PlatformConfigurationApi() + +export const platformConfiguration = createQueryKeys('platformConfiguration', { + templates: ({ + organizationId, + clusterMode, + cloudProvider, + }: { + organizationId: string + clusterMode?: PlatformClusterMode + cloudProvider?: PlatformCloudVendor + }) => ({ + queryKey: [organizationId, clusterMode, cloudProvider], + async queryFn() { + const response = await platformConfigurationApi.listPlatformTemplates(organizationId, clusterMode, cloudProvider) + return response.data.results + }, + }), + // Resolves a component draft against a template release, before the cluster exists. + templateComponentConfiguration: ({ + organizationId, + templateKey, + templateVersion, + componentKey, + clusterMode, + cloudProvider, + request, + }: { + organizationId: string + templateKey: string + templateVersion: string + componentKey: string + clusterMode: PlatformClusterMode + cloudProvider: PlatformCloudVendor + request: PlatformComponentConfigurationPreviewRequest + }) => ({ + queryKey: [organizationId, templateKey, templateVersion, componentKey, clusterMode, cloudProvider, request], + async queryFn() { + const response = await platformConfigurationApi.resolvePlatformTemplateComponentConfiguration( + organizationId, + templateKey, + templateVersion, + componentKey, + clusterMode, + cloudProvider, + request + ) + return response.data + }, + }), + configuration: ({ clusterId }: { clusterId: string }) => ({ + queryKey: [clusterId], + async queryFn() { + try { + const response = await platformConfigurationApi.getClusterPlatformConfiguration(clusterId) + return response.data + } catch (error) { + if (isHttpStatus(error, 404)) return null + throw error + } + }, + }), + componentConfiguration: ({ + clusterId, + componentKey, + request, + }: { + clusterId: string + componentKey: string + request: PlatformComponentConfigurationPreviewRequest + }) => ({ + queryKey: [clusterId, componentKey, request], + async queryFn() { + const response = await platformConfigurationApi.resolveClusterPlatformComponentConfiguration( + clusterId, + componentKey, + request + ) + return response.data + }, + }), +}) + +export const platformConfigurationMutations = { + async updateConfiguration({ + clusterId, + configurationRequest, + }: { + clusterId: string + configurationRequest: ClusterPlatformConfigurationRequest + }) { + const response = await platformConfigurationApi.updateClusterPlatformConfiguration(clusterId, configurationRequest) + return response.data + }, +} diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 30fdf89ed64..b4e0c7de319 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -7,6 +7,9 @@ export * from './lib/cluster-actions/cluster-actions' export * from './lib/cluster-avatar/cluster-avatar' export * from './lib/cluster-advanced-settings/cluster-advanced-settings' export * from './lib/cluster-advanced-settings/cluster-advanced-settings-feature' +export * from './lib/cluster-profile/cluster-profile' +export * from './lib/cluster-profile/profile-tree' +export * from './lib/cluster-profile/use-cluster-profile-tree' export * from './lib/cluster-advanced-settings/init-form-values' export * from './lib/cluster-setup/cluster-setup' export * from './lib/cluster-need-redeploy-flag/cluster-need-redeploy-flag' @@ -59,6 +62,10 @@ export * from './lib/hooks/use-cluster-status/use-cluster-status' export * from './lib/hooks/use-services-cluster/use-services-cluster' export * from './lib/hooks/use-cluster-statuses/use-cluster-statuses' export * from './lib/hooks/use-cluster/use-cluster' +export * from './lib/hooks/use-platform-templates/use-platform-templates' +export * from './lib/hooks/use-platform-configuration/use-platform-configuration' +export * from './lib/hooks/use-platform-component-configuration/use-platform-component-configuration' +export * from './lib/hooks/use-platform-component-configurations/use-platform-component-configurations' export * from './lib/hooks/use-clusters/use-clusters' export * from './lib/hooks/use-argocd-credentials/use-argocd-credentials' export * from './lib/hooks/use-cluster-advanced-settings/use-cluster-advanced-settings' @@ -91,3 +98,10 @@ export * from './lib/utils/derive-global-spot-enabled' export * from './lib/utils/map-creation-spot-to-node-pools' export * from './lib/utils/normalize-karpenter-spot' export * from './lib/cluster-resources-settings/cluster-resources-settings' +export * from './lib/cluster-add/cluster-add' +export * from './lib/hooks/use-create-self-managed-cluster/use-create-self-managed-cluster' +export * from './lib/hooks/use-cluster-operator-status/use-cluster-operator-status' +export * from './lib/hooks/use-update-platform-configuration/use-update-platform-configuration' +export * from './lib/hooks/use-cluster-operator-bootstrap/use-cluster-operator-bootstrap' +export * from './lib/hooks/use-is-operator-attached/use-is-operator-attached' +export * from './lib/hooks/use-update-cluster-operator/use-update-cluster-operator' diff --git a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx index 0d34841026c..57652a02284 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx @@ -8,8 +8,12 @@ import { import type { ReactNode } from 'react' import { clusterFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { useIsOperatorAttached } from '../hooks/use-is-operator-attached/use-is-operator-attached' import { ClusterActions } from './cluster-actions' +jest.mock('../hooks/use-is-operator-attached/use-is-operator-attached') +const mockUseIsOperatorAttached = useIsOperatorAttached as jest.Mock + const mockCluster = clusterFactoryMock(1)[0] const mockOpenModal = jest.fn() const mockOpenModalConfirmation = jest.fn() @@ -54,6 +58,7 @@ jest.mock('@qovery/shared/util-hooks', () => ({ describe('ClusterActions', () => { beforeEach(() => { jest.clearAllMocks() + mockUseIsOperatorAttached.mockReturnValue(false) mockCluster.deployment_status = ClusterDeploymentStatusEnum.UP_TO_DATE mockClusterStatus = { cluster_id: mockCluster.id, @@ -310,4 +315,65 @@ describe('ClusterActions', () => { expect(screen.getByRole('button', { name: 'Deployments' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Logs' })).not.toBeInTheDocument() }) + + describe('clusters with an attached Operator', () => { + const selfManagedCluster = { + ...mockCluster, + kubernetes: KubernetesEnum.SELF_MANAGED, + cloud_provider: 'AWS' as const, + } + + it('keeps the installation guide for self-managed clusters without an attached Operator', () => { + const { container } = renderWithProviders( + + ) + + // The installation guide button is only named by its tooltip. + expect(container.querySelector('.fa-circle-info')).toBeInTheDocument() + expect(screen.queryByLabelText(/manage deployment/i)).not.toBeInTheDocument() + }) + + it('offers to deploy a cluster with an attached Operator, without stopping it', async () => { + mockUseIsOperatorAttached.mockReturnValue(true) + const { userEvent } = renderWithProviders( + , + { container: document.body } + ) + + expect(document.body.querySelector('.fa-circle-info')).not.toBeInTheDocument() + await userEvent.click(screen.getByLabelText(/manage deployment/i)) + + expect(screen.getByRole('menuitem', { name: 'Deploy' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /stop/i })).not.toBeInTheDocument() + }) + + it('offers to redeploy a deployed cluster with an attached Operator, without upgrading Kubernetes', async () => { + mockUseIsOperatorAttached.mockReturnValue(true) + const { userEvent } = renderWithProviders( + , + { container: document.body } + ) + + await userEvent.click(screen.getByLabelText(/manage deployment/i)) + + expect(screen.getByRole('menuitem', { name: 'Update' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /upgrade k8s/i })).not.toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /stop/i })).not.toBeInTheDocument() + }) + + it('shows the deployments card action for clusters with an attached Operator', () => { + mockUseIsOperatorAttached.mockReturnValue(true) + renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: 'Deployments' })).toBeInTheDocument() + }) + }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx index 7b43dce6519..2216439e960 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx @@ -25,6 +25,7 @@ import { useClusterRunningStatus } from '../hooks/use-cluster-running-status/use import { useDeployCluster } from '../hooks/use-deploy-cluster/use-deploy-cluster' import { useDownloadKubeconfig } from '../hooks/use-download-kubeconfig/use-download-kubeconfig' import { useEksAnywhereClusterJwt } from '../hooks/use-eks-anywhere-cluster-jwt/use-eks-anywhere-cluster-jwt' +import { useIsOperatorAttached } from '../hooks/use-is-operator-attached/use-is-operator-attached' import { useStopCluster } from '../hooks/use-stop-cluster/use-stop-cluster' import { useUpdateEksAnywhereCommit } from '../hooks/use-update-eks-anywhere-commit/use-update-eks-anywhere-commit' import { useUpgradeCluster } from '../hooks/use-upgrade-cluster/use-upgrade-cluster' @@ -36,10 +37,13 @@ function MenuManageDeployment({ cluster, clusterStatus, variant = 'default', + isOperatorAttached = false, }: { cluster: Cluster clusterStatus: ClusterStatus variant?: ActionToolbarVariant + // The cluster is attached to the Qovery Operator: Qovery only deploys its platform profile, it cannot stop or upgrade it. + isOperatorAttached?: boolean }) { const { openModalConfirmation } = useModalConfirmation() const { openModal, closeModal } = useModal() @@ -63,7 +67,8 @@ function MenuManageDeployment({ clusterStatus.next_k8s_available_version && clusterStatus.next_k8s_available_version !== null && clusterStatus.status === 'DEPLOYED' && - cluster.kubernetes !== 'PARTIALLY_MANAGED' + cluster.kubernetes !== 'PARTIALLY_MANAGED' && + !isOperatorAttached const clusterNeedUpdate = cluster.deployment_status !== 'UP_TO_DATE' && clusterStatus.status !== 'STOPPED' const isEksAnywhereCluster = cluster.kubernetes === 'PARTIALLY_MANAGED' const hasEksAnywhereGitRepository = Boolean( @@ -202,7 +207,8 @@ function MenuManageDeployment({ {tooltipClusterNeedUpdate} ), - cluster.cloud_provider !== 'GCP' && + !isOperatorAttached && + cluster.cloud_provider !== 'GCP' && cluster.cloud_provider !== 'AZURE' && isStopAvailable(clusterStatus.status) && cluster.kubernetes !== 'PARTIALLY_MANAGED' && ( @@ -422,6 +428,7 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: organizationId: cluster.organization.id, clusterId: cluster.id, }) + const isOperatorAttached = useIsOperatorAttached(cluster) const searchParams = useMemo(() => { // @ts-expect-error TODO needs to be fixed @@ -471,6 +478,12 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: }, [searchParams, location.search, location.pathname, cluster.kubernetes, closeModal, openInstallationGuideModal]) const primaryActionButton = match(cluster) + .when( + () => isOperatorAttached, + () => ( + + ) + ) .with({ cloud_provider: P.not('ON_PREMISE'), kubernetes: 'SELF_MANAGED' }, () => ( + + + ), +})) + +type BlockerOptions = { disabled: boolean; shouldBlockFn: () => boolean; enableBeforeUnload: boolean } +const getBlocker = () => mockUseBlocker.mock.calls.at(-1)?.[0] as BlockerOptions + +describe('ClusterAdd', () => { + afterEach(() => jest.restoreAllMocks()) + + async function openCreationFlow(userEvent: ReturnType['userEvent']) { + await userEvent.click(screen.getByRole('button', { name: 'AWS' })) + expect(await screen.findByText('Creation flow')).toBeInTheDocument() + } + + it('asks for EKS Anywhere access when it is not enabled', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: /Amazon Web Services EKS Anywhere\s*Request access/ })) + + expect(mockShowPylonForm).toHaveBeenCalledWith('request-access-eks-anywhere') + expect(posthog.capture).toHaveBeenCalledWith('select-cluster', { + selectedCloudProvider: 'AWS', + selectedInstallationType: 'partially-managed', + }) + }) + + it('names each provider card once for assistive technologies', () => { + const { container } = renderWithProviders() + + expect(screen.getByRole('button', { name: 'AWS' })).toBeInTheDocument() + expect(screen.queryAllByRole('img')).toHaveLength(0) + expect(container.querySelectorAll('svg:not([aria-hidden="true"])')).toHaveLength(0) + }) + + it('lets the creation flow be dismissed before the cluster is created', async () => { + const { userEvent } = renderWithProviders() + + await openCreationFlow(userEvent) + expect(posthog.capture).toHaveBeenCalledWith('select-cluster', { + selectedCloudProvider: 'AWS', + selectedInstallationType: 'self-managed', + }) + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + + await waitFor(() => expect(screen.queryByText('Creation flow')).not.toBeInTheDocument()) + }) + + it('cannot be dismissed on the install step, only closed by the flow once the Operator connects', async () => { + const { userEvent } = renderWithProviders() + + await openCreationFlow(userEvent) + await userEvent.click(screen.getByRole('button', { name: 'Reach install step' })) + await userEvent.keyboard('{Escape}') + + expect(screen.getByText('Creation flow')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Operator connected' })) + + await waitFor(() => expect(screen.queryByText('Creation flow')).not.toBeInTheDocument()) + }) + + describe('leaving the install step', () => { + it('does not guard the page before the cluster is created', async () => { + const { userEvent } = renderWithProviders() + + expect(getBlocker().disabled).toBe(true) + + await openCreationFlow(userEvent) + + expect(getBlocker().disabled).toBe(true) + }) + + it('asks for confirmation before leaving, and enables the before-unload warning', async () => { + const confirm = jest.spyOn(window, 'confirm') + const { userEvent } = renderWithProviders() + + await openCreationFlow(userEvent) + await userEvent.click(screen.getByRole('button', { name: 'Reach install step' })) + + expect(getBlocker()).toMatchObject({ disabled: false, enableBeforeUnload: true }) + confirm.mockReturnValueOnce(false) + expect(getBlocker().shouldBlockFn()).toBe(true) + confirm.mockReturnValueOnce(true) + expect(getBlocker().shouldBlockFn()).toBe(false) + }) + + it('lets the flow navigate away once the Operator connects', async () => { + const confirm = jest.spyOn(window, 'confirm') + const { userEvent } = renderWithProviders() + + await openCreationFlow(userEvent) + await userEvent.click(screen.getByRole('button', { name: 'Reach install step' })) + await userEvent.click(screen.getByRole('button', { name: 'Operator connected' })) + + expect(getBlocker().shouldBlockFn()).toBe(false) + expect(getBlocker().disabled).toBe(true) + expect(confirm).not.toHaveBeenCalled() + }) + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx new file mode 100644 index 00000000000..d37d5cdbcd9 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx @@ -0,0 +1,280 @@ +import { Link, useBlocker, useParams } from '@tanstack/react-router' +import Azure from 'devicon/icons/azure/azure-original.svg' +import DigitalOcean from 'devicon/icons/digitalocean/digitalocean-original.svg' +import GCP from 'devicon/icons/googlecloud/googlecloud-original.svg' +import Kubernetes from 'devicon/icons/kubernetes/kubernetes-original.svg' +import posthog from 'posthog-js' +import { useFeatureFlagEnabled } from 'posthog-js/react' +import { type ReactElement, type ReactNode, cloneElement, useRef, useState } from 'react' +import { Callout, Heading, Icon, Modal, Section, useModal } from '@qovery/shared/ui' +import { useSupportChat } from '@qovery/shared/util-hooks' +import { twMerge } from '@qovery/shared/util-js' +import { ClusterInstallationGuideModal } from '../cluster-installation-guide-modal/cluster-installation-guide-modal' +import { useClusterCreationRestriction } from '../hooks/use-cluster-creation-restriction/use-cluster-creation-restriction' +import { + SELF_MANAGED_CLUSTER_CREATION_MODAL_WIDTH, + SelfManagedClusterCreationFlow, + type SelfManagedClusterCreationStep, +} from '../self-managed-cluster-creation/self-managed-cluster-creation-flow' + +const LEAVE_INSTALL_STEP_MESSAGE = + 'The Operator installation instructions will be lost if you leave this page. Leave anyway?' + +type ProviderIconSource = string | ReactElement<{ className?: string; 'aria-hidden'?: boolean }> + +type ProviderCardProps = { + title: string + icon: ProviderIconSource + disabled?: boolean + actionLabel?: string + analytics: { selectedCloudProvider: string; selectedInstallationType: string } +} & ({ slug: string } | { onClick: () => void }) + +// Decorative: the card title already names the provider. +function ProviderIcon({ icon }: { icon: ProviderIconSource }) { + return typeof icon === 'string' ? ( + + ) : ( + cloneElement(icon, { className: 'size-5 select-none', 'aria-hidden': true }) + ) +} + +function ProviderCard({ title, icon, disabled = false, actionLabel, analytics, ...props }: ProviderCardProps) { + const { organizationId = '' } = useParams({ strict: false }) + const className = twMerge( + 'flex h-[52px] items-center gap-2 rounded-md border border-neutral bg-surface-neutral px-4 text-left text-sm font-medium text-neutral transition-colors', + disabled ? 'cursor-not-allowed bg-surface-neutral-component text-neutral-subtle' : 'hover:border-brand-strong', + // Not available to the organization yet: it opens an access request, not the creation flow. + actionLabel && 'border-dashed text-neutral-subtle' + ) + const content = ( + <> + + {title} + {actionLabel ? ( + {actionLabel} + ) : ( + + )} + + ) + const capture = () => posthog.capture('select-cluster', analytics) + + if ('slug' in props && !disabled) { + return ( + + {content} + + ) + } + + return ( + + ) +} + +function ProviderSection({ + title, + description, + children, +}: { + title: string + description: string + children: ReactNode +}) { + return ( +
+
+ {title} +

{description}

+
+
{children}
+
+ ) +} + +export function ClusterAdd() { + const { organizationId = '' } = useParams({ strict: false }) + const { openModal, closeModal } = useModal() + const { showPylonForm } = useSupportChat() + const isEksAnywhereEnabled = useFeatureFlagEnabled('eks-anywhere') + const { isClusterCreationRestricted: disabled } = useClusterCreationRestriction({ organizationId }) + const [isCreationFlowOpen, setIsCreationFlowOpen] = useState(false) + const [creationStep, setCreationStep] = useState('general') + + // The flow closes itself before navigating once the Operator connects: that navigation must not be blocked. + const isFlowClosingRef = useRef(false) + // Once the cluster exists, leaving the page would lose the Operator installation instructions. + const isInstalling = isCreationFlowOpen && creationStep === 'install' + + useBlocker({ + disabled: !isInstalling, + shouldBlockFn: () => !isFlowClosingRef.current && !window.confirm(LEAVE_INSTALL_STEP_MESSAGE), + enableBeforeUnload: isInstalling, + }) + + const openInstallationGuideModal = ({ isDemo = false }: { isDemo?: boolean } = {}) => + openModal({ + options: { width: 500 }, + content: , + }) + + const openCreationFlow = () => { + isFlowClosingRef.current = false + setCreationStep('general') + setIsCreationFlowOpen(true) + } + + const managed = (selectedCloudProvider: string) => ({ + selectedCloudProvider, + selectedInstallationType: 'managed', + }) + const selfManaged = (selectedCloudProvider: string) => ({ + selectedCloudProvider, + selectedInstallationType: 'self-managed', + }) + + return ( +
+ {disabled && ( + + + + + + Cluster creation is restricted + + Your organization has a billing restriction that prevents cluster creation. Please contact support to + resolve this issue. + + + + )} + + + } + slug="aws" + disabled={disabled} + analytics={managed('AWS')} + /> + {isEksAnywhereEnabled ? ( + } + slug="aws-eks-anywhere" + disabled={disabled} + analytics={{ selectedCloudProvider: 'AWS', selectedInstallationType: 'partially-managed' }} + /> + ) : ( + } + actionLabel="Request access" + onClick={() => showPylonForm('request-access-eks-anywhere')} + analytics={{ selectedCloudProvider: 'AWS', selectedInstallationType: 'partially-managed' }} + /> + )} + + } + slug="scw" + disabled={disabled} + analytics={managed('SCW')} + /> + + + + + openInstallationGuideModal({ isDemo: true })} + analytics={{ selectedCloudProvider: 'OTHER', selectedInstallationType: 'demo' }} + /> + } + onClick={openCreationFlow} + disabled={disabled} + analytics={selfManaged('AWS')} + /> + {[ + { title: 'GCP', icon: GCP, provider: 'GCP' }, + { title: 'Microsoft Azure', icon: Azure, provider: 'AZURE' }, + { title: 'OVH Cloud', icon: , provider: 'OVH_CLOUD' }, + { title: 'Digital Ocean', icon: DigitalOcean, provider: 'DIGITAL_OCEAN' }, + { title: 'CIVO', icon: , provider: 'CIVO' }, + { title: 'Oracle Cloud Infrastructure', icon: , provider: 'ORACLE_CLOUD' }, + { title: 'Hetzner', icon: , provider: 'HETZNER' }, + { title: 'IBM Cloud', icon: , provider: 'IBM_CLOUD' }, + ].map(({ title, icon, provider }) => ( + openInstallationGuideModal()} + disabled={disabled} + analytics={selfManaged(provider)} + /> + ))} + + + + {/* The dialog unmounts its content once closed, so each opening starts a fresh flow. */} + { + isFlowClosingRef.current = true + setIsCreationFlowOpen(false) + }} + /> + +
+ ) +} + +export default ClusterAdd diff --git a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.spec.tsx new file mode 100644 index 00000000000..a99d790af0d --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.spec.tsx @@ -0,0 +1,35 @@ +import { renderWithProviders } from '@qovery/shared/util-tests' +import { ClusterAvatar } from './cluster-avatar' + +jest.mock('@qovery/shared/ui', () => { + const React = jest.requireActual('react') + const ui = jest.requireActual('@qovery/shared/ui') + return { + ...ui, + Icon: (props: { name?: string; iconName?: string }) => + React.createElement('span', { 'data-icon-name': props.name ?? props.iconName }), + } +}) + +describe('ClusterAvatar', () => { + it.each([ + ['AWS', 'AWS'], + ['GCP', 'GCP'], + ['ON_PREMISE', 'KUBERNETES'], + ['OVH', 'OVH_CLOUD'], + ['ORACLE', 'ORACLE_CLOUD'], + ['IBM', 'IBM_CLOUD'], + ] as const)('shows the %s provider icon', (cloudProvider, iconName) => { + const { container } = renderWithProviders() + + expect(container.querySelector(`[data-icon-name="${iconName}"]`)).toBeInTheDocument() + }) + + it('shows a demo icon for demo clusters', () => { + const { container } = renderWithProviders( + + ) + + expect(container.querySelector('[data-icon-name="laptop-code"]')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx index 10ed95a8863..045ae72bd7c 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-avatar/cluster-avatar.tsx @@ -1,10 +1,11 @@ -import { type CloudProviderEnum, type Cluster, type ClusterOverviewResponse } from 'qovery-typescript-axios' +import { type CloudVendorEnum, type Cluster, type ClusterOverviewResponse } from 'qovery-typescript-axios' import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react' import { match } from 'ts-pattern' +import { IconEnum } from '@qovery/shared/enums' import { Avatar, Icon } from '@qovery/shared/ui' export interface ClusterAvatarProps extends Omit, 'fallback'> { - cloudProvider?: CloudProviderEnum + cloudProvider?: CloudVendorEnum cluster?: Cluster | ClusterOverviewResponse } @@ -18,6 +19,9 @@ export const ClusterAvatar = forwardRef, ClusterAvatar )) .with({ localCloudProvider: 'ON_PREMISE' }, () => ) + .with({ localCloudProvider: 'OVH' }, () => ) + .with({ localCloudProvider: 'ORACLE' }, () => ) + .with({ localCloudProvider: 'IBM' }, () => ) .otherwise(() => ) return diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx index 50bf60934ef..b5ba9d8433c 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx @@ -1,4 +1,4 @@ -import { type ClusterDeploymentHistory } from 'qovery-typescript-axios' +import { type ClusterDeploymentHistory, ClusterDeploymentHistoryOperationEnum } from 'qovery-typescript-axios' import { type ReactNode } from 'react' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { ClusterDeploymentList } from './cluster-deployment-list' @@ -19,6 +19,7 @@ const defaultDeploymentHistory: ClusterDeploymentHistory[] = [ status: 'DEPLOYED', action_status: 'SUCCESS', trigger_action: 'DEPLOY', + operation: ClusterDeploymentHistoryOperationEnum.CLUSTER, is_dry_run: false, reason: 'MAINTENANCE', total_duration: 'PT16.503S', @@ -67,6 +68,44 @@ describe('ClusterDeploymentList', () => { expect(screen.getByText('Maintenance')).toBeInTheDocument() }) + it('should render the Operator action with its maintenance badge when operation is OPERATOR_UPDATE', () => { + mockDeploymentHistory = [ + { + ...defaultDeploymentHistory[0], + operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE, + }, + ] + + renderWithProviders() + + const action = screen.getByText('Operator').parentElement + expect(screen.queryByText('Deploy')).not.toBeInTheDocument() + expect(screen.getByText('Maintenance')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Operator update/ }).tagName).toBe('TR') + expect(action?.querySelector('.fa-rotate-right')).toBeInTheDocument() + expect(action?.querySelector('[tabindex]')).toBeNull() + expect(action?.closest('[tabindex]')?.tagName).toBe('TR') + }) + + it('should render the Deploy action when operation is CLUSTER', () => { + renderWithProviders() + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + expect(screen.getByText('Maintenance')).toBeInTheDocument() + }) + + it('should render the Deploy action when operation is missing from the payload', () => { + const legacyDeployment: Partial = { ...defaultDeploymentHistory[0] } + delete legacyDeployment.operation + mockDeploymentHistory = [legacyDeployment as ClusterDeploymentHistory] + + renderWithProviders() + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + it('should render a dry run badge when the deployment is a dry run', () => { mockDeploymentHistory = [ { diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx index 03aabb583da..32746321701 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx @@ -153,8 +153,8 @@ export function ClusterDeploymentList({ organizationId, clusterId }: ClusterDepl return (
-
- +
+ {data.reason === 'MAINTENANCE' && ( Maintenance @@ -166,7 +166,7 @@ export function ClusterDeploymentList({ organizationId, clusterId }: ClusterDepl )}
-
+
{data.action_status === 'ERROR' && ( ({ let mockClusterDeploymentHistoryProps: UseClusterDeploymentHistoryProps | undefined +const defaultDeploymentHistory: ClusterDeploymentHistory[] = [ + { + identifier: { + deployment_id: 'deployment-1', + execution_id: 'execution-1', + cluster_id: 'cluster-1', + }, + auditing_data: { + created_at: '2026-08-10T12:30:00Z', + updated_at: '2026-08-10T12:31:00Z', + }, + status: 'DEPLOYED', + action_status: 'SUCCESS', + trigger_action: 'DEPLOY', + operation: ClusterDeploymentHistoryOperationEnum.CLUSTER, + is_dry_run: false, + reason: 'UNSPECIFIED', + total_duration: 'PT1M', + }, +] + +let mockDeploymentHistory = defaultDeploymentHistory + jest.mock('../hooks/use-cluster-deployment-history/use-cluster-deployment-history', () => ({ useClusterDeploymentHistory: (props: UseClusterDeploymentHistoryProps) => { mockClusterDeploymentHistoryProps = props return { - data: [ - { - identifier: { - deployment_id: 'deployment-1', - execution_id: 'execution-1', - cluster_id: 'cluster-1', - }, - auditing_data: { - created_at: '2026-08-10T12:30:00Z', - }, - status: 'DEPLOYED', - action_status: 'SUCCESS', - trigger_action: 'DEPLOY', - reason: 'UNSPECIFIED', - total_duration: 'PT1M', - }, - ], + data: mockDeploymentHistory, isFetched: true, } }, @@ -101,6 +110,7 @@ describe('ClusterLastDeploymentSection', () => { beforeEach(() => { jest.clearAllMocks() mockClusterDeploymentHistoryProps = undefined + mockDeploymentHistory = defaultDeploymentHistory }) it('renders the latest cluster deployment row linked to its deployment logs', () => { @@ -181,6 +191,92 @@ describe('ClusterLastDeploymentSection', () => { expect(screen.getByText('Maintenance')).toBeInTheDocument() }) + it('renders the Operator action with the reason when the last deployment is an Operator update', () => { + mockDeploymentHistory = [ + { ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE }, + ] + + renderWithProviders( + + ) + + expect(screen.getByText('Operator')).toBeInTheDocument() + expect(screen.queryByText('Deploy')).not.toBeInTheDocument() + expect(screen.getByText('Maintenance')).toBeInTheDocument() + expect(screen.getByText('Operator').closest('a')?.querySelector('[tabindex]')).toBeNull() + expect(screen.getByRole('link', { name: /Operator update/ })).toHaveAttribute( + 'href', + '/organization/org-1/cluster/cluster-1/deployments/logs/deployment-1' + ) + }) + + it('renders the Deploy action when the cluster status has no last execution id', () => { + mockDeploymentHistory = [ + { ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE }, + ] + + renderWithProviders( + + ) + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + + it('renders the Deploy action when the last deployment is a cluster deployment', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + + it('renders the Deploy action when operation is missing from the payload', () => { + const legacyDeployment: Partial = { ...defaultDeploymentHistory[0] } + delete legacyDeployment.operation + mockDeploymentHistory = [legacyDeployment as ClusterDeploymentHistory] + + renderWithProviders( + + ) + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + + it('ignores a stale Operator update when the cluster status points to a newer execution', () => { + mockDeploymentHistory = [ + { ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE }, + ] + + renderWithProviders( + + ) + + expect(screen.getByText('Stop')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + it('renders an empty state when the cluster has no deployment information', () => { renderWithProviders( { if (!clusterStatus) return @@ -170,7 +175,10 @@ export function ClusterLastDeploymentSection({ >
- + {deploymentReasonLabel && ( diff --git a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx index 0487137548d..d7f94026945 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx @@ -1,5 +1,5 @@ import download from 'downloadjs' -import { ClusterStateEnum } from 'qovery-typescript-axios' +import { ClusterDeploymentHistoryOperationEnum, ClusterStateEnum } from 'qovery-typescript-axios' import { type RefObject } from 'react' import { clusterFactoryMock, clusterLogFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen } from '@qovery/shared/util-tests' @@ -45,6 +45,20 @@ describe('ClusterHeaderLogs', () => { expect(screen.getByText('Romain Billard')).toBeInTheDocument() }) + it('should render an Operator update badge when operation is OPERATOR_UPDATE', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Operator update')).toBeInTheDocument() + }) + + it('should not render an Operator update badge when operation is CLUSTER', () => { + renderWithProviders() + + expect(screen.queryByText('Operator update')).not.toBeInTheDocument() + }) + it('should trigger scroll up on click', async () => { const { userEvent } = renderWithProviders() const scrollUpButton = screen.getByTestId('scroll-up-button') diff --git a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx index c5d67642b6e..4a94246eb88 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx @@ -2,6 +2,7 @@ import download from 'downloadjs' import posthog from 'posthog-js' import { type Cluster, + ClusterDeploymentHistoryOperationEnum, type ClusterLogs, type ClusterStatus, type DeploymentHistoryActionStatus, @@ -9,7 +10,7 @@ import { } from 'qovery-typescript-axios' import { type RefObject, useContext } from 'react' import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/feature' -import { Button, Icon, Tooltip, Truncate } from '@qovery/shared/ui' +import { Badge, Button, Icon, Tooltip, Truncate } from '@qovery/shared/ui' import { dateDifference, dateFullFormat, dateUTCString, formatDuration } from '@qovery/shared/util-dates' import { useIntervalTick } from '@qovery/shared/util-hooks' import { EventOriginIcon } from '@qovery/shared/util-icons' @@ -35,6 +36,7 @@ export interface ClusterHeaderLogsProps { triggeredBy?: string | null actionStatus?: DeploymentHistoryActionStatus totalDuration?: string | null + operation?: ClusterDeploymentHistoryOperationEnum } export function ClusterHeaderLogs({ @@ -49,6 +51,7 @@ export function ClusterHeaderLogs({ triggeredBy, actionStatus, totalDuration, + operation, }: ClusterHeaderLogsProps) { const { setDevopsCopilotOpen, sendMessageRef } = useContext(DevopsCopilotContext) @@ -90,6 +93,11 @@ export function ClusterHeaderLogs({ )} + {operation === ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE && ( + + Operator update + + )} {createdAt && ( <> ({ + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ organizationId: 'organization-id', clusterId: 'cluster-id' }), +})) +jest.mock('../hooks/use-update-cluster-operator/use-update-cluster-operator') + +const mockUseUpdateClusterOperator = useUpdateClusterOperator as jest.Mock +const mockUpdateOperator = jest.fn() + +const operatorStatus = { + status: 'OUTDATED_CHART', + operator_connected: true, + desired_chart_version: '1.2.0', + desired_image_version: '1.2.0', +} as ClusterOperatorStatusResponse + +describe('ClusterOperatorStatus', () => { + beforeEach(() => { + mockUpdateOperator.mockReset() + mockUseUpdateClusterOperator.mockReturnValue({ mutate: mockUpdateOperator, isLoading: false }) + }) + + it('updates the Operator to the target versions', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Update Operator' })) + + expect(mockUpdateOperator).toHaveBeenCalledWith({ + organizationId: 'organization-id', + clusterId: 'cluster-id', + chartVersion: '1.2.0', + imageVersion: '1.2.0', + }) + }) + + it('does not start another update while one is in progress', async () => { + mockUseUpdateClusterOperator.mockReturnValue({ mutate: mockUpdateOperator, isLoading: true }) + const { userEvent } = renderWithProviders() + + const button = screen.getByRole('button', { name: 'Update Operator' }) + expect(button).toBeDisabled() + await userEvent.click(button) + + expect(mockUpdateOperator).not.toHaveBeenCalled() + }) + + it('hides the update action while the Operator is not connected', () => { + renderWithProviders() + + expect(screen.queryByRole('button', { name: 'Update Operator' })).not.toBeInTheDocument() + }) + + it('disables the update action without a target chart version', () => { + renderWithProviders() + + expect(screen.getByRole('button', { name: 'Update Operator' })).toBeDisabled() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.tsx b/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.tsx new file mode 100644 index 00000000000..5e8599de14f --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.tsx @@ -0,0 +1,147 @@ +import { type IconName } from '@fortawesome/fontawesome-common-types' +import { useParams } from '@tanstack/react-router' +import { type ClusterOperatorFleetStatus, type ClusterOperatorStatusResponse } from 'qovery-typescript-axios' +import { match } from 'ts-pattern' +import { Badge, Button, StatusChip } from '@qovery/shared/ui' +import { timeAgo } from '@qovery/shared/util-dates' +import { useUpdateClusterOperator } from '../hooks/use-update-cluster-operator/use-update-cluster-operator' + +export function getClusterOperatorStatusDisplay(status: ClusterOperatorFleetStatus) { + return match(status) + .with('CURRENT', () => ({ + label: 'Versions current', + color: 'green' as const, + chipStatus: 'DEPLOYED' as const, + icon: { name: 'circle-check' as IconName, className: 'text-positive' }, + description: 'The Operator is connected and runs the selected image and Helm chart versions.', + })) + .with('DISCONNECTED', () => ({ + label: 'Disconnected', + color: 'red' as const, + chipStatus: 'ERROR' as const, + icon: { name: 'circle-xmark' as IconName, className: 'text-negative' }, + description: 'No recent heartbeat was received from the Operator.', + })) + .with('OUTDATED_IMAGE', () => ({ + label: 'Image update available', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'The installed Operator image differs from the selected version.', + })) + .with('OUTDATED_CHART', () => ({ + label: 'Chart update available', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'The installed Helm chart differs from the selected version.', + })) + .with('OUTDATED_IMAGE_AND_CHART', () => ({ + label: 'Update available', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'The installed Operator image and Helm chart differ from the selected versions.', + })) + .with('TARGET_UNKNOWN', () => ({ + label: 'Target unknown', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'Qovery cannot determine the image or Helm chart version selected for this cluster.', + })) + .with('REPORTED_VERSION_UNKNOWN', () => ({ + label: 'Version unknown', + color: 'yellow' as const, + chipStatus: 'WARNING' as const, + icon: { name: 'circle-exclamation' as IconName, className: 'text-warning' }, + description: 'The Operator is connected but does not report enough version information.', + })) + .with('NOT_ATTACHED', () => ({ + label: 'Not attached', + color: 'neutral' as const, + chipStatus: 'UNKNOWN' as const, + icon: { name: 'circle-minus' as IconName, className: 'text-neutral-subtle' }, + description: 'This cluster is not attached to the Qovery Operator execution path.', + })) + .exhaustive() +} + +function Version({ installed, target }: { installed?: string | null; target?: string | null }) { + return ( + + {installed ?? 'Not reported'} + Target: {target ?? 'Unknown'} + + ) +} + +export interface ClusterOperatorStatusProps { + operatorStatus: ClusterOperatorStatusResponse +} + +export function ClusterOperatorStatus({ operatorStatus }: ClusterOperatorStatusProps) { + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { mutate: updateOperator, isLoading: isUpdating } = useUpdateClusterOperator() + const display = getClusterOperatorStatusDisplay(operatorStatus.status) + + return ( +
+
+
+

Qovery Operator

+

{display.description}

+
+
+ + + {display.label} + + {operatorStatus.operator_connected && ( + + )} +
+
+ +
+
+
Last heartbeat
+
+ {operatorStatus.last_heartbeat ? `${timeAgo(new Date(operatorStatus.last_heartbeat))} ago` : 'Never'} +
+
+
+
Operator image
+
+ +
+
+
+
Helm chart
+
+ +
+
+
+
+ ) +} + +export default ClusterOperatorStatus diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx new file mode 100644 index 00000000000..22569ed15f8 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx @@ -0,0 +1,267 @@ +import { useBlocker, useParams } from '@tanstack/react-router' +import equal from 'fast-deep-equal' +import { + type ClusterPlatformConfigurationRequest, + type ClusterPlatformConfigurationResponse, + type PlatformTemplateSummaryResponse, +} from 'qovery-typescript-axios' +import { type PropsWithChildren, createContext, useContext, useState } from 'react' +import { type SchemaFieldValue, getSchemaFieldValue } from '@qovery/shared/schema-fields/feature' +import { toast } from '@qovery/shared/ui' +import { useDeployCluster } from '../hooks/use-deploy-cluster/use-deploy-cluster' +import { useUpdatePlatformConfiguration } from '../hooks/use-update-platform-configuration/use-update-platform-configuration' +import { + type PlatformFieldDescriptor, + type PlatformScalarField, + applyPlatformConfigurationDefaults, + isPlatformScalarField, + omitEmptyValues, + updateComponentValue, +} from '../utils/platform-configuration-utils' +import { type ProfileTreeItem } from './profile-tree' +import { useClusterProfileTree } from './use-cluster-profile-tree' + +const PROFILE_ROUTE_ID = '/_authenticated/organization/$organizationId/cluster/$clusterId/profile' +const LEAVE_UNSAVED_CHANGES_MESSAGE = 'You have unsaved changes on this profile. Leave the page and discard them?' + +export type ProfileValues = Record> +export type ClusterInputValues = Record> + +// Scalars are compared as the inputs display them: an unset toggle shows as off, an unset text as empty. +function getDisplayedScalarValue(field: Pick, value: unknown) { + return getSchemaFieldValue(field, value) ?? (field.type === 'bool' ? false : '') +} + +// Local edits differing from the saved (or default) value; edits reverted by hand do not count. +function getChangedProfileValues( + profileTree: ProfileTreeItem[], + configuration: ClusterPlatformConfigurationResponse | null | undefined, + profileValues: ProfileValues +): ProfileValues { + const fieldsByComponent = new Map( + profileTree.flatMap((layer) => layer.children).map((component) => [component.key, component.fields]) + ) + return Object.fromEntries( + Object.entries(profileValues).flatMap(([componentKey, values]) => { + const fields = fieldsByComponent.get(componentKey) ?? [] + const savedValues = applyPlatformConfigurationDefaults( + fields, + configuration?.platform.managedConfig?.[componentKey] ?? {} + ) + const changedValues = Object.fromEntries( + Object.entries(values).filter(([fieldKey, value]) => { + const field = fields.find(({ key }) => key === fieldKey) + return field && isPlatformScalarField(field) + ? getDisplayedScalarValue(field, value) !== getDisplayedScalarValue(field, savedValues[fieldKey]) + : !equal(value, savedValues[fieldKey]) + }) + ) + return Object.keys(changedValues).length ? [[componentKey, changedValues]] : [] + }) + ) +} + +function countProfileChanges( + profileTree: ProfileTreeItem[], + configuration: ClusterPlatformConfigurationResponse | null | undefined, + profileValues: ProfileValues, + clusterInputs: ClusterInputValues +) { + const profileChanges = Object.values(getChangedProfileValues(profileTree, configuration, profileValues)).flatMap( + (values) => Object.keys(values) + ) + const clusterInputChanges = Object.entries(clusterInputs).flatMap(([componentKey, values]) => + Object.entries(values).filter( + ([inputKey, value]) => value !== (configuration?.clusterInputs[componentKey]?.[inputKey] ?? '') + ) + ) + + return profileChanges.length + clusterInputChanges.length +} + +function omitSubmittedValues>>(currentValues: T, submittedValues: T) { + return Object.fromEntries( + Object.entries(currentValues).flatMap(([componentKey, values]) => { + const remainingValues = Object.fromEntries( + Object.entries(values).filter(([key, value]) => !equal(value, submittedValues[componentKey]?.[key])) + ) + return Object.keys(remainingValues).length ? [[componentKey, remainingValues]] : [] + }) + ) as T +} + +// PUT replaces the whole configuration: unedited components and inputs are sent back as saved, and edits reverted +// by hand are left out so the field keeps following the schema default. +function getConfigurationRequest( + template: PlatformTemplateSummaryResponse, + profileTree: ProfileTreeItem[], + configuration: ClusterPlatformConfigurationResponse | null | undefined, + profileValues: ProfileValues, + clusterInputs: ClusterInputValues +): ClusterPlatformConfigurationRequest { + const savedManagedConfig = configuration?.platform.managedConfig + const managedConfig = { ...savedManagedConfig } + for (const [componentKey, values] of Object.entries( + getChangedProfileValues(profileTree, configuration, profileValues) + )) { + managedConfig[componentKey] = omitEmptyValues({ ...savedManagedConfig?.[componentKey], ...values }) + } + const nextClusterInputs = { ...configuration?.clusterInputs } + for (const [componentKey, values] of Object.entries(clusterInputs)) { + if (!Object.keys(values).length) continue + nextClusterInputs[componentKey] = { ...configuration?.clusterInputs[componentKey], ...values } + } + + return { + platform: { + templateKey: configuration?.platform.templateKey ?? template.key, + templateVersion: configuration?.platform.templateVersion ?? template.version, + layerSelections: configuration?.platform.layerSelections, + managedConfig, + }, + clusterInputs: nextClusterInputs, + } +} + +interface ClusterProfileContextValue { + organizationId: string + clusterId: string + templates?: PlatformTemplateSummaryResponse[] + configuration?: ClusterPlatformConfigurationResponse | null + profileTree: ProfileTreeItem[] + isLoading: boolean + isError: boolean + // Unsaved edits, keyed by component then field. + profileValues: ProfileValues + clusterInputs: ClusterInputValues + // Changes on reset: some inputs are uncontrolled, remounting the form shows the saved values again. + formKey: number + changeCount: number + isSaving: boolean + isDeploying: boolean + updateProfileConfig: (componentKey: string, fieldKey: string, value: unknown) => void + updateClusterInput: (componentKey: string, field: PlatformFieldDescriptor, value: SchemaFieldValue) => void + resetChanges: () => void + saveChanges: () => Promise + saveAndDeployChanges: () => Promise +} + +const ClusterProfileContext = createContext(undefined) + +export function ClusterProfileProvider({ children }: PropsWithChildren) { + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const [profileValues, setProfileValues] = useState({}) + const [clusterInputs, setClusterInputs] = useState({}) + const [formKey, setFormKey] = useState(0) + const [isSaveAndDeployPending, setIsSaveAndDeployPending] = useState(false) + const { mutateAsync: updatePlatformConfiguration, isLoading: isSavingConfiguration } = + useUpdatePlatformConfiguration() + const { mutateAsync: deployCluster } = useDeployCluster() + const { templates, configuration, selectedTemplate, profileTree, isLoading, isError } = useClusterProfileTree({ + organizationId, + clusterId, + }) + + const updateProfileConfig = (componentKey: string, fieldKey: string, value: unknown) => { + setProfileValues((currentValues) => updateComponentValue(currentValues, componentKey, fieldKey, value)) + } + + const updateClusterInput = (componentKey: string, field: PlatformFieldDescriptor, value: SchemaFieldValue) => { + // The input shows its default while unsaved: setting it back to what is displayed is no edit. + const nextValue = String(value) + const isDisplayedValue = + getDisplayedScalarValue(field, nextValue) === + getDisplayedScalarValue(field, configuration?.clusterInputs[componentKey]?.[field.key]) + setClusterInputs((currentValues) => + updateComponentValue(currentValues, componentKey, field.key, isDisplayedValue ? undefined : nextValue) + ) + } + + const changeCount = countProfileChanges(profileTree, configuration, profileValues, clusterInputs) + const hasChanges = changeCount > 0 + + useBlocker({ + disabled: !hasChanges, + // Edits are kept while switching components of this profile: only leaving it would drop them. + shouldBlockFn: ({ next }) => + !( + next.routeId.startsWith(PROFILE_ROUTE_ID) && + 'clusterId' in next.params && + next.params.clusterId === clusterId + ) && !window.confirm(LEAVE_UNSAVED_CHANGES_MESSAGE), + enableBeforeUnload: hasChanges, + }) + + const resetChanges = () => { + setProfileValues({}) + setClusterInputs({}) + setFormKey((key) => key + 1) + } + + const saveConfiguration = async () => { + if (!selectedTemplate) return + await updatePlatformConfiguration({ + clusterId, + configurationRequest: getConfigurationRequest( + selectedTemplate, + profileTree, + configuration, + profileValues, + clusterInputs + ), + }) + // The saved configuration now carries the submitted edits, so their local copies can go. + setProfileValues((currentValues) => omitSubmittedValues(currentValues, profileValues)) + setClusterInputs((currentValues) => omitSubmittedValues(currentValues, clusterInputs)) + } + + const saveChanges = async () => { + try { + await saveConfiguration() + toast('success', 'Profile saved', 'Deploy the cluster to apply the changes.') + } catch { + // Errors are notified by the mutation. + } + } + + const saveAndDeployChanges = async () => { + setIsSaveAndDeployPending(true) + try { + await saveConfiguration() + await deployCluster({ organizationId, clusterId }) + } catch { + // Errors are notified by the mutations. + } finally { + setIsSaveAndDeployPending(false) + } + } + + const value: ClusterProfileContextValue = { + organizationId, + clusterId, + templates, + configuration, + profileTree, + isLoading, + isError, + profileValues, + clusterInputs, + formKey, + changeCount, + isSaving: isSavingConfiguration && !isSaveAndDeployPending, + isDeploying: isSaveAndDeployPending, + updateProfileConfig, + updateClusterInput, + resetChanges, + saveChanges, + saveAndDeployChanges, + } + + return {children} +} + +export function useClusterProfileContext() { + const context = useContext(ClusterProfileContext) + if (!context) throw new Error('useClusterProfileContext must be used within a ClusterProfileProvider') + return context +} diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx new file mode 100644 index 00000000000..463971686a3 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx @@ -0,0 +1,219 @@ +import { type ReactNode, useState } from 'react' +import { IconEnum } from '@qovery/shared/enums' +import { Icon, InputSearch, Tooltip } from '@qovery/shared/ui' + +type LayerStatus = 'disabled' | 'success' + +export const NOT_CONFIGURABLE_COMPONENT_REASON = 'No configuration needed for this component' +export const NOT_CONFIGURABLE_LAYER_REASON = 'No configuration needed for this layer yet' + +export interface ClusterProfileSidebarItem { + id: string + key: string + label: string + configurable: boolean +} + +export interface ClusterProfileSidebarLayer { + id: string + label: string + status: LayerStatus + disabledReason?: string + configurable: boolean + items: readonly ClusterProfileSidebarItem[] +} + +interface LayerSectionRowProps { + section: ClusterProfileSidebarLayer + selectedSectionId?: string + selectedItemId?: string + onSelectSection: (sectionId: string) => void + onSelectItem: (itemId: string) => void +} + +function LayerIcon() { + return