From d42bd7f963d837891c770176bdc2a9c18b3fd70a Mon Sep 17 00:00:00 2001 From: Mate Vago Date: Thu, 16 May 2024 13:39:03 +0200 Subject: [PATCH 01/14] fix(crux-ui): secret releated env keys (#974) --- .../images/config/common-config-section.tsx | 15 +++++++++++++-- web/crux-ui/src/validations/common.ts | 2 +- web/crux-ui/src/validations/container.ts | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/web/crux-ui/src/components/projects/versions/images/config/common-config-section.tsx b/web/crux-ui/src/components/projects/versions/images/config/common-config-section.tsx index 8b8bcf4fb..5ce6ad716 100644 --- a/web/crux-ui/src/components/projects/versions/images/config/common-config-section.tsx +++ b/web/crux-ui/src/components/projects/versions/images/config/common-config-section.tsx @@ -32,7 +32,14 @@ import { mergeConfigs, } from '@app/models' import { fetcher, toNumber } from '@app/utils' -import { ContainerConfigValidationErrors, findErrorFor, findErrorStartsWith, matchError } from '@app/validations' +import { + ContainerConfigValidationErrors, + findErrorFor, + findErrorStartsWith, + getValidationError, + matchError, + unsafeUniqueKeyValuesSchema, +} from '@app/validations' import clsx from 'clsx' import useTranslation from 'next-translate/useTranslation' import useSWR from 'swr' @@ -137,6 +144,9 @@ const CommonConfigSection = (props: CommonConfigSectionProps) => { onChange(patch) } + const environmentWarning = + getValidationError(unsafeUniqueKeyValuesSchema, config.environment, undefined, t)?.message ?? null + return !filterEmpty([...COMMON_CONFIG_PROPERTIES], selectedFilters) ? null : (
@@ -453,7 +463,8 @@ const CommonConfigSection = (props: CommonConfigSectionProps) => { editorOptions={editorOptions} disabled={disabled} findErrorMessage={index => findErrorStartsWith(fieldErrors, `environment[${index}]`)} - message={findErrorFor(fieldErrors, `environment`)} + message={findErrorFor(fieldErrors, `environment`) ?? environmentWarning} + messageType={!findErrorFor(fieldErrors, `environment`) && environmentWarning ? 'info' : 'error'} />
)} diff --git a/web/crux-ui/src/validations/common.ts b/web/crux-ui/src/validations/common.ts index 548bcd2da..3b043c931 100644 --- a/web/crux-ui/src/validations/common.ts +++ b/web/crux-ui/src/validations/common.ts @@ -52,7 +52,7 @@ export const yupErrorTranslate = (error: yup.ValidationError, t: Translate): yup } export const getValidationError = ( - schema: yup.Schema, + schema: yup.AnySchema, candidate: any, options?: yup.ValidateOptions, t?: Translate, diff --git a/web/crux-ui/src/validations/container.ts b/web/crux-ui/src/validations/container.ts index c72ed81eb..07f939077 100644 --- a/web/crux-ui/src/validations/container.ts +++ b/web/crux-ui/src/validations/container.ts @@ -487,7 +487,7 @@ const testEnvironment = (imageLabels: Record) => (arr: UniqueKey const createContainerConfigBaseSchema = (imageLabels: Record) => yup.object().shape({ name: matchNoWhitespace(yup.string().required().label('container:common.containerName')), - environment: unsafeUniqueKeyValuesSchema + environment: uniqueKeyValuesSchema .default([]) .nullable() .label('container:common.environment') From 271c9462e02e1892b722fcbbddc1f63ae5bca450 Mon Sep 17 00:00:00 2001 From: Mate Vago Date: Tue, 21 May 2024 10:49:29 +0200 Subject: [PATCH 02/14] feat(crux): get container list api (#976) Co-authored-by: Levente Orban --- .../node/node.container.http.controller.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/web/crux/src/app/node/node.container.http.controller.ts b/web/crux/src/app/node/node.container.http.controller.ts index c71facd9d..de9213968 100644 --- a/web/crux/src/app/node/node.container.http.controller.ts +++ b/web/crux/src/app/node/node.container.http.controller.ts @@ -34,7 +34,7 @@ import { ROUTE_PREFIX, ROUTE_TEAM_SLUG, } from './node.const' -import { ContainerInspectionDto, NodeContainerLogQuery } from './node.dto' +import { ContainerDto, ContainerInspectionDto, NodeContainerLogQuery } from './node.dto' import NodeService from './node.service' import NodeContainerLogQueryValidationPipe from './pipes/node.container-log-query.pipe' @@ -48,6 +48,23 @@ const TeamSlug = () => Param(PARAM_TEAM_SLUG) export default class NodeContainerHttpController { constructor(private service: NodeService) {} + @Get() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + description: 'Request must include `nodeId`, `prefix`.', + summary: 'Returns a list of containers on a node. Use `_` as the prefix to get all containers.', + }) + @ApiOkResponse({ description: 'Container list.' }) + @ApiBadRequestResponse({ description: 'Bad request for get container list.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for get container list.' }) + async getContainers( + @TeamSlug() _: string, + @NodeId() nodeId: string, + @Prefix() prefix: string, + ): Promise { + return await this.service.getContainers(nodeId, prefix) + } + @Post(`${ROUTE_NAME}/start`) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ From d6c5301cd297bae5eea10d98a472c413d561408f Mon Sep 17 00:00:00 2001 From: Nandor Magyar Date: Tue, 21 May 2024 14:22:47 +0200 Subject: [PATCH 03/14] ci: tag version only from release (#975) Co-authored-by: Levente Orban --- .github/workflows/pipeline_set_output_tag.sh | 28 ++++++----- .github/workflows/product_builder.yaml | 49 +++++++++++++++----- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/.github/workflows/pipeline_set_output_tag.sh b/.github/workflows/pipeline_set_output_tag.sh index ba0618fec..64180034e 100755 --- a/.github/workflows/pipeline_set_output_tag.sh +++ b/.github/workflows/pipeline_set_output_tag.sh @@ -7,7 +7,8 @@ github_ref_name=$2 github_sha=$3 github_base_ref=${4:-""} -DOCKERIMAGETAG="$github_sha" +IMAGETAG="$github_sha" +EXTRATAG="" # These values are wrong on purpose: if we see these default values in the wild, # we will know something happened that shouldn't have VERSION="v0.0.0" @@ -15,25 +16,28 @@ MINORVERSION="v0.0.0" if [ "$github_ref_type" = "branch" ]; then case $github_ref_name in - "main") DOCKERIMAGETAG="stable" + "main") IMAGETAG="stable" ;; - "develop") DOCKERIMAGETAG="latest" + "develop") IMAGETAG="latest" ;; esac - if [ $github_base_ref = "main" ]; then - DOCKERIMAGETAG="stable" - elif [ $github_base_ref != "" ]; then - DOCKERIMAGETAG="latest" + if [ "$github_base_ref" = "main" ]; then + IMAGETAG="stable" + elif [ "$github_base_ref" != "" ]; then + IMAGETAG="latest" fi -fi - -if [ "$github_ref_type" = "tag" ]; then - DOCKERIMAGETAG=$github_ref_name +elif [ "$github_ref_type" = "tag" ]; then + IMAGETAG="$github_ref_name" + EXTRATAG="stable" VERSION=$github_ref_name MINORVERSION=$(echo "$github_ref_name"| cut -d. -f1-2) +else + echo unexpected github_ref_type + exit 1 fi -echo "tag=$DOCKERIMAGETAG" >> "$GITHUB_OUTPUT" +echo "tag=$IMAGETAG" >> "$GITHUB_OUTPUT" +echo "extratag=$EXTRATAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "minorversion=$MINORVERSION" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/product_builder.yaml b/.github/workflows/product_builder.yaml index a889b4104..004630222 100644 --- a/.github/workflows/product_builder.yaml +++ b/.github/workflows/product_builder.yaml @@ -2,7 +2,9 @@ name: product_builder on: push: branches: [develop, main] - tags: ['*'] + release: + types: + - "published" pull_request: types: [edited, opened, synchronize, reopened] permissions: @@ -24,7 +26,7 @@ env: KRATOS_WORKING_DIRECTORY: web/kratos GOLANG_WORKING_DIRECTORY: golang concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.sha }} cancel-in-progress: true jobs: conventional_commits: @@ -68,6 +70,7 @@ jobs: cruxui: ${{ steps.filter.outputs.cruxui }} kratos: ${{ steps.filter.outputs.kratos }} tag: ${{ steps.settag.outputs.tag }} + extratag: ${{ steps.settag.outputs.extratag }} version: ${{ steps.settag.outputs.version }} minorversion: ${{ steps.settag.outputs.minorversion }} release: ${{ steps.release.outputs.release }} @@ -519,7 +522,7 @@ jobs: # runs-on: self-hosted container: image: ghcr.io/dyrector-io/dyrectorio/playwright:latest - volumes: ['/var/run/docker.sock:/var/run/docker'] + volumes: ["/var/run/docker.sock:/var/run/docker"] needs: - go_build - crux_build @@ -613,11 +616,11 @@ jobs: # DEBUG: pw:api HUB_PROXY_URL: ${{ secrets.HUB_PROXY_URL }} HUB_PROXY_TOKEN: ${{ secrets.HUB_PROXY_TOKEN }} - E2E_BASE_URL: 'http://dyo-e2e_traefik:8000' - MAILSLURPER_URL: 'http://dyo-e2e_mailslurper:4437' - CRUX_UI_URL: 'http://dyo-e2e_traefik:8000' - KRATOS_URL: 'http://dyo-e2e_kratos:4433' - KRATOS_ADMIN_URL: 'http://dyo-e2e_kratos:4434' + E2E_BASE_URL: "http://dyo-e2e_traefik:8000" + MAILSLURPER_URL: "http://dyo-e2e_mailslurper:4437" + CRUX_UI_URL: "http://dyo-e2e_traefik:8000" + KRATOS_URL: "http://dyo-e2e_kratos:4433" + KRATOS_ADMIN_URL: "http://dyo-e2e_kratos:4434" CI: true run: | npm ci --include=dev --arch=x64 --cache .npm --prefer-offline --no-fund @@ -763,6 +766,15 @@ jobs: crane cp ${GITHUB_REGISTRY}/${CRANE_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/crane:${{ needs.gather_changes.outputs.tag }} crane cp ${GITHUB_REGISTRY}/${DAGENT_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/dagent:${{ needs.gather_changes.outputs.tag }} crane cp ${GITHUB_REGISTRY}/${CLI_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/dyo:${{ needs.gather_changes.outputs.tag }} + - name: Docker tag extra + if: ${{ needs.gather_changes.outputs.extratag != '' }} + run: | + crane cp ${GITHUB_REGISTRY}/${CRANE_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/crane:${{ needs.gather_changes.outputs.extratag }} + crane cp ${GITHUB_REGISTRY}/${DAGENT_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/dagent:${{ needs.gather_changes.outputs.extratag }} + crane cp ${GITHUB_REGISTRY}/${CLI_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/dyo:${{ needs.gather_changes.outputs.extratag }} + crane cp ${GITHUB_REGISTRY}/${CRANE_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${GITHUB_REGISTRY}/${CRANE_IMAGE_NAME}:${{ needs.gather_changes.outputs.extratag }} + crane cp ${GITHUB_REGISTRY}/${DAGENT_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${GITHUB_REGISTRY}/${DAGENT_IMAGE_NAME}:${{ needs.gather_changes.outputs.extratag }} + crane cp ${GITHUB_REGISTRY}/${CLI_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${GITHUB_REGISTRY}/${CLI_IMAGE_NAME}:${{ needs.gather_changes.outputs.extratag }} - name: Add minor version tag if: github.ref_type == 'tag' run: | @@ -823,12 +835,17 @@ jobs: - name: Docker tag run: | docker tag ${GITHUB_REGISTRY}/${CRUX_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/crux:${{ needs.gather_changes.outputs.tag }} + - name: Docker tag extra + if: ${{ needs.gather_changes.outputs.extratag != '' }} + run: | + docker tag ${GITHUB_REGISTRY}/${CRUX_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${GITHUB_REGISTRY}/${CRUX_IMAGE_NAME}:${{ needs.gather_changes.outputs.extratag }} + docker tag ${GITHUB_REGISTRY}/${CRUX_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/crux:${{ needs.gather_changes.outputs.extratag }} - name: Add minor version tag if: github.ref_type == 'tag' run: | docker tag ${GITHUB_REGISTRY}/${CRUX_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/crux:${{ needs.gather_changes.outputs.minorversion }} docker tag ${GITHUB_REGISTRY}/${CRUX_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${GITHUB_REGISTRY}/${CRUX_IMAGE_NAME}:${{ needs.gather_changes.outputs.minorversion }} - - name: Docker tag + - name: Docker push all tags run: | docker push -a ${GITHUB_REGISTRY}/${CRUX_IMAGE_NAME} docker push -a ${DOCKERHUB_REGISTRY}/crux @@ -881,12 +898,17 @@ jobs: - name: Docker tag run: | docker tag ${GITHUB_REGISTRY}/${CRUX_UI_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/crux-ui:${{ needs.gather_changes.outputs.tag }} + - name: Docker tag extra + if: ${{ needs.gather_changes.outputs.extratag != '' }} + run: | + docker tag ${GITHUB_REGISTRY}/${CRUX_UI_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${GITHUB_REGISTRY}/${CRUX_UI_IMAGE_NAME}:${{ needs.gather_changes.outputs.extratag }} + docker tag ${GITHUB_REGISTRY}/${CRUX_UI_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/crux-ui:${{ needs.gather_changes.outputs.extratag }} - name: Add minor version tag if: github.ref_type == 'tag' run: | docker tag ${GITHUB_REGISTRY}/${CRUX_UI_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/crux-ui:${{ needs.gather_changes.outputs.minorversion }} docker tag ${GITHUB_REGISTRY}/${CRUX_UI_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${GITHUB_REGISTRY}/${CRUX_UI_IMAGE_NAME}:${{ needs.gather_changes.outputs.minorversion }} - - name: Docker push + - name: Docker push all tags run: | docker push -a ${GITHUB_REGISTRY}/${CRUX_UI_IMAGE_NAME} docker push -a ${DOCKERHUB_REGISTRY}/crux-ui @@ -939,12 +961,17 @@ jobs: - name: Docker tag run: | docker tag ${GITHUB_REGISTRY}/${KRATOS_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/kratos:${{ needs.gather_changes.outputs.tag }} + - name: Docker tag extra + if: ${{ needs.gather_changes.outputs.extratag != '' }} + run: | + docker tag ${GITHUB_REGISTRY}/${KRATOS_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${GITHUB_REGISTRY}/${KRATOS_IMAGE_NAME}:${{ needs.gather_changes.outputs.extratag }} + docker tag ${GITHUB_REGISTRY}/${KRATOS_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/kratos:${{ needs.gather_changes.outputs.extratag }} - name: Add minor version tag if: github.ref_type == 'tag' run: | docker tag ${GITHUB_REGISTRY}/${KRATOS_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${DOCKERHUB_REGISTRY}/kratos:${{ needs.gather_changes.outputs.minorversion }} docker tag ${GITHUB_REGISTRY}/${KRATOS_IMAGE_NAME}:${{ needs.gather_changes.outputs.tag }} ${GITHUB_REGISTRY}/${KRATOS_IMAGE_NAME}:${{ needs.gather_changes.outputs.minorversion }} - - name: Docker push + - name: Docker push all tags run: | docker push -a ${GITHUB_REGISTRY}/${KRATOS_IMAGE_NAME} docker push -a ${DOCKERHUB_REGISTRY}/kratos From 07b4e70b7f10bef6d7dcc000943bd7e54c36d275 Mon Sep 17 00:00:00 2001 From: Mate Vago Date: Wed, 22 May 2024 11:38:28 +0200 Subject: [PATCH 04/14] feat(crux-ui): reorganize the menu structure (#977) --- web/crux-ui/locales/en/common.json | 3 +- .../src/components/main/nav-button.tsx | 29 +++--- .../src/components/main/nav-section.tsx | 18 ++-- .../src/components/main/profile-card.tsx | 90 +++++++++++++++++++ web/crux-ui/src/components/main/sidebar.tsx | 71 +++------------ .../components/main/team-selection-card.tsx | 56 ------------ web/crux-ui/src/components/main/top-bar.tsx | 7 +- 7 files changed, 134 insertions(+), 140 deletions(-) create mode 100644 web/crux-ui/src/components/main/profile-card.tsx delete mode 100644 web/crux-ui/src/components/main/team-selection-card.tsx diff --git a/web/crux-ui/locales/en/common.json b/web/crux-ui/locales/en/common.json index 5b61ce912..7d6ebcc79 100644 --- a/web/crux-ui/locales/en/common.json +++ b/web/crux-ui/locales/en/common.json @@ -27,8 +27,9 @@ "log": "Log", "home": "Home", - "settings": "Settings", "components": "Components", + "integrations": "Integrations", + "tools": "Tools", "logout": "Log out", diff --git a/web/crux-ui/src/components/main/nav-button.tsx b/web/crux-ui/src/components/main/nav-button.tsx index dc50e2f54..6725bc0c0 100644 --- a/web/crux-ui/src/components/main/nav-button.tsx +++ b/web/crux-ui/src/components/main/nav-button.tsx @@ -1,34 +1,39 @@ +import DyoIcon from '@app/elements/dyo-icon' import DyoLink from '@app/elements/dyo-link' import clsx from 'clsx' import { useRouter } from 'next/router' -interface NavButtonProps { - children: string +type NavButtonProps = { + className?: string href: string target?: string - passHref?: boolean - icon?: JSX.Element + icon: string + text: string + activeIndicator?: boolean } const NavButton = (props: NavButtonProps) => { - const { children, href, target, passHref, icon } = props + const { className, activeIndicator, href, target, icon, text } = props const router = useRouter() - const active = router.asPath.startsWith(href) + const active = activeIndicator && router.asPath.startsWith(href) return ( <> -
- -
-
{icon}
- {children} +
+ +
+
+ +
+ + {text}
-
 
+
 
) } diff --git a/web/crux-ui/src/components/main/nav-section.tsx b/web/crux-ui/src/components/main/nav-section.tsx index 7564c4ce3..5afed43fc 100644 --- a/web/crux-ui/src/components/main/nav-section.tsx +++ b/web/crux-ui/src/components/main/nav-section.tsx @@ -1,4 +1,3 @@ -import DyoIcon from '@app/elements/dyo-icon' import useTranslation from 'next-translate/useTranslation' import NavButton from './nav-button' @@ -9,7 +8,7 @@ export type MenuOption = { icon: string } -interface NavSectionProps { +type NavSectionProps = { title: string options: MenuOption[] className?: string @@ -20,17 +19,14 @@ export const NavSection = (props: NavSectionProps) => { const { t } = useTranslation('common') - const optionToIcon = (it: MenuOption) => - return (
-

{title.toUpperCase()}

-
    - {options.map((option, index) => ( -
  • - - {t(option.text)} - +

    {title.toUpperCase()}

    + +
      + {options.map((it, index) => ( +
    • +
    • ))}
    diff --git a/web/crux-ui/src/components/main/profile-card.tsx b/web/crux-ui/src/components/main/profile-card.tsx new file mode 100644 index 000000000..9b69e3aab --- /dev/null +++ b/web/crux-ui/src/components/main/profile-card.tsx @@ -0,0 +1,90 @@ +import { DyoCard } from '@app/elements/dyo-card' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { UserMeta, UserMetaTeam } from '@app/models' +import { ROUTE_DOCS, ROUTE_LOGOUT, ROUTE_PROFILE, ROUTE_TEAMS, TeamRoutes } from '@app/routes' +import clsx from 'clsx' +import useTranslation from 'next-translate/useTranslation' +import Image from 'next/image' +import NavButton from './nav-button' + +interface TeamSelectionCardProps { + className?: string + meta: UserMeta + onTeamSelected: (team: UserMetaTeam) => void +} + +const menuItemsOf = (routes: TeamRoutes) => [ + { + icon: '/audit.svg', + text: 'audit', + link: routes.audit.list(), + }, + { + icon: '/team.svg', + text: 'teams', + link: ROUTE_TEAMS, + }, + { + icon: '/documentation.svg', + text: 'documentation', + link: ROUTE_DOCS, + target: '_blank', + }, + { + icon: '/logout.svg', + text: 'logout', + link: ROUTE_LOGOUT, + }, +] + +const ProfileCard = (props: TeamSelectionCardProps) => { + const { meta, className, onTeamSelected } = props + + const { t } = useTranslation('common') + const routes = useTeamRoutes() + + const teamRoutes = useTeamRoutes() + const menuitems = menuItemsOf(teamRoutes) + + return ( + +
    + +
    + +

    {t('yourTeams').toUpperCase()}

    + + {meta.teams.map(team => { + const currentTeam = team.slug === routes?.teamSlug + + return ( +
    onTeamSelected(team)} + > + {t('teamAvatar')} + +
    {team.name}
    +
    + ) + })} + +
    + {menuitems.map(it => ( +
    + +
    + ))} +
    +
    + ) +} + +export default ProfileCard diff --git a/web/crux-ui/src/components/main/sidebar.tsx b/web/crux-ui/src/components/main/sidebar.tsx index 81ed9b3cf..b0bcef5ee 100644 --- a/web/crux-ui/src/components/main/sidebar.tsx +++ b/web/crux-ui/src/components/main/sidebar.tsx @@ -1,16 +1,6 @@ -import DyoIcon from '@app/elements/dyo-icon' import DyoLink from '@app/elements/dyo-link' import useTeamRoutes from '@app/hooks/use-team-routes' -import { - ROUTE_COMPOSER, - ROUTE_DOCS, - ROUTE_INDEX, - ROUTE_LOGOUT, - ROUTE_PROFILE, - ROUTE_TEAMS, - ROUTE_TEMPLATES, - TeamRoutes, -} from '@app/routes' +import { ROUTE_COMPOSER, ROUTE_INDEX, ROUTE_TEMPLATES, TeamRoutes } from '@app/routes' import useTranslation from 'next-translate/useTranslation' import Image from 'next/image' import NavButton from './nav-button' @@ -27,7 +17,7 @@ export interface SidebarProps { export const sidebarSectionsOf = (routes: TeamRoutes): MenuSection[] => [ { - title: 'project', + title: 'components', items: [ { icon: '/projects.svg', @@ -39,11 +29,6 @@ export const sidebarSectionsOf = (routes: TeamRoutes): MenuSection[] => [ text: 'deployments', link: routes.deployment.list(), }, - ], - }, - { - title: 'components', - items: [ { icon: '/servers.svg', text: 'nodes', @@ -59,6 +44,11 @@ export const sidebarSectionsOf = (routes: TeamRoutes): MenuSection[] => [ text: 'configBundles', link: routes.configBundles.list(), }, + ], + }, + { + title: 'integrations', + items: [ { icon: '/storage.svg', text: 'storages', @@ -74,6 +64,11 @@ export const sidebarSectionsOf = (routes: TeamRoutes): MenuSection[] => [ text: 'notifications', link: routes.notification.list(), }, + ], + }, + { + title: 'tools', + items: [ { icon: '/template.svg', text: 'templates', @@ -86,37 +81,6 @@ export const sidebarSectionsOf = (routes: TeamRoutes): MenuSection[] => [ }, ], }, - { - title: 'settings', - items: [ - { - icon: '/audit.svg', - text: 'audit', - link: routes.audit.list(), - }, - { - icon: '/team.svg', - text: 'teams', - link: ROUTE_TEAMS, - }, - { - icon: '/profile.svg', - text: 'profile', - link: ROUTE_PROFILE, - }, - { - icon: '/documentation.svg', - text: 'documentation', - link: ROUTE_DOCS, - target: '_blank', - }, - { - icon: '/logout.svg', - text: 'logout', - link: ROUTE_LOGOUT, - }, - ], - }, ] export const Sidebar = (props: SidebarProps) => { @@ -145,18 +109,11 @@ export const Sidebar = (props: SidebarProps) => { {sidebarSections && (
    - }> - {t('dashboard')} - +
    {sidebarSections.map((it, index) => ( - + ))}
    )} diff --git a/web/crux-ui/src/components/main/team-selection-card.tsx b/web/crux-ui/src/components/main/team-selection-card.tsx deleted file mode 100644 index b4a04c0c4..000000000 --- a/web/crux-ui/src/components/main/team-selection-card.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { DyoCard } from '@app/elements/dyo-card' -import { DyoList } from '@app/elements/dyo-list' -import useTeamRoutes from '@app/hooks/use-team-routes' -import { UserMeta, UserMetaTeam } from '@app/models' -import clsx from 'clsx' -import useTranslation from 'next-translate/useTranslation' -import Image from 'next/image' - -interface TeamSelectionCardProps { - className?: string - meta: UserMeta - onTeamSelected: (team: UserMetaTeam) => void -} - -const TeamSelectionCard = (props: TeamSelectionCardProps) => { - const { meta, className, onTeamSelected } = props - - const { t } = useTranslation('common') - const routes = useTeamRoutes() - - const itemTemplate = (team: UserMetaTeam) => { - const currentTeam = team.slug === routes?.teamSlug - - /* eslint-disable react/jsx-key */ - return [ -
    onTeamSelected(team)} - > - {t('teamAvatar')} -
    {team.name}
    -
    , - ] - /* eslint-enable react/jsx-key */ - } - - return ( - - - - ) -} - -export default TeamSelectionCard diff --git a/web/crux-ui/src/components/main/top-bar.tsx b/web/crux-ui/src/components/main/top-bar.tsx index 48eb003b2..2aaac2bf2 100644 --- a/web/crux-ui/src/components/main/top-bar.tsx +++ b/web/crux-ui/src/components/main/top-bar.tsx @@ -9,7 +9,7 @@ import useTranslation from 'next-translate/useTranslation' import { useRouter } from 'next/router' import { useState } from 'react' import UserDefaultAvatar from '../team/user-default-avatar' -import TeamSelectionCard from './team-selection-card' +import ProfileCard from './profile-card' interface TopbarProps { className?: string @@ -60,12 +60,13 @@ const Topbar = (props: TopbarProps) => { {!teamSelectionVisible ? null : ( <>
    -
    +
    )} From 3878ebba83a1f22b68853f193675ef63502653f2 Mon Sep 17 00:00:00 2001 From: Mate Vago Date: Thu, 23 May 2024 14:19:35 +0200 Subject: [PATCH 05/14] feat(web): add option to disable deployment copy on version increase (#978) --- web/crux-ui/locales/en/versions.json | 1 + .../components/projects/edit-project-card.tsx | 2 +- .../projects/versions/edit-version-card.tsx | 49 ++++-- web/crux-ui/src/models/version.ts | 7 +- .../[projectId]/versions/[versionId].tsx | 1 + .../migration.sql | 2 + web/crux/prisma/schema.prisma | 21 +-- web/crux/src/app/version/version.dto.ts | 17 +- web/crux/src/app/version/version.mapper.ts | 1 + web/crux/src/app/version/version.service.ts | 136 +++++++++------- web/crux/src/domain/version-increase.ts | 149 ++++++++++++++++++ 11 files changed, 300 insertions(+), 86 deletions(-) create mode 100644 web/crux/prisma/migrations/20240522094419_version_copy_deployments/migration.sql create mode 100644 web/crux/src/domain/version-increase.ts diff --git a/web/crux-ui/locales/en/versions.json b/web/crux-ui/locales/en/versions.json index 8afc3905c..c1086e731 100644 --- a/web/crux-ui/locales/en/versions.json +++ b/web/crux-ui/locales/en/versions.json @@ -9,6 +9,7 @@ "type": "Type", "incremental": "Incremental", "rolling": "Rolling", + "copyDeployments": "Copy deployments while increasing", "noItems": "You haven't added a version to this project yet. Click on 'Add Version'.", "noDeployments": "You haven't added a deployment to this version. Click 'Add deployment' to browse images you can add.", diff --git a/web/crux-ui/src/components/projects/edit-project-card.tsx b/web/crux-ui/src/components/projects/edit-project-card.tsx index 3b7000c75..971e04ece 100644 --- a/web/crux-ui/src/components/projects/edit-project-card.tsx +++ b/web/crux-ui/src/components/projects/edit-project-card.tsx @@ -111,7 +111,7 @@ const EditProjectCard = (props: EditProjectCardProps) => { value={formik.values.description} /> - {editing ? null : ( + {!editing && ( { name: '', changelog: '', type: 'incremental', - increasable: true, + autoCopyDeployments: true, audit: null, }, ) @@ -57,6 +59,7 @@ const EditVersionCard = (props: EditVersionCardProps) => { t, onSubmit: async (values, { setFieldError }) => { const body: CreateVersion | UpdateVersion = values + body.autoCopyDeployments = values.type === 'incremental' ? body.autoCopyDeployments : null const res = !editing ? await sendForm('POST', routes.project.versions(project.id).api.list(), body as CreateVersion) @@ -116,23 +119,35 @@ const EditVersionCard = (props: EditVersionCardProps) => { message={formik.errors.changelog} /> - {editing ? null : ( - <> - {t('type')} - - t(it)} - onSelectionChange={async (type): Promise => { - await formik.setFieldValue('type', type, false) - }} - qaLabel={chipsQALabelFromValue} +
    + {!editing && ( +
    + {t('type')} + + t(it)} + onSelectionChange={async (type): Promise => { + await formik.setFieldValue('type', type, false) + }} + qaLabel={chipsQALabelFromValue} + /> +
    + )} + + {formik.values.type === 'incremental' && ( + - - )} + )} +
    diff --git a/web/crux-ui/src/models/version.ts b/web/crux-ui/src/models/version.ts index b01dd1980..0116bc579 100644 --- a/web/crux-ui/src/models/version.ts +++ b/web/crux-ui/src/models/version.ts @@ -16,17 +16,20 @@ export type Version = BasicVersion & { changelog?: string default: boolean increasable: boolean + autoCopyDeployments?: boolean audit: Audit } -export type EditableVersion = Omit +export type EditableVersion = Omit export type IncreaseVersion = { name: string changelog?: string } -export type UpdateVersion = IncreaseVersion +export type UpdateVersion = IncreaseVersion & { + autoCopyDeployments?: boolean +} export type CreateVersion = UpdateVersion & { type: VersionType diff --git a/web/crux-ui/src/pages/[teamSlug]/projects/[projectId]/versions/[versionId].tsx b/web/crux-ui/src/pages/[teamSlug]/projects/[projectId]/versions/[versionId].tsx index 561bfd323..da21d6b6b 100644 --- a/web/crux-ui/src/pages/[teamSlug]/projects/[projectId]/versions/[versionId].tsx +++ b/web/crux-ui/src/pages/[teamSlug]/projects/[projectId]/versions/[versionId].tsx @@ -58,6 +58,7 @@ const VersionDetailsPage = (props: VersionDetailsPageProps) => { newAllVersion[index] = { ...newVersion, default: oldVersion.default, + increasable: oldVersion.increasable, } setAllVersions(newAllVersion) } diff --git a/web/crux/prisma/migrations/20240522094419_version_copy_deployments/migration.sql b/web/crux/prisma/migrations/20240522094419_version_copy_deployments/migration.sql new file mode 100644 index 000000000..b5e8b4c42 --- /dev/null +++ b/web/crux/prisma/migrations/20240522094419_version_copy_deployments/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Version" ADD COLUMN "autoCopyDeployments" BOOLEAN NOT NULL DEFAULT true; diff --git a/web/crux/prisma/schema.prisma b/web/crux/prisma/schema.prisma index 3aa23fa23..b5acb3c10 100644 --- a/web/crux/prisma/schema.prisma +++ b/web/crux/prisma/schema.prisma @@ -157,16 +157,17 @@ model Project { } model Version { - id String @id @default(uuid()) @db.Uuid - createdAt DateTime @default(now()) @db.Timestamptz(6) - createdBy String @db.Uuid - updatedAt DateTime @updatedAt @db.Timestamptz(6) - updatedBy String? @db.Uuid - name String @db.VarChar(70) - changelog String? - default Boolean @default(false) - type VersionTypeEnum @default(incremental) - projectId String @db.Uuid + id String @id @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + createdBy String @db.Uuid + updatedAt DateTime @updatedAt @db.Timestamptz(6) + updatedBy String? @db.Uuid + name String @db.VarChar(70) + changelog String? + default Boolean @default(false) + type VersionTypeEnum @default(incremental) + autoCopyDeployments Boolean @default(true) + projectId String @db.Uuid project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) images Image[] diff --git a/web/crux/src/app/version/version.dto.ts b/web/crux/src/app/version/version.dto.ts index 98932a069..ee91c2c66 100644 --- a/web/crux/src/app/version/version.dto.ts +++ b/web/crux/src/app/version/version.dto.ts @@ -42,6 +42,10 @@ export class UpdateVersionDto { @IsString() @IsOptional() changelog?: string + + @IsBoolean() + @IsOptional() + autoCopyDeployments?: boolean } export class CreateVersionDto extends UpdateVersionDto { @@ -53,7 +57,14 @@ export class CreateVersionDto extends UpdateVersionDto { type: VersionTypeDto } -export class IncreaseVersionDto extends UpdateVersionDto {} +export class IncreaseVersionDto { + @IsString() + name: string + + @IsString() + @IsOptional() + changelog?: string +} export class VersionDetailsDto extends VersionDto { @IsBoolean() @@ -62,6 +73,10 @@ export class VersionDetailsDto extends VersionDto { @IsBoolean() deletable: boolean + @IsBoolean() + @IsOptional() + autoCopyDeployments?: boolean + @Type(() => ImageDto) images: ImageDto[] diff --git a/web/crux/src/app/version/version.mapper.ts b/web/crux/src/app/version/version.mapper.ts index 41a11e750..2ad2c4712 100644 --- a/web/crux/src/app/version/version.mapper.ts +++ b/web/crux/src/app/version/version.mapper.ts @@ -50,6 +50,7 @@ export default class VersionMapper { mutable: versionIsMutable(version), deletable: versionIsDeletable(version), increasable: versionIsIncreasable(version), + autoCopyDeployments: version.autoCopyDeployments, images: version.images.map(it => this.imageMapper.toDto(it)), deployments: version.deployments.map(it => this.deployMapper.toDeploymentWithBasicNodeDto(it, nodeStatusLookup.get(it.nodeId)), diff --git a/web/crux/src/app/version/version.service.ts b/web/crux/src/app/version/version.service.ts index 10f86907a..f767d20c1 100644 --- a/web/crux/src/app/version/version.service.ts +++ b/web/crux/src/app/version/version.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common' import { Identity } from '@ory/kratos-client' import { DeploymentStatusEnum, Prisma } from '@prisma/client' import { VersionMessage } from 'src/domain/notification-templates' +import { increaseIncrementalVersion } from 'src/domain/version-increase' import DomainNotificationService from 'src/services/domain.notification.service' import PrismaService from 'src/services/prisma.service' import AgentService from '../agent/agent.service' @@ -170,6 +171,7 @@ export default class VersionService { name: req.name, changelog: req.changelog, type: req.type, + autoCopyDeployments: req.type === 'incremental' && req.autoCopyDeployments, default: !defaultVersion, createdBy: identity.id, }, @@ -281,6 +283,15 @@ export default class VersionService { } async updateVersion(versionId: string, req: UpdateVersionDto, identity: Identity): Promise { + const version = await this.prisma.version.findUniqueOrThrow({ + where: { + id: versionId, + }, + select: { + type: true, + }, + }) + await this.prisma.version.update({ where: { id: versionId, @@ -288,6 +299,7 @@ export default class VersionService { data: { name: req.name, changelog: req.changelog, + autoCopyDeployments: version.type === 'incremental' && req.autoCopyDeployments, updatedBy: identity.id, }, }) @@ -331,8 +343,9 @@ export default class VersionService { /** * Increasing an existing Version means copying the whole Version object - * with Images and connected ImageConfigs and with Deployments and connected - * Instances and InstanceConfigs. + * with Images and connected ImageConfigs, + * and - when autoCopyDeployments is true - with Deployments + * with their connected Instances and InstanceConfigs. * * @async * @method @@ -375,16 +388,23 @@ export default class VersionService { }, }) - const increasedVersion = await this.prisma.$transaction(async prisma => { - const version = await prisma.version.create({ - data: { - projectId: parentVersion.projectId, - name: request.name, - changelog: request.changelog, - default: false, - createdBy: identity.id, - type: parentVersion.type, + const increased = increaseIncrementalVersion(parentVersion, request.name, request.changelog) + + const newVersionData: Prisma.VersionCreateInput = { + ...increased, + createdBy: identity.id, + images: undefined, + deployments: undefined, + project: { + connect: { + id: parentVersion.projectId, }, + }, + } + + const newVersion = await this.prisma.$transaction(async prisma => { + const version = await prisma.version.create({ + data: newVersionData, include: { children: { select: { @@ -400,69 +420,75 @@ export default class VersionService { }, }) - const images: [string, string][] = await Promise.all( - // Iterate through the version images - parentVersion.images.map(async image => { + // Create images + const imageIdEntries: [string, string][] = await Promise.all( + increased.images.map(async image => { + const { originalId } = image + delete image.originalId + const createdImage = await prisma.image.create({ data: { - name: image.name, - tag: image.tag, - order: image.order, - registryId: image.registryId, + ...image, versionId: version.id, createdBy: identity.id, + config: { + create: this.imageMapper.dbContainerConfigToCreateImageStatement({ + ...image.config, + id: undefined, + imageId: undefined, + }), + }, }, }) - await prisma.containerConfig.create({ - data: { - ...this.imageMapper.dbContainerConfigToCreateImageStatement(image.config), - id: undefined, - imageId: createdImage.id, - }, - }) - - return [image.id, createdImage.id] + return [originalId, createdImage.id] }), ) - const imageMap = new Map(images) + // Create deployments + const imageIdMap = new Map(imageIdEntries) await Promise.all( - // Iterate through the deployments images - parentVersion.deployments.map(async deployment => { - const createdDeploy = await prisma.deployment.create({ + increased.deployments.map(async deployment => { + const newDeployment = await prisma.deployment.create({ data: { + ...deployment, createdBy: identity.id, - note: deployment.note, - prefix: deployment.prefix, - // Default status for deployments is preparing - status: DeploymentStatusEnum.preparing, - environment: deployment.environment ?? [], - nodeId: deployment.nodeId, versionId: version.id, + instances: undefined, }, }) await Promise.all( deployment.instances.map(async instance => { - const imageId = imageMap.get(instance.imageId) + const { originalImageId } = instance + delete instance.originalImageId - const createdInstance = await prisma.instance.create({ + const newImageId = imageIdMap.get(originalImageId) + + await prisma.instance.create({ data: { - deploymentId: createdDeploy.id, - imageId, + ...instance, + deployment: { + connect: { + id: newDeployment.id, + }, + }, + image: { + connect: { + id: newImageId, + }, + }, + config: !instance.config + ? undefined + : { + create: this.imageMapper.dbContainerConfigToCreateImageStatement({ + ...instance.config, + id: undefined, + instanceId: undefined, + }), + }, }, }) - - if (instance.config) { - await prisma.instanceContainerConfig.create({ - data: { - ...this.imageMapper.dbContainerConfigToCreateImageStatement(instance.config), - id: undefined, - instanceId: createdInstance.id, - }, - }) - } }), ) }), @@ -480,16 +506,16 @@ export default class VersionService { }) // End of Prisma transaction await this.notificationService.sendNotification({ - teamId: increasedVersion.project.teamId, + teamId: newVersion.project.teamId, messageType: 'version', message: { - subject: increasedVersion.project.name, - version: increasedVersion.name, + subject: newVersion.project.name, + version: newVersion.name, owner: identity, } as VersionMessage, }) - return this.mapper.toDto(increasedVersion) + return this.mapper.toDto(newVersion) } async onEditorJoined( diff --git a/web/crux/src/domain/version-increase.ts b/web/crux/src/domain/version-increase.ts new file mode 100644 index 000000000..370da66c1 --- /dev/null +++ b/web/crux/src/domain/version-increase.ts @@ -0,0 +1,149 @@ +import { + ContainerConfig, + Deployment, + DeploymentStatusEnum, + Image, + Instance, + InstanceContainerConfig, + Version, +} from '@prisma/client' + +type ImageWithConfig = Image & { + config: ContainerConfig +} + +type InstanceWithConfig = Instance & { + config: InstanceContainerConfig | null +} + +type DeploymentWithInstances = Deployment & { + instances: InstanceWithConfig[] +} + +export type IncreasableVersion = Version & { + images: ImageWithConfig[] + deployments: DeploymentWithInstances[] +} + +type CopiedImageWithConfig = Omit & { + originalId: string + config: Omit +} + +type CopiedInstanceWithConfig = Omit & { + originalImageId: string + config: Omit +} + +type CopiedDeploymentWithInstances = Omit & { + instances: CopiedInstanceWithConfig[] +} + +export type IncreasedVersion = Omit & { + images: CopiedImageWithConfig[] + deployments: CopiedDeploymentWithInstances[] +} + +const copyInstance = (instance: InstanceWithConfig): CopiedInstanceWithConfig => { + const newInstance: CopiedInstanceWithConfig = { + originalImageId: instance.imageId, + updatedAt: undefined, + config: null, + } + + if (instance.config) { + const config = { + ...instance.config, + } + + delete config.id + delete config.instanceId + + newInstance.config = config + } + + return newInstance +} + +const copyDeployment = (deployment: DeploymentWithInstances): CopiedDeploymentWithInstances => { + const newDeployment: CopiedDeploymentWithInstances = { + note: deployment.note, + prefix: deployment.prefix, + // default status for deployments is preparing + status: DeploymentStatusEnum.preparing, + environment: deployment.environment ?? [], + nodeId: deployment.nodeId, + protected: deployment.protected, + tries: 0, + instances: [], + updatedAt: undefined, + updatedBy: undefined, + } + + deployment.instances.forEach(instance => { + const newInstance = copyInstance(instance) + + newDeployment.instances.push(newInstance) + }) + + return newDeployment +} + +const copyImage = (image: ImageWithConfig): CopiedImageWithConfig => { + const config = { + ...image.config, + } + + delete config.id + delete config.imageId + + const newImage: CopiedImageWithConfig = { + originalId: image.id, + name: image.name, + tag: image.tag, + order: image.order, + registryId: image.registryId, + labels: image.labels, + config, + updatedAt: undefined, + updatedBy: undefined, + } + + return newImage +} + +export const increaseIncrementalVersion = ( + parentVersion: IncreasableVersion, + name: string, + changelog: string, +): IncreasedVersion => { + const newVersion: IncreasedVersion = { + name, + changelog, + default: false, + type: parentVersion.type, + autoCopyDeployments: parentVersion.autoCopyDeployments, + images: [], + deployments: [], + updatedAt: undefined, + updatedBy: undefined, + } + + // copy images + parentVersion.images.forEach(image => { + const newImage = copyImage(image) + + newVersion.images.push(newImage) + }) + + if (parentVersion.autoCopyDeployments) { + // copy deployments + parentVersion.deployments.forEach(deployment => { + const newDeployment = copyDeployment(deployment) + + newVersion.deployments.push(newDeployment) + }) + } + + return newVersion +} From d7208d90756c80dbe06a819beb74651203bc8163 Mon Sep 17 00:00:00 2001 From: Mate Vago Date: Fri, 31 May 2024 14:13:13 +0200 Subject: [PATCH 06/14] fix(crux-ui): image ordering (#979) --- .../version-reorder-images-section.tsx | 49 ++++++++++++------- .../components/shared/drag-and-drop-list.tsx | 9 ++-- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/web/crux-ui/src/components/projects/versions/version-reorder-images-section.tsx b/web/crux-ui/src/components/projects/versions/version-reorder-images-section.tsx index 6ba6b256b..32c103bc5 100644 --- a/web/crux-ui/src/components/projects/versions/version-reorder-images-section.tsx +++ b/web/crux-ui/src/components/projects/versions/version-reorder-images-section.tsx @@ -5,7 +5,36 @@ import clsx from 'clsx' import useTranslation from 'next-translate/useTranslation' import { useState } from 'react' -interface VersionReorderImagesSectionProps { +const sortImages = (images: VersionImage[], order: Map): VersionImage[] => { + const sorted = images.sort((one, other) => { + const oneOrder = order.get(one.id) + const otherOrder = order.get(other.id) + + if (oneOrder !== undefined) { + return otherOrder !== undefined ? oneOrder - otherOrder : -1 + } + + return otherOrder !== undefined ? 1 : 0 + }) + + return sorted +} + +const orderImagesByIndex = (images: VersionImage[], newOrder: VersionImage[]): VersionImage[] => { + const order: Map = new Map() + newOrder.forEach((it, index) => order.set(it.id, index)) + + return sortImages(images, order) +} + +const orderImages = (images: VersionImage[]): VersionImage[] => { + const order: Map = new Map() + images.forEach(it => order.set(it.id, it.order)) + + return sortImages(images, order) +} + +type VersionReorderImagesSectionProps = { images: VersionImage[] saveRef: React.MutableRefObject onSave: (images: VersionImage[]) => void @@ -16,24 +45,10 @@ const VersionReorderImagesSection = (props: VersionReorderImagesSectionProps) => const { t } = useTranslation('images') - const [items, setItems] = useState(images) + const [items, setItems] = useState(orderImages(images)) saveRef.current = () => { - const currentImages = images - - const order: Map = new Map() - items.forEach((it, index) => order.set(it.id, index)) - - const sorted = currentImages.sort((one, other) => { - const oneOrder = order.get(one.id) - const otherOrder = order.get(other.id) - - if (oneOrder !== undefined) { - return otherOrder !== undefined ? oneOrder - otherOrder : -1 - } - - return otherOrder !== undefined ? 1 : 0 - }) + const sorted = orderImagesByIndex(images, items) onSave(sorted) } diff --git a/web/crux-ui/src/components/shared/drag-and-drop-list.tsx b/web/crux-ui/src/components/shared/drag-and-drop-list.tsx index 79797c4e0..bcc4709ee 100644 --- a/web/crux-ui/src/components/shared/drag-and-drop-list.tsx +++ b/web/crux-ui/src/components/shared/drag-and-drop-list.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' interface DragAndDropListProps { items: T[] @@ -9,9 +9,8 @@ interface DragAndDropListProps { type DragState = { counter: number; current: HTMLDivElement } const DragAndDropList = (props: DragAndDropListProps) => { - const { itemBuilder, onItemsChange, items: propsItems } = props + const { itemBuilder, onItemsChange, items } = props - const [items, setItems] = useState(propsItems) const [dragging, setDragging] = useState() const dragState = useRef({ counter: 0, current: null }) @@ -74,7 +73,7 @@ const DragAndDropList = (props: DragAndDropListProps) => { newItems = [...before, dragging, ...after] } - setItems(newItems) + onItemsChange(newItems) setDragging(null) } @@ -83,8 +82,6 @@ const DragAndDropList = (props: DragAndDropListProps) => { event.preventDefault() } - useEffect(() => onItemsChange?.call(null, items), [items, onItemsChange]) - return (
    {items.map((it, index) => { From d5fd776f99ac0226aa472a902367fdd534937963 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 10:58:42 +0200 Subject: [PATCH 07/14] chore(deps): bump pug from 3.0.2 to 3.0.3 in /web/crux (#980) Bumps [pug](https://github.com/pugjs/pug) from 3.0.2 to 3.0.3. - [Release notes](https://github.com/pugjs/pug/releases) - [Commits](https://github.com/pugjs/pug/compare/pug@3.0.2...pug@3.0.3) --- updated-dependencies: - dependency-name: pug dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/crux/package-lock.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/crux/package-lock.json b/web/crux/package-lock.json index 83efcb1d6..5e8e6da64 100644 --- a/web/crux/package-lock.json +++ b/web/crux/package-lock.json @@ -10635,11 +10635,11 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/pug": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.2.tgz", - "integrity": "sha512-bp0I/hiK1D1vChHh6EfDxtndHji55XP/ZJKwsRqrz6lRia6ZC2OZbdAymlxdVFwd1L70ebrVJw4/eZ79skrIaw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", + "integrity": "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==", "dependencies": { - "pug-code-gen": "^3.0.2", + "pug-code-gen": "^3.0.3", "pug-filters": "^4.0.0", "pug-lexer": "^5.0.1", "pug-linker": "^4.0.0", @@ -10660,24 +10660,24 @@ } }, "node_modules/pug-code-gen": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.2.tgz", - "integrity": "sha512-nJMhW16MbiGRiyR4miDTQMRWDgKplnHyeLvioEJYbk1RsPI3FuA3saEP8uwnTb2nTJEKBU90NFVWJBk4OU5qyg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.3.tgz", + "integrity": "sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==", "dependencies": { "constantinople": "^4.0.1", "doctypes": "^1.1.0", "js-stringify": "^1.0.2", "pug-attrs": "^3.0.0", - "pug-error": "^2.0.0", - "pug-runtime": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", "void-elements": "^3.1.0", "with": "^7.0.0" } }, "node_modules/pug-error": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.0.0.tgz", - "integrity": "sha512-sjiUsi9M4RAGHktC1drQfCr5C5eriu24Lfbt4s+7SykztEOwVZtbFk1RRq0tzLxcMxMYTBR+zMQaG07J/btayQ==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==" }, "node_modules/pug-filters": { "version": "4.0.0", From 6c0f86dce52e16623b07850798c17786f44b5ea6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Jun 2024 08:49:56 +0200 Subject: [PATCH 08/14] chore(deps): bump @grpc/grpc-js from 1.9.0 to 1.9.15 in /web/crux (#982) Bumps [@grpc/grpc-js](https://github.com/grpc/grpc-node) from 1.9.0 to 1.9.15. - [Release notes](https://github.com/grpc/grpc-node/releases) - [Commits](https://github.com/grpc/grpc-node/compare/@grpc/grpc-js@1.9.0...@grpc/grpc-js@1.9.15) --- updated-dependencies: - dependency-name: "@grpc/grpc-js" dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/crux/package-lock.json | 10 +++++----- web/crux/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/crux/package-lock.json b/web/crux/package-lock.json index 5e8e6da64..555874d74 100644 --- a/web/crux/package-lock.json +++ b/web/crux/package-lock.json @@ -9,7 +9,7 @@ "version": "0.12.0", "license": "Apache-2.0", "dependencies": { - "@grpc/grpc-js": "^1.9.0", + "@grpc/grpc-js": "^1.9.15", "@grpc/proto-loader": "^0.7.8", "@nestjs-modules/mailer": "^1.9.1", "@nestjs/axios": "^3.0.0", @@ -1100,11 +1100,11 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.0.tgz", - "integrity": "sha512-H8+iZh+kCE6VR/Krj6W28Y/ZlxoZ1fOzsNt77nrdE3knkbSelW1Uus192xOFCxHyeszLj8i4APQkSIXjAoOxXg==", + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", "dependencies": { - "@grpc/proto-loader": "^0.7.0", + "@grpc/proto-loader": "^0.7.8", "@types/node": ">=12.12.47" }, "engines": { diff --git a/web/crux/package.json b/web/crux/package.json index 88d38e371..28b8e16bd 100644 --- a/web/crux/package.json +++ b/web/crux/package.json @@ -38,7 +38,7 @@ "schema": "prisma/schema.prisma" }, "dependencies": { - "@grpc/grpc-js": "^1.9.0", + "@grpc/grpc-js": "^1.9.15", "@grpc/proto-loader": "^0.7.8", "@nestjs-modules/mailer": "^1.9.1", "@nestjs/axios": "^3.0.0", From e30e600c9874a88d04a759bbc855843bb898fa5f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 08:15:38 +0200 Subject: [PATCH 09/14] chore(deps): bump braces from 3.0.2 to 3.0.3 in /web/crux (#983) Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3. - [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3) --- updated-dependencies: - dependency-name: braces dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/crux/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/crux/package-lock.json b/web/crux/package-lock.json index 555874d74..44dd238b0 100644 --- a/web/crux/package-lock.json +++ b/web/crux/package-lock.json @@ -3879,11 +3879,11 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -5993,9 +5993,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, From 98b574718717b3bc3804392cd35bf39ae4fb4049 Mon Sep 17 00:00:00 2001 From: Hojin Yang Date: Tue, 18 Jun 2024 23:53:25 +0900 Subject: [PATCH 10/14] doc: fix typo (#984) * fix: fix typo * Initial commit --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index a9360e5c3..1bd9c65a1 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ DOMAIN=example.com TIMEZONE=UTC # Required for Traefik's certification resolution # If there's an issue with the certificate, or when it expires, -# letsencrypt will send a notificatiom to this e-mail address +# letsencrypt will send a notification to this e-mail address ACME_EMAIL=user@example.com # NodeJS services can run in two modes: production and development # These are the two values this key can have From c82c394ebad7c58b81005abc9691237d5ee92da8 Mon Sep 17 00:00:00 2001 From: Mate Vago Date: Thu, 4 Jul 2024 12:34:39 +0200 Subject: [PATCH 11/14] feat(web): packages (#981) --- web/crux-ui/e2e/utils/common.ts | 1 + web/crux-ui/e2e/utils/config-bundle.ts | 6 +- web/crux-ui/e2e/utils/global.teardown.ts | 4 +- .../e2e/with-login/config-bundle.spec.ts | 2 +- .../e2e/without-login/register.spec.ts | 9 +- web/crux-ui/i18n.json | 3 + web/crux-ui/locales/en/common.json | 1 + web/crux-ui/locales/en/packages.json | 16 + web/crux-ui/public/chevron_down.svg | 8 +- web/crux-ui/public/package.svg | 1 + web/crux-ui/quality-assurance.ts | 1 + .../config-bundles/add-config-bundle-card.tsx | 2 +- .../config-bundles/config-bundle-card.tsx | 2 +- .../use-config-bundle-details-state.tsx | 6 +- .../deployments/add-deployment-card.tsx | 60 +- .../deployment-details-section.tsx | 6 +- web/crux-ui/src/components/main/sidebar.tsx | 7 +- .../src/components/nodes/node-audit-list.tsx | 3 +- .../components/nodes/node-deployment-list.tsx | 3 +- .../components/nodes/select-node-chips.tsx | 59 ++ .../packages/add-version-chains.tsx | 144 +++++ .../create-package-deployment-modal.tsx | 136 +++++ .../components/packages/edit-package-card.tsx | 169 ++++++ .../edit-package-environment-card.tsx | 136 +++++ .../src/components/packages/package-card.tsx | 51 ++ .../packages/package-details-card.tsx | 50 ++ .../packages/package-environment-card.tsx | 54 ++ .../package-environment-version-list.tsx | 158 +++++ .../packages/version-chain-list.tsx | 47 ++ .../projects/select-project-chips.tsx | 2 - .../projects/versions/version-card.tsx | 4 +- .../versions/version-deployments-section.tsx | 3 +- .../projects/versions/version-view-list.tsx | 2 +- web/crux-ui/src/elements/dyo-img-button.tsx | 1 + web/crux-ui/src/models/deployment.ts | 8 + web/crux-ui/src/models/index.ts | 1 + web/crux-ui/src/models/package.ts | 76 +++ web/crux-ui/src/models/version.ts | 6 + .../src/pages/[teamSlug]/audit-log.tsx | 2 +- .../src/pages/[teamSlug]/config-bundles.tsx | 8 +- .../config-bundles/[configBundleId].tsx | 8 +- .../src/pages/[teamSlug]/deployments.tsx | 1 + web/crux-ui/src/pages/[teamSlug]/packages.tsx | 106 ++++ .../pages/[teamSlug]/packages/[packageId].tsx | 152 +++++ .../environments/[environmentId].tsx | 121 ++++ web/crux-ui/src/routes.ts | 66 ++- web/crux-ui/src/validations/deployment.ts | 4 +- web/crux-ui/src/validations/index.ts | 1 + web/crux-ui/src/validations/package.ts | 16 + web/crux/package-lock.json | 21 +- .../20240702110318_packages/migration.sql | 108 ++++ web/crux/prisma/schema.prisma | 71 ++- web/crux/src/app.module.ts | 2 + .../app/audit.logger/audit.logger.service.ts | 2 +- web/crux/src/app/deploy/deploy.dto.ts | 6 +- web/crux/src/app/deploy/deploy.mapper.ts | 12 + web/crux/src/app/deploy/deploy.service.ts | 2 +- .../deploy.create-deploy-token.interceptor.ts | 2 +- .../app/node/pipes/node.get-script.pipe.ts | 2 +- .../guards/package.node-access.guard.ts | 26 + .../guards/package.team-access.guard.ts | 36 ++ .../guards/package.version-access.guard.ts | 36 ++ .../package.version-chain-access.guard.ts | 30 + .../package.create-deployment.interceptor.ts | 44 ++ web/crux/src/app/package/package.dto.ts | 115 ++++ .../app/package/package.http.controller.ts | 300 ++++++++++ web/crux/src/app/package/package.mapper.ts | 133 +++++ web/crux/src/app/package/package.module.ts | 20 + web/crux/src/app/package/package.service.ts | 548 ++++++++++++++++++ .../guards/pipeline.auth.validation.guard.ts | 2 +- web/crux/src/app/pipeline/pipeline.service.ts | 4 +- .../app/project/project.http.controller.ts | 4 +- web/crux/src/app/project/project.service.ts | 2 +- web/crux/src/app/registry/registry.service.ts | 2 +- .../version/version-chains.http.controller.ts | 41 ++ web/crux/src/app/version/version.dto.ts | 13 +- web/crux/src/app/version/version.mapper.ts | 17 +- web/crux/src/app/version/version.module.ts | 3 +- web/crux/src/app/version/version.service.ts | 73 ++- web/crux/src/domain/version-chain.ts | 32 + web/crux/src/domain/version-increase.ts | 6 +- web/crux/src/domain/version.ts | 8 +- .../interceptors/prisma-error-interceptor.ts | 4 + 83 files changed, 3341 insertions(+), 119 deletions(-) create mode 100644 web/crux-ui/locales/en/packages.json create mode 100644 web/crux-ui/public/package.svg create mode 100644 web/crux-ui/src/components/nodes/select-node-chips.tsx create mode 100644 web/crux-ui/src/components/packages/add-version-chains.tsx create mode 100644 web/crux-ui/src/components/packages/create-package-deployment-modal.tsx create mode 100644 web/crux-ui/src/components/packages/edit-package-card.tsx create mode 100644 web/crux-ui/src/components/packages/edit-package-environment-card.tsx create mode 100644 web/crux-ui/src/components/packages/package-card.tsx create mode 100644 web/crux-ui/src/components/packages/package-details-card.tsx create mode 100644 web/crux-ui/src/components/packages/package-environment-card.tsx create mode 100644 web/crux-ui/src/components/packages/package-environment-version-list.tsx create mode 100644 web/crux-ui/src/components/packages/version-chain-list.tsx create mode 100644 web/crux-ui/src/models/package.ts create mode 100644 web/crux-ui/src/pages/[teamSlug]/packages.tsx create mode 100644 web/crux-ui/src/pages/[teamSlug]/packages/[packageId].tsx create mode 100644 web/crux-ui/src/pages/[teamSlug]/packages/[packageId]/environments/[environmentId].tsx create mode 100644 web/crux-ui/src/validations/package.ts create mode 100644 web/crux/prisma/migrations/20240702110318_packages/migration.sql create mode 100644 web/crux/src/app/package/guards/package.node-access.guard.ts create mode 100644 web/crux/src/app/package/guards/package.team-access.guard.ts create mode 100644 web/crux/src/app/package/guards/package.version-access.guard.ts create mode 100644 web/crux/src/app/package/guards/package.version-chain-access.guard.ts create mode 100644 web/crux/src/app/package/interceptors/package.create-deployment.interceptor.ts create mode 100644 web/crux/src/app/package/package.dto.ts create mode 100644 web/crux/src/app/package/package.http.controller.ts create mode 100644 web/crux/src/app/package/package.mapper.ts create mode 100644 web/crux/src/app/package/package.module.ts create mode 100644 web/crux/src/app/package/package.service.ts create mode 100644 web/crux/src/app/version/version-chains.http.controller.ts create mode 100644 web/crux/src/domain/version-chain.ts diff --git a/web/crux-ui/e2e/utils/common.ts b/web/crux-ui/e2e/utils/common.ts index a66f33122..a726d564f 100644 --- a/web/crux-ui/e2e/utils/common.ts +++ b/web/crux-ui/e2e/utils/common.ts @@ -19,6 +19,7 @@ export const USER_TEAM_SLUG = 'jot' export const USER_FIRSTNAME = 'John' export const USER_LASTNAME = 'Doe' export const USER_FULLNAME = `${USER_FIRSTNAME} ${USER_LASTNAME}` +export const REGISTERED_USER_EMAIL = `r.${USER_EMAIL}` export const TEAM_ROUTES = new TeamRoutes(USER_TEAM_SLUG) diff --git a/web/crux-ui/e2e/utils/config-bundle.ts b/web/crux-ui/e2e/utils/config-bundle.ts index 96e657ba1..203d482e2 100644 --- a/web/crux-ui/e2e/utils/config-bundle.ts +++ b/web/crux-ui/e2e/utils/config-bundle.ts @@ -11,7 +11,7 @@ const matchPatchEnvironment = (expected: Record) => (message: Pa ) export const createConfigBundle = async (page: Page, name: string, data: Record): Promise => { - await page.goto(TEAM_ROUTES.configBundles.list()) + await page.goto(TEAM_ROUTES.configBundle.list()) await page.waitForSelector('h2:text-is("Config bundles")') await page.locator('button:has-text("Add")').click() @@ -20,13 +20,13 @@ export const createConfigBundle = async (page: Page, name: string, data: Record< const sock = waitSocketRef(page) await page.locator('text=Save').click() - await page.waitForURL(`${TEAM_ROUTES.configBundles.list()}/**`) + await page.waitForURL(`${TEAM_ROUTES.configBundle.list()}/**`) await page.waitForSelector(`h4:text-is("View ${name}")`) const configBundleId = page.url().split('/').pop() const ws = await sock - const wsRoute = TEAM_ROUTES.configBundles.detailsSocket(configBundleId) + const wsRoute = TEAM_ROUTES.configBundle.detailsSocket(configBundleId) await page.locator('button:has-text("Edit")').click() diff --git a/web/crux-ui/e2e/utils/global.teardown.ts b/web/crux-ui/e2e/utils/global.teardown.ts index 61a27487f..301fed31a 100644 --- a/web/crux-ui/e2e/utils/global.teardown.ts +++ b/web/crux-ui/e2e/utils/global.teardown.ts @@ -5,6 +5,8 @@ import { API_USERS_ME, teamApiUrl } from '@app/routes' import { isDyoError } from '@app/utils' import { BASE_URL } from '../../playwright.config' import { + REGISTERED_USER_EMAIL, + USER_EMAIL, cruxUrlFromEnv, deleteUserByEmail, execAsync, @@ -14,7 +16,6 @@ import { kratosFromConfig, kratosFrontendFromConfig, logCmdOutput, - USER_EMAIL, } from './common' const logInfo = (...messages: string[]) => console.info('[E2E]: Teardown -', ...messages) @@ -85,6 +86,7 @@ export const globalTeardown = async () => { logInfo('fetch', 'delete user') await deleteUserByEmail(kratos, USER_EMAIL) + await deleteUserByEmail(kratos, REGISTERED_USER_EMAIL) const settings = getExecOptions() diff --git a/web/crux-ui/e2e/with-login/config-bundle.spec.ts b/web/crux-ui/e2e/with-login/config-bundle.spec.ts index c4ab3fd60..4c89a69c5 100644 --- a/web/crux-ui/e2e/with-login/config-bundle.spec.ts +++ b/web/crux-ui/e2e/with-login/config-bundle.spec.ts @@ -12,7 +12,7 @@ test('Creating a config bundle', async ({ page }) => { [ENV_KEY]: ENV_VALUE, }) - await page.goto(TEAM_ROUTES.configBundles.details(configBundleId)) + await page.goto(TEAM_ROUTES.configBundle.details(configBundleId)) const keyInput = page.locator('input[placeholder="Key"]').first() await expect(keyInput).toBeDisabled() diff --git a/web/crux-ui/e2e/without-login/register.spec.ts b/web/crux-ui/e2e/without-login/register.spec.ts index a5af25669..d70f04295 100644 --- a/web/crux-ui/e2e/without-login/register.spec.ts +++ b/web/crux-ui/e2e/without-login/register.spec.ts @@ -1,9 +1,14 @@ import { ROUTE_LOGIN, ROUTE_REGISTER, verificationUrl } from '@app/routes' import { expect } from '@playwright/test' +import { + REGISTERED_USER_EMAIL, + USER_PASSWORD, + deleteUserByEmail, + kratosFromBaseURL, + screenshotPath, +} from '../utils/common' import { test } from '../utils/test.fixture' -import { deleteUserByEmail, kratosFromBaseURL, screenshotPath, USER_EMAIL, USER_PASSWORD } from '../utils/common' -const REGISTERED_USER_EMAIL = `r.${USER_EMAIL}` const REGISTERED_USER_PASSWORD = `r.${USER_PASSWORD}` const REGISTERED_USER_FIRST_NAME = 'r.John' diff --git a/web/crux-ui/i18n.json b/web/crux-ui/i18n.json index 495e61677..a258c3f15 100644 --- a/web/crux-ui/i18n.json +++ b/web/crux-ui/i18n.json @@ -47,6 +47,9 @@ "/[teamSlug]/storages/[storageId]": ["storages"], "/[teamSlug]/config-bundles": ["config-bundles"], "/[teamSlug]/config-bundles/[configBundleId]": ["config-bundles"], + "/[teamSlug]/packages": ["packages"], + "/[teamSlug]/packages/[packageId]": ["packages"], + "/[teamSlug]/packages/[packageId]/environments/[environmentId]": ["packages", "deployments"], "/[teamSlug]/pipelines": ["pipelines"], "/[teamSlug]/pipelines/[pipelineId]": ["pipelines"] } diff --git a/web/crux-ui/locales/en/common.json b/web/crux-ui/locales/en/common.json index 7d6ebcc79..d609c92d2 100644 --- a/web/crux-ui/locales/en/common.json +++ b/web/crux-ui/locales/en/common.json @@ -95,6 +95,7 @@ "pipelines": "Pipelines", "storages": "Storages", "configBundles": "Config bundles", + "packages": "Packages", "role": { "owner": "Owner", diff --git a/web/crux-ui/locales/en/packages.json b/web/crux-ui/locales/en/packages.json new file mode 100644 index 000000000..c9020dbce --- /dev/null +++ b/web/crux-ui/locales/en/packages.json @@ -0,0 +1,16 @@ +{ + "new": "New package", + "packageName": "Package - {{name}}", + "newEnvironment": "New environment", + "tips": "Packages are a composition of related project versions and nodes. By creating a package you can track and update your applications across various nodes.", + "noItems": "You haven't created a package yet. Make one by clicking 'Add' on the top right.", + "environments": "Environments", + "addEnvironment": "Add environment", + "versionChains": "Version chains", + "addVersionChains": "Add version chains", + "earliest": "Earliest", + "latest": "Latest", + "noVersionChains": "You haven't selected any version chain yet.", + "createDeployment": "Create deployment", + "deployed": "Deployed" +} diff --git a/web/crux-ui/public/chevron_down.svg b/web/crux-ui/public/chevron_down.svg index b7f8bcea1..20ee67ced 100644 --- a/web/crux-ui/public/chevron_down.svg +++ b/web/crux-ui/public/chevron_down.svg @@ -1,9 +1,3 @@ - - - - - - - + diff --git a/web/crux-ui/public/package.svg b/web/crux-ui/public/package.svg new file mode 100644 index 000000000..db898e4fb --- /dev/null +++ b/web/crux-ui/public/package.svg @@ -0,0 +1 @@ + diff --git a/web/crux-ui/quality-assurance.ts b/web/crux-ui/quality-assurance.ts index 0cfae0422..c713bf494 100644 --- a/web/crux-ui/quality-assurance.ts +++ b/web/crux-ui/quality-assurance.ts @@ -118,6 +118,7 @@ export const QA_MODAL_LABEL_NODE_AUDIT_DETAILS = 'nodeAuditDetails' export const QA_MODAL_LABEL_DEPLOYMENT_NOTE = 'deploymentNote' export const QA_MODAL_LABEL_IMAGE_TAGS = 'image-tags' export const QA_MODAL_LABEL_AUDIT_LOG_DETAILS = 'auditLogDetails' +export const QA_MODAL_LABEL_CREATE_PACKAGE_DEPLOYMENT = 'createPackageDeployment' export type QualityAssurance = { disabled: boolean diff --git a/web/crux-ui/src/components/config-bundles/add-config-bundle-card.tsx b/web/crux-ui/src/components/config-bundles/add-config-bundle-card.tsx index 95bdac39a..d5059204f 100644 --- a/web/crux-ui/src/components/config-bundles/add-config-bundle-card.tsx +++ b/web/crux-ui/src/components/config-bundles/add-config-bundle-card.tsx @@ -41,7 +41,7 @@ const AddConfigBundleCard = (props: AddConfigBundleCardProps) => { ...values, } - const res = await sendForm('POST', routes.configBundles.api.list(), body) + const res = await sendForm('POST', routes.configBundle.api.list(), body) if (res.ok) { let result: ConfigBundle diff --git a/web/crux-ui/src/components/config-bundles/config-bundle-card.tsx b/web/crux-ui/src/components/config-bundles/config-bundle-card.tsx index e18d0aa9d..a3abdc670 100644 --- a/web/crux-ui/src/components/config-bundles/config-bundle-card.tsx +++ b/web/crux-ui/src/components/config-bundles/config-bundle-card.tsx @@ -17,7 +17,7 @@ const ConfigBundleCard = (props: ConfigBundleCardProps) => { const { t } = useTranslation('config-bundles') const routes = useTeamRoutes() - const titleHref = routes.configBundles.details(configBundle.id) + const titleHref = routes.configBundle.details(configBundle.id) return ( diff --git a/web/crux-ui/src/components/config-bundles/use-config-bundle-details-state.tsx b/web/crux-ui/src/components/config-bundles/use-config-bundle-details-state.tsx index a48e93919..1b9cc8c9b 100644 --- a/web/crux-ui/src/components/config-bundles/use-config-bundle-details-state.tsx +++ b/web/crux-ui/src/components/config-bundles/use-config-bundle-details-state.tsx @@ -62,7 +62,7 @@ export const useConfigBundleDetailsState = ( const [fieldErrors, setFieldErrors] = useState([]) const [topBarContent, setTopBarContent] = useState(null) - const sock = useWebSocket(routes.configBundles.detailsSocket(configBundle.id), { + const sock = useWebSocket(routes.configBundle.detailsSocket(configBundle.id), { onOpen: () => setSaveState('connected'), onClose: () => setSaveState('disconnected'), onSend: message => { @@ -89,12 +89,12 @@ export const useConfigBundleDetailsState = ( }) const onDelete = async (): Promise => { - const res = await fetch(routes.configBundles.api.details(configBundle.id), { + const res = await fetch(routes.configBundle.api.details(configBundle.id), { method: 'DELETE', }) if (res.ok) { - await router.replace(routes.configBundles.list()) + await router.replace(routes.configBundle.list()) } else if (res.status === 412) { toastWarning(t('inUse')) } else { diff --git a/web/crux-ui/src/components/deployments/add-deployment-card.tsx b/web/crux-ui/src/components/deployments/add-deployment-card.tsx index e36b461ce..f0bd86df1 100644 --- a/web/crux-ui/src/components/deployments/add-deployment-card.tsx +++ b/web/crux-ui/src/components/deployments/add-deployment-card.tsx @@ -26,6 +26,7 @@ import { createFullDeploymentSchema } from '@app/validations' import useTranslation from 'next-translate/useTranslation' import { useEffect } from 'react' import useSWR from 'swr' +import SelectNodeChips from '../nodes/select-node-chips' import SelectProjectChips from '../projects/select-project-chips' interface AddDeploymentCardProps { @@ -40,8 +41,6 @@ const AddDeploymentCard = (props: AddDeploymentCardProps) => { const { t } = useTranslation('deployments') const routes = useTeamRoutes() - const { data: nodes, error: fetchNodesError } = useSWR(routes.node.api.list(), fetcher) - const handleApiError = apiErrorHandler((stringId: string, status: number, dto: DyoApiError) => { if (deploymentHasError(dto)) { onAdd(dto.value) @@ -60,7 +59,7 @@ const AddDeploymentCard = (props: AddDeploymentCardProps) => { const formik = useDyoFormik({ initialValues: { - nodeId: null as string, + node: null as DyoNode, note: '', prefix: '', protected: false, @@ -71,7 +70,10 @@ const AddDeploymentCard = (props: AddDeploymentCardProps) => { t, onSubmit: async (values, { setFieldError }) => { const body: CreateDeployment = { - ...values, + nodeId: values.node?.id, + prefix: values.prefix, + protected: values.protected, + versionId: values.versionId, } const res = await sendForm('POST', routes.deployment.api.list(), body) @@ -93,14 +95,7 @@ const AddDeploymentCard = (props: AddDeploymentCardProps) => { fetcher, ) - const { setFieldValue: formikSetFieldValue, setFieldError: formikSetFieldError, values: formikValues } = formik - - useEffect(() => { - if (nodes?.length === 1 && !formikValues.nodeId) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - formikSetFieldValue('nodeId', nodes[0].id) - } - }, [nodes, formikValues.nodeId, formikSetFieldValue]) + const { setFieldValue: formikSetFieldValue, setFieldError: formikSetFieldError } = formik useEffect(() => { if (versions?.length === 1) { @@ -122,6 +117,13 @@ const AddDeploymentCard = (props: AddDeploymentCardProps) => { } } + const onNodesFetched = (nodes: DyoNode[] | null) => { + if (nodes?.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + formikSetFieldValue('project', nodes[0]) + } + } + const currentProject = formik.values.project return ( @@ -142,28 +144,16 @@ const AddDeploymentCard = (props: AddDeploymentCardProps) => {
    {t('common:nodes')} - {fetchNodesError ? ( - - {t('errors:fetchFailed', { - type: t('common:nodes'), - })} - - ) : !nodes ? ( - {t('common:loading')} - ) : nodes.length === 0 ? ( - - ) : ( - <> - it.name} - selection={nodes.find(it => it.id === formik.values.nodeId)} - onSelectionChange={it => formik.setFieldValue('nodeId', it.id)} - /> - {formik.errors.nodeId && } - - )} + + { + await formik.setFieldValue('node', it) + }} + onNodesFetched={onNodesFetched} + selection={formik.values.node} + errorMessage={formik.errors.node as string} + /> {t('common:projects')} @@ -187,7 +177,7 @@ const AddDeploymentCard = (props: AddDeploymentCardProps) => { type: t('common:versions'), })} - ) : !versions && formik.values.project ? ( + ) : !versions && currentProject ? ( {t('common:loading')} ) : versions.length === 0 ? ( diff --git a/web/crux-ui/src/components/deployments/deployment-details-section.tsx b/web/crux-ui/src/components/deployments/deployment-details-section.tsx index 35c30594e..5b2fa4111 100644 --- a/web/crux-ui/src/components/deployments/deployment-details-section.tsx +++ b/web/crux-ui/src/components/deployments/deployment-details-section.tsx @@ -31,12 +31,12 @@ const DeploymentDetailsSection = (props: DeploymentDetailsSectionProps) => { const editorState = useItemEditorState(editor, sock, ITEM_ID) - const { data: configBundleOptions } = useSWR(teamRoutes.configBundles.api.options(), fetcher) + const { data: configBundleOptions } = useSWR(teamRoutes.configBundle.api.options(), fetcher) const configBundlesHref = deployment.configBundleIds?.length === 1 - ? teamRoutes.configBundles.details(deployment.configBundleIds[0]) - : teamRoutes.configBundles.list() + ? teamRoutes.configBundle.details(deployment.configBundleIds[0]) + : teamRoutes.configBundle.list() return ( diff --git a/web/crux-ui/src/components/main/sidebar.tsx b/web/crux-ui/src/components/main/sidebar.tsx index b0bcef5ee..ff8e014b7 100644 --- a/web/crux-ui/src/components/main/sidebar.tsx +++ b/web/crux-ui/src/components/main/sidebar.tsx @@ -42,7 +42,12 @@ export const sidebarSectionsOf = (routes: TeamRoutes): MenuSection[] => [ { icon: '/config_bundle.svg', text: 'configBundles', - link: routes.configBundles.list(), + link: routes.configBundle.list(), + }, + { + icon: '/package.svg', + text: 'packages', + link: routes.package.list(), }, ], }, diff --git a/web/crux-ui/src/components/nodes/node-audit-list.tsx b/web/crux-ui/src/components/nodes/node-audit-list.tsx index f617cbe8f..6a8ec799b 100644 --- a/web/crux-ui/src/components/nodes/node-audit-list.tsx +++ b/web/crux-ui/src/components/nodes/node-audit-list.tsx @@ -170,7 +170,8 @@ const NodeAuditList = (props: NodeAuditListProps) => { /> - {!showInfo ? null : ( + + {showInfo && ( { {t('noItems')} )} - {!showInfo ? null : ( + + {showInfo && ( Promise + errorMessage?: string | null + onNodesFetched?: (nodes: DyoNode[] | null) => void +} + +const SelectNodeChips = (props: SelectNodeChipsProps) => { + const { className, name, selection, onSelectionChange, errorMessage, onNodesFetched } = props + + const { t } = useTranslation('common') + const routes = useTeamRoutes() + + const { data: nodes, error: fetchError } = useSWR(routes.node.api.list(), fetcher) + + useEffect(() => { + onNodesFetched?.call(null, nodes) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodes]) + + return fetchError ? ( + + {t('errors:fetchFailed', { + type: t('common:nodes'), + })} + + ) : !nodes ? ( + {t('common:loading')} + ) : nodes.length === 0 ? ( + + ) : ( + <> + it.name} + selection={selection} + onSelectionChange={onSelectionChange} + /> + {errorMessage && } + + ) +} + +export default SelectNodeChips diff --git a/web/crux-ui/src/components/packages/add-version-chains.tsx b/web/crux-ui/src/components/packages/add-version-chains.tsx new file mode 100644 index 000000000..222f589f2 --- /dev/null +++ b/web/crux-ui/src/components/packages/add-version-chains.tsx @@ -0,0 +1,144 @@ +import DyoButton from '@app/elements/dyo-button' +import DyoCheckbox from '@app/elements/dyo-checkbox' +import { DyoHeading } from '@app/elements/dyo-heading' +import { DyoLabel } from '@app/elements/dyo-label' +import { DyoList } from '@app/elements/dyo-list' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { PackageVersionChain, Project, VersionChain } from '@app/models' +import { fetcher } from '@app/utils' +import clsx from 'clsx' +import useTranslation from 'next-translate/useTranslation' +import { useState } from 'react' +import useSWR from 'swr' +import SelectProjectChips from '../projects/select-project-chips' + +type AddVersionChainsProps = { + className?: string + currentChains: PackageVersionChain[] + onAdd: (chains: PackageVersionChain[]) => Promise + onDiscard: VoidFunction +} + +const AddVersionChains = (props: AddVersionChainsProps) => { + const { className, currentChains, onAdd: propsOnAdd, onDiscard } = props + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + + const [project, setProject] = useState(null) + const [selected, setSelected] = useState([]) + + const { data: fetchedChains, error: fetchVersionChainsError } = useSWR( + project ? routes.project.api.versionChains(project.id) : null, + fetcher, + ) + + const ignoredChainIds = currentChains.map(it => it.id) + const selectedIds = selected.map(it => it.id) + + const versionChains: PackageVersionChain[] = [ + ...selected, + ...(fetchedChains ?? []) + .filter(it => !ignoredChainIds.includes(it.id) && !selectedIds.includes(it.id)) + .map(it => ({ + ...it, + project, + })), + ] + + const onProjectsFetched = (projects: Project[] | null) => { + if (!projects || project || projects.length < 1) { + return + } + + setProject(projects[0]) + } + + const onProjectSelected = async (proj: Project) => { + setProject(proj) + } + + const onAdd = async () => await propsOnAdd(selected) + + const onChainCheckedChange = (selectable: PackageVersionChain, checked: boolean) => { + if (checked) { + if (selected.find(it => it.id === selectable.id)) { + return + } + + const newSelected = [...selected, selectable] + setSelected(newSelected) + return + } + + const newSelected = selected.filter(it => it.id !== selectable.id) + setSelected(newSelected) + } + + const itemTemplate = (selectable: PackageVersionChain, index: number) => { + const checked = !!selected.find(it => it.id === selectable.id) + const onCheckedChange = isChecked => onChainCheckedChange(selectable, isChecked) + + /* eslint-disable react/jsx-key */ + return [ +
    + + + onCheckedChange(!checked)}>{selectable.project.name} +
    , + onCheckedChange(!checked)}>{selectable.earliest.name}, + onCheckedChange(!checked)}>{selectable.latest.name}, + ] + /* eslint-enable react/jsx-key */ + } + + return ( +
    +
    + + {t('addVersionChains')} + + + + {t('common:discard')} + + + + {t('common:add')} + +
    + + {t('common:projects')} + + + + {fetchVersionChainsError ? ( + + {t('errors:fetchFailed', { + type: t('versionChains'), + })} + + ) : ( + t(it))} + itemBuilder={itemTemplate} + /> + )} +
    + ) +} + +export default AddVersionChains diff --git a/web/crux-ui/src/components/packages/create-package-deployment-modal.tsx b/web/crux-ui/src/components/packages/create-package-deployment-modal.tsx new file mode 100644 index 000000000..f523d34e7 --- /dev/null +++ b/web/crux-ui/src/components/packages/create-package-deployment-modal.tsx @@ -0,0 +1,136 @@ +import DyoButton from '@app/elements/dyo-button' +import { DyoHeading } from '@app/elements/dyo-heading' +import { DyoInput } from '@app/elements/dyo-input' +import DyoModal from '@app/elements/dyo-modal' +import DyoRadioButton from '@app/elements/dyo-radio-button' +import { defaultApiErrorHandler } from '@app/errors' +import { TextFilter, textFilterFor, useFilters } from '@app/hooks/use-filters' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { + CreatePackageDeployment, + Deployment, + DyoApiError, + PackageVersion, + PackageVersionChainDetails, +} from '@app/models' +import { sendForm, toastWarning } from '@app/utils' +import useTranslation from 'next-translate/useTranslation' +import { useRouter } from 'next/router' +import { QA_MODAL_LABEL_CREATE_PACKAGE_DEPLOYMENT } from 'quality-assurance' +import { useState } from 'react' + +type CreatePackageDeploymentModalProps = { + className?: string + packageId: string + environmentId: string + chain: PackageVersionChainDetails + onDeploymentCreated: (deployment: Deployment) => void + onClose: VoidFunction +} + +const CreatePackageDeploymentModal = (props: CreatePackageDeploymentModalProps) => { + const { className, packageId, environmentId, chain, onDeploymentCreated, onClose } = props + + const { t } = useTranslation('packages') + + const router = useRouter() + const routes = useTeamRoutes() + + const [selected, setSelected] = useState(chain.versions.at(-1) ?? null) + + const filters = useFilters({ + filters: [textFilterFor(it => [it.name])], + data: chain?.versions ?? [], + initialFilter: { + text: '', + }, + }) + + const handleApiError = defaultApiErrorHandler(t) + + const onCreate = async () => { + if (!selected) { + return + } + + const body: CreatePackageDeployment = { + versionId: selected.id, + } + + const res = await sendForm('POST', routes.package.api.environmentDeployments(packageId, environmentId), body) + + if (!res.ok) { + if (res.status === 409) { + // A deployment with the prefix on that node is already exists + + const error = (await res.json()) as DyoApiError + + toastWarning(t('deployments:alreadyHaveDeployment')) + await router.push(routes.deployment.details(error.value)) + return + } + + await handleApiError(res) + return + } + + const deployment = (await res.json()) as Deployment + onDeploymentCreated(deployment) + + onClose() + } + + return ( + + + {t('common:create')} + + + + {t('common:cancel')} + + + } + > +
    + + filters.setFilter({ + text: e.target.value, + }) + } + /> + + + {t('common:versions')} + + +
    + {filters.filtered.map((it, index) => ( + { + setSelected(it) + }} + qaLabel={`version-${index}`} + /> + ))} +
    +
    +
    + ) +} + +export default CreatePackageDeploymentModal diff --git a/web/crux-ui/src/components/packages/edit-package-card.tsx b/web/crux-ui/src/components/packages/edit-package-card.tsx new file mode 100644 index 000000000..28f91d5a8 --- /dev/null +++ b/web/crux-ui/src/components/packages/edit-package-card.tsx @@ -0,0 +1,169 @@ +import DyoButton from '@app/elements/dyo-button' +import { DyoCard } from '@app/elements/dyo-card' +import DyoForm from '@app/elements/dyo-form' +import { DyoHeading } from '@app/elements/dyo-heading' +import DyoIconPicker from '@app/elements/dyo-icon-picker' +import { DyoInput } from '@app/elements/dyo-input' +import { DyoLabel } from '@app/elements/dyo-label' +import DyoTextArea from '@app/elements/dyo-text-area' +import { defaultApiErrorHandler } from '@app/errors' +import useDyoFormik from '@app/hooks/use-dyo-formik' +import { SubmitHook } from '@app/hooks/use-submit' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { CreatePackage, PackageDetails, PackageVersionChain, UpdatePackage } from '@app/models' +import { sendForm } from '@app/utils' +import useTranslation from 'next-translate/useTranslation' +import { useState } from 'react' +import AddVersionChains from './add-version-chains' +import VersionChainList from './version-chain-list' +import { packageSchema } from '@app/validations' + +type EditPackageCardProps = { + className?: string + package?: PackageDetails + onPackageEdited: (registry: PackageDetails) => void + submit: SubmitHook +} + +const EditPackageCard = (props: EditPackageCardProps) => { + const { className, package: propsPackage, onPackageEdited, submit } = props + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + + const [pack, setPackage] = useState( + propsPackage ?? { + id: null, + name: '', + description: '', + icon: null, + environments: [], + versionChains: [], + }, + ) + + const [addingVersionChains, setAddingVersionChains] = useState(false) + + const editing = !!pack.id + + const handleApiError = defaultApiErrorHandler(t) + + const formik = useDyoFormik({ + submit, + initialValues: pack, + validationSchema: packageSchema, + t, + onSubmit: async values => { + const body: CreatePackage | UpdatePackage = { + name: values.name, + description: values.description, + icon: values.icon, + chainIds: values.versionChains.map(it => it.id), + } + + const res = await (!editing + ? sendForm('POST', routes.package.api.list(), body) + : sendForm('PUT', routes.package.api.details(pack.id), body)) + + if (!res.ok) { + await handleApiError(res) + return + } + + let result = values + if (res.status !== 204) { + result = (await res.json()) as PackageDetails + } + + setPackage(result) + onPackageEdited(result) + }, + }) + + const onVersionChainsAdded = async (chains: PackageVersionChain[]) => { + await formik.setFieldValue('versionChains', [...formik.values.versionChains, ...chains]) + + setAddingVersionChains(false) + } + + const onRemoveVersionChain = async (chain: PackageVersionChain) => { + await formik.setFieldValue( + 'versionChains', + formik.values.versionChains.filter(it => it.id !== chain.id), + ) + } + + return ( + + + {editing ? t('common:editName', { name: pack.name }) : t('new')} + + + {t('tips')} + + +
    +
    + +
    + +
    + {t('common:icon')} + + +
    + + +
    + + {addingVersionChains ? ( + setAddingVersionChains(false)} + /> + ) : ( +
    + {t('versionChains')} + + setAddingVersionChains(true)}> + {t('common:add')} + + + {formik.values.versionChains.length > 0 ? ( + + ) : ( +

    {t('noVersionChains')}

    + )} +
    + )} + + +
    +
    + ) +} + +export default EditPackageCard diff --git a/web/crux-ui/src/components/packages/edit-package-environment-card.tsx b/web/crux-ui/src/components/packages/edit-package-environment-card.tsx new file mode 100644 index 000000000..0519c9e76 --- /dev/null +++ b/web/crux-ui/src/components/packages/edit-package-environment-card.tsx @@ -0,0 +1,136 @@ +import DyoButton from '@app/elements/dyo-button' +import { DyoCard } from '@app/elements/dyo-card' +import DyoForm from '@app/elements/dyo-form' +import { DyoHeading } from '@app/elements/dyo-heading' +import { DyoInput } from '@app/elements/dyo-input' +import { DyoLabel } from '@app/elements/dyo-label' +import { defaultApiErrorHandler } from '@app/errors' +import useDyoFormik from '@app/hooks/use-dyo-formik' +import { SubmitHook } from '@app/hooks/use-submit' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { CreatePackageEnvironment, DyoNode, PackageEnvironment, UpdatePackageEnvironment } from '@app/models' +import { sendForm } from '@app/utils' +import { packageEnvironmentSchema } from '@app/validations' +import useTranslation from 'next-translate/useTranslation' +import { useState } from 'react' +import SelectNodeChips from '../nodes/select-node-chips' + +type EditPackageEnvironmentCardProps = { + className?: string + packageId: string + environment?: PackageEnvironment + onEnvironmentEdited: (env: PackageEnvironment) => void + submit?: SubmitHook +} + +const EditPackageEnvironmentCard = (props: EditPackageEnvironmentCardProps) => { + const { packageId, environment: propsEnv, className, onEnvironmentEdited, submit } = props + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + + const [environment, setEnvironment] = useState( + propsEnv ?? { + id: null, + name: '', + node: null, + prefix: '', + }, + ) + + const editing = !!environment.id + + const handleApiError = defaultApiErrorHandler(t) + + const formik = useDyoFormik({ + submit, + initialValues: environment, + validationSchema: packageEnvironmentSchema, + t, + onSubmit: async (values, { setFieldError }) => { + const body: CreatePackageEnvironment | UpdatePackageEnvironment = { + name: values.name, + nodeId: values.node?.id, + prefix: values.prefix, + } + + const res = await (!editing + ? sendForm('POST', routes.package.api.environments(packageId), body as CreatePackageEnvironment) + : sendForm( + 'PUT', + routes.package.api.environmentDetails(packageId, environment.id), + body as UpdatePackageEnvironment, + )) + + if (!res.ok) { + await handleApiError(res, setFieldError) + return + } + + let result: PackageEnvironment + if (res.status !== 204) { + const json = await res.json() + result = json as PackageEnvironment + } else { + result = values + } + + setEnvironment(result) + onEnvironmentEdited(result) + }, + }) + + const onNodesFetched = (nodes: DyoNode[] | null) => { + if (nodes?.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + formik.setFieldValue('node', nodes[0]) + } + } + + return ( + + + {editing ? t('common:editName', { name: environment.name }) : t('newEnvironment')} + + + + + + {t('common:node')} + + { + await formik.setFieldValue('node', it) + }} + /> + + + + + + + ) +} + +export default EditPackageEnvironmentCard diff --git a/web/crux-ui/src/components/packages/package-card.tsx b/web/crux-ui/src/components/packages/package-card.tsx new file mode 100644 index 000000000..677d25410 --- /dev/null +++ b/web/crux-ui/src/components/packages/package-card.tsx @@ -0,0 +1,51 @@ +import DyoBadge from '@app/elements/dyo-badge' +import { DyoCard } from '@app/elements/dyo-card' +import { DyoHeading } from '@app/elements/dyo-heading' +import DyoLink from '@app/elements/dyo-link' +import DyoTag from '@app/elements/dyo-tag' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { Package } from '@app/models' +import clsx from 'clsx' +import useTranslation from 'next-translate/useTranslation' + +type PackageCardProps = { + className?: string + pack: Package + hideEnvironments?: boolean +} + +const PackageCard = (props: PackageCardProps) => { + const { className, pack, hideEnvironments } = props + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + const titleHref = routes.package.details(pack.id) + + return ( + + + {pack.icon && } + + + {pack.name} + + + +

    {pack.description}

    + + {!hideEnvironments && pack.environments.length > 0 && ( + <> + {t('environments')} + +
    + {pack.environments.map(it => ( + {it} + ))} +
    + + )} +
    + ) +} + +export default PackageCard diff --git a/web/crux-ui/src/components/packages/package-details-card.tsx b/web/crux-ui/src/components/packages/package-details-card.tsx new file mode 100644 index 000000000..bf883cfa0 --- /dev/null +++ b/web/crux-ui/src/components/packages/package-details-card.tsx @@ -0,0 +1,50 @@ +import DyoBadge from '@app/elements/dyo-badge' +import { DyoCard } from '@app/elements/dyo-card' +import { DyoHeading } from '@app/elements/dyo-heading' +import DyoLink from '@app/elements/dyo-link' +import DyoTag from '@app/elements/dyo-tag' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { Package } from '@app/models' +import clsx from 'clsx' +import useTranslation from 'next-translate/useTranslation' + +type PackageDetailsCardProps = { + className?: string + pack: Package +} + +const PackageDetailsCard = (props: PackageDetailsCardProps) => { + const { className, pack } = props + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + const titleHref = routes.package.details(pack.id) + + return ( + + + {pack.icon && } + + + {pack.name} + + + +

    {pack.description}

    + + {pack.versionChains.length > 0 && ( + <> + {t('environments')} + +
    + {pack.versionChains.map(it => ( + {`${it.project.name} ${it.earliest.name} -> ${it.latest?.name}`} + ))} +
    + + )} +
    + ) +} + +export default PackageDetailsCard diff --git a/web/crux-ui/src/components/packages/package-environment-card.tsx b/web/crux-ui/src/components/packages/package-environment-card.tsx new file mode 100644 index 000000000..b48d49d49 --- /dev/null +++ b/web/crux-ui/src/components/packages/package-environment-card.tsx @@ -0,0 +1,54 @@ +import { DyoCard } from '@app/elements/dyo-card' +import { DyoHeading } from '@app/elements/dyo-heading' +import { DyoLabel } from '@app/elements/dyo-label' +import DyoLink from '@app/elements/dyo-link' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { PackageEnvironment } from '@app/models' +import clsx from 'clsx' +import useTranslation from 'next-translate/useTranslation' +import NodeStatusIndicator from '../nodes/node-status-indicator' + +type PackageEnvironmentCardProps = { + className?: string + packageId: string + environment: PackageEnvironment + onClick?: VoidFunction +} + +const PackageEnvironmentCard = (props: PackageEnvironmentCardProps) => { + const { className, packageId, environment, onClick } = props + const { node } = environment + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + const titleHref = routes.package.environmentDetails(packageId, environment.id) + + const title = ( + + {environment.name} + + ) + + return ( + +
    + + {title} + +
    + +
    + + +
    + {t('common:node')} + {node.name} + {t('common:prefix')} + {environment.prefix} +
    +
    +
    + ) +} + +export default PackageEnvironmentCard diff --git a/web/crux-ui/src/components/packages/package-environment-version-list.tsx b/web/crux-ui/src/components/packages/package-environment-version-list.tsx new file mode 100644 index 000000000..454b9d139 --- /dev/null +++ b/web/crux-ui/src/components/packages/package-environment-version-list.tsx @@ -0,0 +1,158 @@ +import { DyoCard } from '@app/elements/dyo-card' +import DyoIcon from '@app/elements/dyo-icon' +import DyoImgButton from '@app/elements/dyo-img-button' +import DyoLink from '@app/elements/dyo-link' +import DyoTable, { DyoColumn, sortDate, sortString } from '@app/elements/dyo-table' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { + BasicDeployment, + Deployment, + PackageEnvironmentDetails, + PackageVersion, + PackageVersionChainDetails, +} from '@app/models' +import { utcDateToLocale } from '@app/utils' +import useTranslation from 'next-translate/useTranslation' +import { useState } from 'react' +import CreatePackageDeploymentModal from './create-package-deployment-modal' + +const latestNameOf = (chain: PackageVersionChainDetails): string => { + const version = chain.versions.at(-1) + return version?.name ?? '' +} + +const currentVersionOf = (chain: PackageVersionChainDetails): PackageVersion | null => + chain.versions.findLast(it => it.deployment?.status === 'successful') ?? null + +const currentVersionNameOf = (chain: PackageVersionChainDetails): string => currentVersionOf(chain)?.name ?? '' + +const currentSuccessfulDeploymentOf = (chain: PackageVersionChainDetails): BasicDeployment | null => { + const version = currentVersionOf(chain) + return version?.deployment ?? null +} + +const lastestDeploymentOf = (chain: PackageVersionChainDetails): BasicDeployment | null => { + const version = chain.versions.findLast(it => !!it.deployment) + return version?.deployment ?? null +} + +const deployedAtOf = (chain: PackageVersionChainDetails): string => { + const deployment = currentSuccessfulDeploymentOf(chain) + const audit = deployment?.audit + + return audit?.updatedAt ?? audit?.createdAt ?? '' +} + +type PackageEnvironmentVersionListProps = { + environment: PackageEnvironmentDetails +} + +const PackageEnvironmentVersionList = (props: PackageEnvironmentVersionListProps) => { + const { environment } = props + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + + const [chains, setChains] = useState(environment.versionChains) + const [targetChain, setTargetChain] = useState(null) + + const onCreateDeployment = (chain: PackageVersionChainDetails) => setTargetChain(chain) + + const onDeploymentCreated = (deployment: Deployment) => { + const newChains = [...chains] + + const chain = newChains.find(it => it.chainId === targetChain.chainId) + + const version = chain.versions.find(it => it.id === deployment.version.id) + version.deployment = deployment + + setChains(newChains) + } + + return ( + <> + + + it.project.name} + /> + + + + + + { + const deployedAt = deployedAtOf(it) + + return deployedAt ? utcDateToLocale(deployedAt) : t('common:never') + }} + /> + + { + const deployment = lastestDeploymentOf(it) + + return ( +
    + await onCreateDeployment(it)} + /> + + {deployment && ( + + + + )} +
    + ) + }} + /> +
    +
    + + {targetChain && ( + setTargetChain(null)} + /> + )} + + ) +} + +export default PackageEnvironmentVersionList diff --git a/web/crux-ui/src/components/packages/version-chain-list.tsx b/web/crux-ui/src/components/packages/version-chain-list.tsx new file mode 100644 index 000000000..c4a535cf8 --- /dev/null +++ b/web/crux-ui/src/components/packages/version-chain-list.tsx @@ -0,0 +1,47 @@ +import DyoImgButton from '@app/elements/dyo-img-button' +import { DyoLabel } from '@app/elements/dyo-label' +import { DyoList } from '@app/elements/dyo-list' +import { PackageVersionChain } from '@app/models' +import useTranslation from 'next-translate/useTranslation' + +type VersionChainListProps = { + className?: string + versionChains: PackageVersionChain[] + onRemove: (chain: PackageVersionChain) => Promise +} + +const VersionChainList = (props: VersionChainListProps) => { + const { className, versionChains, onRemove } = props + + const { t } = useTranslation('packages') + + const itemTemplate = (chain: PackageVersionChain) => + /* eslint-disable react/jsx-key */ + [ + {chain.project.name}, + {chain.earliest.name}, +
    + {chain.latest.name} + + await onRemove(chain)} + /> +
    , + ] + /* eslint-enable react/jsx-key */ + + return ( + t(it))} + itemBuilder={itemTemplate} + /> + ) +} + +export default VersionChainList diff --git a/web/crux-ui/src/components/projects/select-project-chips.tsx b/web/crux-ui/src/components/projects/select-project-chips.tsx index 049b3d8fd..5fdd1d03c 100644 --- a/web/crux-ui/src/components/projects/select-project-chips.tsx +++ b/web/crux-ui/src/components/projects/select-project-chips.tsx @@ -51,8 +51,6 @@ const SelectProjectChips = (props: SelectProjectChipsProps) => { {t('common:loading')} ) : projects.length === 0 ? ( - ) : !projects ? ( - {t('common:loading')} ) : ( <> {
    {!onIncreaseClick || !version.increasable ? null : ( { {!onSetAsDefaultClick || version.default ? null : ( { {t('noDeployments')} )} - {!showInfo ? null : ( + + {showInfo && ( { - {!tagsModalTarget ? null : ( + {tagsModalTarget && ( { className={clsx(color, ring, className, 'rounded grid items-center', disabled ? 'opacity-40' : null)} disabled={disabled} onClick={onClick} + type="button" > +export type BasicDeployment = { + id: string + prefix: string + status: DeploymentStatus + protected: boolean + audit: Audit +} + export type Deployment = { id: string audit: Audit diff --git a/web/crux-ui/src/models/index.ts b/web/crux-ui/src/models/index.ts index 7c3efe58d..11643c799 100644 --- a/web/crux-ui/src/models/index.ts +++ b/web/crux-ui/src/models/index.ts @@ -2,6 +2,7 @@ export * from './audit' export * from './auth' export * from './common' export * from './config-bundle' +export * from './package' export * from './container' export * from './dashboard' export * from './deployment' diff --git a/web/crux-ui/src/models/package.ts b/web/crux-ui/src/models/package.ts new file mode 100644 index 000000000..249e4bcf5 --- /dev/null +++ b/web/crux-ui/src/models/package.ts @@ -0,0 +1,76 @@ +import { BasicDeployment } from './deployment' +import { DyoNode } from './node' +import { BasicProject } from './project' +import { VersionChain } from './version' + +export type PackageEnvironment = { + id: string + name: string + node: DyoNode + prefix: string +} + +export type PackageVersion = { + id: string + name: string + deployment?: BasicDeployment +} + +export type PackageVersionChainDetails = { + chainId: string + project: BasicProject + versions: PackageVersion[] +} + +export type BasicPackage = { + id: string + name: string +} + +export type PackageEnvironmentDetails = PackageEnvironment & { + package: BasicPackage + versionChains: PackageVersionChainDetails[] +} + +export type PackageVersionChain = VersionChain & { + project: BasicProject +} + +export type Package = { + id: string + name: string + description: string + icon?: string + versionChains: PackageVersionChain[] + environments: string[] +} + +export type PackageDetails = Omit & { + environments: PackageEnvironment[] +} + +export type UpdatePackage = { + name: string + description?: string + icon?: string + chainIds: string[] +} + +export type CreatePackage = UpdatePackage + +export type UpdatePackageEnvironment = { + name: string + nodeId: string + prefix: string +} + +export type CreatePackageEnvironment = UpdatePackageEnvironment + +export const packageDetailsToPackage = (pack: PackageDetails): Package => ({ + ...pack, + environments: pack.environments.map(it => it.name), +}) + +export type CreatePackageDeployment = { + versionId: string +} diff --git a/web/crux-ui/src/models/version.ts b/web/crux-ui/src/models/version.ts index 0116bc579..e777cfd27 100644 --- a/web/crux-ui/src/models/version.ts +++ b/web/crux-ui/src/models/version.ts @@ -55,3 +55,9 @@ export type VersionAddSectionState = 'image' | 'deployment' | 'none' export const VERSION_SECTIONS_STATE_VALUES = ['images', 'deployments', 'reorder'] as const export type VersionSectionsState = (typeof VERSION_SECTIONS_STATE_VALUES)[number] + +export type VersionChain = { + id: string + earliest: BasicVersion + latest?: BasicVersion +} diff --git a/web/crux-ui/src/pages/[teamSlug]/audit-log.tsx b/web/crux-ui/src/pages/[teamSlug]/audit-log.tsx index f51795cb8..299d24954 100644 --- a/web/crux-ui/src/pages/[teamSlug]/audit-log.tsx +++ b/web/crux-ui/src/pages/[teamSlug]/audit-log.tsx @@ -171,7 +171,7 @@ const AuditLogPage = () => { - {!showInfo ? null : ( + {showInfo && ( { const submit = useSubmit() const onCreated = async (bundle: ConfigBundle) => { - await router.push(routes.configBundles.details(bundle.id)) + await router.push(routes.configBundle.details(bundle.id)) } const onRouteOptionsChange = async (routeOptions: ListRouteOptions) => { - await router.replace(routes.configBundles.list(routeOptions)) + await router.replace(routes.configBundle.list(routeOptions)) } const selfLink: BreadcrumbLink = { name: t('common:configBundles'), - url: routes.configBundles.list(), + url: routes.configBundle.list(), } return ( @@ -93,7 +93,7 @@ export default ConfigBundles const getPageServerSideProps = async (context: GetServerSidePropsContext) => { const routes = TeamRoutes.fromContext(context) - const bundles = await getCruxFromContext(context, routes.configBundles.api.list()) + const bundles = await getCruxFromContext(context, routes.configBundle.api.list()) return { props: { diff --git a/web/crux-ui/src/pages/[teamSlug]/config-bundles/[configBundleId].tsx b/web/crux-ui/src/pages/[teamSlug]/config-bundles/[configBundleId].tsx index 0216a3ca7..bdec815f3 100644 --- a/web/crux-ui/src/pages/[teamSlug]/config-bundles/[configBundleId].tsx +++ b/web/crux-ui/src/pages/[teamSlug]/config-bundles/[configBundleId].tsx @@ -49,7 +49,7 @@ const ConfigBundleDetailsPage = (props: ConfigBundleDetailsPageProps) => { const pageLink: BreadcrumbLink = { name: t('common:configBundles'), - url: routes.configBundles.list(), + url: routes.configBundle.list(), } return ( @@ -59,7 +59,7 @@ const ConfigBundleDetailsPage = (props: ConfigBundleDetailsPageProps) => { sublinks={[ { name: configBundle.name, - url: routes.configBundles.details(configBundle.id), + url: routes.configBundle.details(configBundle.id), }, ]} > @@ -78,7 +78,7 @@ const ConfigBundleDetailsPage = (props: ConfigBundleDetailsPageProps) => { - {t(editing ? 'common:editName' : 'view', { name: configBundle.name })} + {t(editing ? 'common:editName' : 'view', configBundle)} {t('tips')} @@ -144,7 +144,7 @@ const getPageServerSideProps = async (context: GetServerSidePropsContext) => { const configBundle = await getCruxFromContext( context, - routes.configBundles.api.details(configBundleId), + routes.configBundle.api.details(configBundleId), ) return { diff --git a/web/crux-ui/src/pages/[teamSlug]/deployments.tsx b/web/crux-ui/src/pages/[teamSlug]/deployments.tsx index 30bfb04b7..b66206c6d 100644 --- a/web/crux-ui/src/pages/[teamSlug]/deployments.tsx +++ b/web/crux-ui/src/pages/[teamSlug]/deployments.tsx @@ -250,6 +250,7 @@ const DeploymentsPage = (props: DeploymentsPageProps) => { {t('noItems')} )} + {!showInfo ? null : ( { + const { packages } = props + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + const router = useRouter() + const anchor = useAnchor() + + const filters = useFilters({ + filters: [ + textFilterFor(pack => [ + pack.name, + pack.description, + pack.icon, + ...pack.environments, + ...pack.versionChains.map(it => it.project.name), + ...pack.versionChains.map(it => it.earliest.name), + ...pack.versionChains.map(it => it.latest?.name).filter(it => !!it), + ]), + ], + initialData: packages, + }) + + const creating = anchor === ANCHOR_NEW + const submit = useSubmit() + + const onPackageCreated = async (pack: PackageDetails) => { + // When creating navigate the user to the project detail page + await router.push(routes.package.details(pack.id)) + } + + const onRouteOptionsChange = async (routeOptions: ListRouteOptions) => { + await router.replace(routes.package.list(routeOptions)) + } + + const pageLink: BreadcrumbLink = { + name: t('common:packages'), + url: routes.package.list(), + } + + return ( + + + + + + {creating && } + + {filters.items.length > 0 ? ( + <> + filters.setFilter({ text: it })} /> + + + {filters.filtered.map((it, index) => ( + + ))} + + + ) : ( + + {t('noItems')} + + )} + + ) +} +export default PackagesPage + +const getPageServerSideProps = async (context: GetServerSidePropsContext) => { + const routes = TeamRoutes.fromContext(context) + + const packages = await getCruxFromContext(context, routes.package.api.list()) + + return { + props: { + packages, + }, + } +} + +export const getServerSideProps = withContextAuthorization(getPageServerSideProps) diff --git a/web/crux-ui/src/pages/[teamSlug]/packages/[packageId].tsx b/web/crux-ui/src/pages/[teamSlug]/packages/[packageId].tsx new file mode 100644 index 000000000..19e45b272 --- /dev/null +++ b/web/crux-ui/src/pages/[teamSlug]/packages/[packageId].tsx @@ -0,0 +1,152 @@ +import { Layout } from '@app/components/layout' +import EditPackageCard from '@app/components/packages/edit-package-card' +import EditPackageEnvironmentCard from '@app/components/packages/edit-package-environment-card' +import PackageCard from '@app/components/packages/package-card' +import PackageEnvironmentCard from '@app/components/packages/package-environment-card' +import { BreadcrumbLink } from '@app/components/shared/breadcrumb' +import PageHeading from '@app/components/shared/page-heading' +import { DetailsPageMenu, DetailsPageTexts } from '@app/components/shared/page-menu' +import DyoWrap from '@app/elements/dyo-wrap' +import useSubmit from '@app/hooks/use-submit' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { PackageDetails, PackageEnvironment, packageDetailsToPackage } from '@app/models' +import { TeamRoutes } from '@app/routes' +import { withContextAuthorization } from '@app/utils' +import { getCruxFromContext } from '@server/crux-api' +import { GetServerSidePropsContext } from 'next' +import useTranslation from 'next-translate/useTranslation' +import { useRouter } from 'next/dist/client/router' +import { useState } from 'react' +import toast from 'react-hot-toast' + +type PackageDetailsState = 'edit-package' | 'add-environment' | 'environments' + +type PackageDetailsPageProps = { + pack: PackageDetails +} + +const PackageDetailsPage = (props: PackageDetailsPageProps) => { + const { pack: propsPackage } = props + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + const router = useRouter() + + const [pack, setPackage] = useState(propsPackage) + const [state, setState] = useState('environments') + + const submit = useSubmit() + + const onPackageEdited = (newPack: PackageDetails) => { + setState('environments') + setPackage(newPack) + } + + const onDelete = async () => { + const res = await fetch(routes.package.api.details(pack.id), { + method: 'DELETE', + }) + + if (res.ok) { + await router.replace(routes.package.list()) + } else { + toast(t('errors:oops')) + } + } + + const onAddEnvironment = () => setState('add-environment') + + const onEnvironmentCreated = (env: PackageEnvironment) => { + const newEnvs = [...pack.environments, env] + + setPackage({ + ...pack, + environments: newEnvs, + }) + setState('environments') + } + + const pageLink: BreadcrumbLink = { + name: t('common:packages'), + url: routes.package.list(), + } + + const sublinks: BreadcrumbLink[] = [ + { + name: pack.name, + url: routes.package.details(pack.id), + }, + ] + + const pageMenuTexts: DetailsPageTexts = { + addDetailsItem: t('addEnvironment'), + } + + return ( + + + setState(editing ? 'edit-package' : 'environments')} + submit={submit} + deleteModalTitle={t('common:areYouSureDeleteName', { name: pack.name })} + deleteModalDescription={t('proceedYouLoseAllDataToName', { + name: pack.name, + })} + /> + + +
    + {state === 'environments' ? ( + + ) : state === 'edit-package' ? ( + + ) : state === 'add-environment' ? ( + + ) : null} + + {state === 'environments' && + (pack.environments.length < 1 ? ( + notyet + ) : ( + + {pack.environments.map((env, index) => ( + + ))} + + ))} +
    +
    + ) +} + +export default PackageDetailsPage + +const getPageServerSideProps = async (context: GetServerSidePropsContext) => { + const routes = TeamRoutes.fromContext(context) + + const packageId = context.query.packageId as string + + const pack = await getCruxFromContext(context, routes.package.api.details(packageId)) + + return { + props: { + pack, + }, + } +} + +export const getServerSideProps = withContextAuthorization(getPageServerSideProps) diff --git a/web/crux-ui/src/pages/[teamSlug]/packages/[packageId]/environments/[environmentId].tsx b/web/crux-ui/src/pages/[teamSlug]/packages/[packageId]/environments/[environmentId].tsx new file mode 100644 index 000000000..4a8879f91 --- /dev/null +++ b/web/crux-ui/src/pages/[teamSlug]/packages/[packageId]/environments/[environmentId].tsx @@ -0,0 +1,121 @@ +import { Layout } from '@app/components/layout' +import EditPackageEnvironmentCard from '@app/components/packages/edit-package-environment-card' +import PackageEnvironmentCard from '@app/components/packages/package-environment-card' +import PackageEnvironmentVersionList from '@app/components/packages/package-environment-version-list' +import { BreadcrumbLink } from '@app/components/shared/breadcrumb' +import PageHeading from '@app/components/shared/page-heading' +import { DetailsPageMenu, DetailsPageTexts } from '@app/components/shared/page-menu' +import useSubmit from '@app/hooks/use-submit' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { PackageEnvironment, PackageEnvironmentDetails } from '@app/models' +import { TeamRoutes } from '@app/routes' +import { withContextAuthorization } from '@app/utils' +import { getCruxFromContext } from '@server/crux-api' +import { GetServerSidePropsContext } from 'next' +import useTranslation from 'next-translate/useTranslation' +import { useRouter } from 'next/dist/client/router' +import { useState } from 'react' +import toast from 'react-hot-toast' + +type EnvironmentDetailsPageProps = { + environment: PackageEnvironmentDetails +} + +const EnvironmentDetailsPage = (props: EnvironmentDetailsPageProps) => { + const { environment: propsEnv } = props + const { package: pack } = propsEnv + + const { t } = useTranslation('packages') + const routes = useTeamRoutes() + const router = useRouter() + + const [env, setEnv] = useState(propsEnv) + const [editing, setEditing] = useState(false) + + const submit = useSubmit() + + const onEnvEdited = (newEnv: PackageEnvironmentDetails) => setEnv(newEnv) + + const onDelete = async () => { + const res = await fetch(routes.package.api.environmentDetails(pack.id, env.id), { + method: 'DELETE', + }) + + if (res.ok) { + await router.replace(routes.package.details(pack.id)) + } else { + toast(t('errors:oops')) + } + } + + const pageLink: BreadcrumbLink = { + name: t('common:packages'), + url: routes.package.list(), + } + + const sublinks: BreadcrumbLink[] = [ + { + name: pack.name, + url: routes.package.details(pack.id), + }, + { + name: env.name, + url: routes.package.environmentDetails(pack.id, env.id), + }, + ] + + const pageMenuTexts: DetailsPageTexts = { + addDetailsItem: t('addEnvironment'), + } + + return ( + + + + + +
    + {!editing ? ( + + ) : ( + + )} + + {!editing && } +
    +
    + ) +} + +export default EnvironmentDetailsPage + +const getPageServerSideProps = async (context: GetServerSidePropsContext) => { + const routes = TeamRoutes.fromContext(context) + + const packageId = context.query.packageId as string + const environmentId = context.query.environmentId as string + + const env = await getCruxFromContext( + context, + routes.package.api.environmentDetails(packageId, environmentId), + ) + + return { + props: { + packageId, + environment: env, + }, + } +} + +export const getServerSideProps = withContextAuthorization(getPageServerSideProps) diff --git a/web/crux-ui/src/routes.ts b/web/crux-ui/src/routes.ts index 4e0a2c83d..0782c7341 100644 --- a/web/crux-ui/src/routes.ts +++ b/web/crux-ui/src/routes.ts @@ -385,6 +385,8 @@ class ProjectApi { details = (id: string) => `${this.root}/${id}` + versionChains = (id: string) => `${this.details(id)}/version-chains` + convertToVersioned = (id: string) => `${this.details(id)}/convert` } @@ -663,6 +665,50 @@ class ConfigBundleRoutes { detailsSocket = (id: string) => this.details(id) } +export class PackageApi { + private readonly root: string + + constructor(root: string) { + this.root = `/api${root}` + } + + list = () => this.root + + details = (id: string) => `${this.root}/${id}` + + environments = (packageId: string) => `${this.details(packageId)}/environments` + + environmentDetails = (packageId: string, environmentId: string) => `${this.environments(packageId)}/${environmentId}` + + environmentDeployments = (packageId: string, environmentId: string) => + `${this.environmentDetails(packageId, environmentId)}/deployments` +} + +export class PackageRoutes { + private readonly root: string + + constructor(root: string) { + this.root = `${root}/packages` + } + + private _api: PackageApi + + get api() { + if (!this._api) { + this._api = new PackageApi(this.root) + } + + return this._api + } + + list = (options?: ListRouteOptions) => appendAnchorWhenDeclared(this.root, ANCHOR_NEW, options?.new) + + details = (id: string) => `${this.root}/${id}` + + environmentDetails = (packageId: string, environmentId: string) => + `${this.details(packageId)}/environments/${environmentId}` +} + export class TeamRoutes { readonly root: string @@ -688,7 +734,9 @@ export class TeamRoutes { private _pipeline: PipelineRoutes - private _configBundles: ConfigBundleRoutes + private _configBundle: ConfigBundleRoutes + + private _package: PackageRoutes get audit() { if (!this._audit) { @@ -762,12 +810,20 @@ export class TeamRoutes { return this._pipeline } - get configBundles() { - if (!this._configBundles) { - this._configBundles = new ConfigBundleRoutes(this.root) + get configBundle() { + if (!this._configBundle) { + this._configBundle = new ConfigBundleRoutes(this.root) + } + + return this._configBundle + } + + get package() { + if (!this._package) { + this._package = new PackageRoutes(this.root) } - return this._configBundles + return this._package } static fromContext(context: GetServerSidePropsContext): TeamRoutes | null { diff --git a/web/crux-ui/src/validations/deployment.ts b/web/crux-ui/src/validations/deployment.ts index 257200a5a..0f03a89d6 100644 --- a/web/crux-ui/src/validations/deployment.ts +++ b/web/crux-ui/src/validations/deployment.ts @@ -2,7 +2,7 @@ import yup from './yup' import { nameRule } from './common' import { createMergedContainerConfigSchema, uniqueKeyValuesSchema } from './container' -const prefixRule = yup +export const prefixRule = yup .string() .trim() .matches(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/) // RFC 1123 @@ -28,7 +28,7 @@ export const createDeploymentTokenSchema = yup.object().shape({ }) export const createFullDeploymentSchema = yup.object().shape({ - nodeId: yup.string().required(), + node: yup.object().required().label('common:node'), prefix: prefixRule, versionId: yup.string().required().label('common:versions'), project: yup.object().required().label('common:projects'), diff --git a/web/crux-ui/src/validations/index.ts b/web/crux-ui/src/validations/index.ts index 9561cfea8..3bc7b0beb 100644 --- a/web/crux-ui/src/validations/index.ts +++ b/web/crux-ui/src/validations/index.ts @@ -16,3 +16,4 @@ export * from './template' export * from './compose' export * from './token' export * from './version' +export * from './package' diff --git a/web/crux-ui/src/validations/package.ts b/web/crux-ui/src/validations/package.ts new file mode 100644 index 000000000..8a58aa89e --- /dev/null +++ b/web/crux-ui/src/validations/package.ts @@ -0,0 +1,16 @@ +import { descriptionRule, iconRule, nameRule } from './common' +import { prefixRule } from './deployment' +import yup from './yup' + +export const packageSchema = yup.object().shape({ + name: nameRule.required(), + description: descriptionRule, + icon: iconRule, + chainIds: yup.array(yup.string()).label('versionChains'), +}) + +export const packageEnvironmentSchema = yup.object().shape({ + name: nameRule.required(), + nodeId: yup.string().required().label('common:node'), + prefix: prefixRule, +}) diff --git a/web/crux/package-lock.json b/web/crux/package-lock.json index 44dd238b0..ca2344970 100644 --- a/web/crux/package-lock.json +++ b/web/crux/package-lock.json @@ -2145,12 +2145,12 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/@nestjs/platform-ws": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.1.3.tgz", - "integrity": "sha512-irGGpzkQ+9RofB1Ea0DEuq/abArAn+Myq9J1WtYDCdrFinAReh2vEN+NH+bdcu8m9u+SoD+z2XPvgEq6GDHK9g==", + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.3.10.tgz", + "integrity": "sha512-xHiMu162ycuiYJFuIlemCV6CK93Q8eh0Ljvq3sGZ+Oin1Xw7wA67NMADnaEr8Uv/LCUyo813uHNIeQaxL8GkRw==", "dependencies": { - "tslib": "2.6.1", - "ws": "8.13.0" + "tslib": "2.6.3", + "ws": "8.17.1" }, "funding": { "type": "opencollective", @@ -2162,6 +2162,11 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-ws/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + }, "node_modules/@nestjs/schematics": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.1.tgz", @@ -13015,9 +13020,9 @@ } }, "node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, diff --git a/web/crux/prisma/migrations/20240702110318_packages/migration.sql b/web/crux/prisma/migrations/20240702110318_packages/migration.sql new file mode 100644 index 000000000..caaef5fe8 --- /dev/null +++ b/web/crux/prisma/migrations/20240702110318_packages/migration.sql @@ -0,0 +1,108 @@ +-- AlterTable +ALTER TABLE "Version" ADD COLUMN "chainId" UUID; + +-- CreateTable +CREATE TABLE "VersionChain" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + + CONSTRAINT "VersionChain_pkey" PRIMARY KEY ("id") +); + +-- insert chains +insert into "VersionChain" +select v."id", p."id" as "projectId" from "Version" v +inner join "Project" p on p."id" = v."projectId" +left join "VersionsOnParentVersion" vopv on vopv."versionId" = v."id" +where v."type" = 'incremental' and vopv."versionId" is null; + +-- set incremental chainIds +update "Version" +set "chainId" = uv."id" +from ( + select v."id" + from "Version" v + left join "VersionsOnParentVersion" vopv on vopv."versionId" = v."id" + where v."type" = 'incremental' and vopv."versionId" is null +) as uv +where "Version"."id" = uv."id"; + +-- set children chainIds +WITH RECURSIVE parents(parentVersionId, versionId) AS ( + SELECT "parentVersionId", "versionId" + FROM "VersionsOnParentVersion" + UNION ALL + SELECT vopv."parentVersionId", p."versionid" + FROM "VersionsOnParentVersion" vopv + join parents p on p."parentversionid" = vopv."versionId" + ) + update "Version" v + set "chainId" = p."parentversionid" + FROM parents p + left join "VersionsOnParentVersion" parentVersionParent on parentVersionParent."versionId" = p."parentversionid" + WHERE v."id" = p."versionid" and parentVersionParent."versionId" is null; + + +-- CreateTable +CREATE TABLE "Package" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" UUID NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "updatedBy" UUID, + "name" VARCHAR(70) NOT NULL, + "description" TEXT, + "icon" TEXT, + "teamId" UUID NOT NULL, + + CONSTRAINT "Package_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VersionChainsOnPackage" ( + "chainId" UUID NOT NULL, + "packageId" UUID NOT NULL, + + CONSTRAINT "VersionChainsOnPackage_pkey" PRIMARY KEY ("chainId","packageId") +); + +-- CreateTable +CREATE TABLE "PackageEnvironment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "packageId" UUID NOT NULL, + "nodeId" UUID NOT NULL, + "prefix" TEXT NOT NULL, + + CONSTRAINT "PackageEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Package_name_teamId_key" ON "Package"("name", "teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "VersionChainsOnPackage_chainId_packageId_key" ON "VersionChainsOnPackage"("chainId", "packageId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PackageEnvironment_name_packageId_key" ON "PackageEnvironment"("name", "packageId"); + +-- AddForeignKey +ALTER TABLE "VersionChain" ADD CONSTRAINT "VersionChain_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Version" ADD CONSTRAINT "Version_chainId_fkey" FOREIGN KEY ("chainId") REFERENCES "VersionChain"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Package" ADD CONSTRAINT "Package_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VersionChainsOnPackage" ADD CONSTRAINT "VersionChainsOnPackage_chainId_fkey" FOREIGN KEY ("chainId") REFERENCES "VersionChain"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VersionChainsOnPackage" ADD CONSTRAINT "VersionChainsOnPackage_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageEnvironment" ADD CONSTRAINT "PackageEnvironment_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageEnvironment" ADD CONSTRAINT "PackageEnvironment_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "Node"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/web/crux/prisma/schema.prisma b/web/crux/prisma/schema.prisma index b5acb3c10..a093091c2 100644 --- a/web/crux/prisma/schema.prisma +++ b/web/crux/prisma/schema.prisma @@ -27,7 +27,8 @@ model Team { notifications Notification[] storages Storage[] configBundles ConfigBundle[] - Pipeline Pipeline[] + pipelines Pipeline[] + packages Package[] } model Token { @@ -84,9 +85,10 @@ model Node { team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) teamId String @db.Uuid - deployments Deployment[] - events NodeEvent[] - token NodeToken? + deployments Deployment[] + events NodeEvent[] + token NodeToken? + environments PackageEnvironment[] @@unique([name, teamId]) } @@ -149,9 +151,10 @@ model Project { description String? type ProjectTypeEnum - versions Version[] - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - teamId String @db.Uuid + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId String @db.Uuid + versions Version[] + versionChains VersionChain[] @@unique([name, teamId]) } @@ -168,8 +171,10 @@ model Version { type VersionTypeEnum @default(incremental) autoCopyDeployments Boolean @default(true) projectId String @db.Uuid + chainId String? @db.Uuid project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + chain VersionChain? @relation(fields: [chainId], references: [id], onDelete: Cascade) images Image[] deployments Deployment[] parent VersionsOnParentVersion? @relation("child") @@ -178,6 +183,15 @@ model Version { @@unique([projectId, name]) } +model VersionChain { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + members Version[] + packages VersionChainsOnPackage[] +} + model VersionsOnParentVersion { versionId String @unique @db.Uuid parentVersionId String @unique @db.Uuid @@ -682,3 +696,46 @@ model QualityAssuranceConfig { id String @id @default(uuid()) @db.Uuid name String? } + +model Package { + id String @id @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + createdBy String @db.Uuid + updatedAt DateTime @updatedAt @db.Timestamptz(6) + updatedBy String? @db.Uuid + name String @db.VarChar(70) + description String? + icon String? + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId String @db.Uuid + + chains VersionChainsOnPackage[] + environments PackageEnvironment[] + + @@unique([name, teamId]) +} + +model VersionChainsOnPackage { + chainId String @db.Uuid + packageId String @db.Uuid + + chain VersionChain @relation(fields: [chainId], references: [id], onDelete: Cascade) + package Package @relation(fields: [packageId], references: [id], onDelete: Cascade) + + @@id([chainId, packageId]) + @@unique([chainId, packageId]) +} + +model PackageEnvironment { + id String @id @default(uuid()) @db.Uuid + name String + packageId String @db.Uuid + nodeId String @db.Uuid + prefix String + + package Package @relation(fields: [packageId], references: [id], onDelete: Cascade) + node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@unique([name, packageId]) +} diff --git a/web/crux/src/app.module.ts b/web/crux/src/app.module.ts index 117dd010d..c40772359 100644 --- a/web/crux/src/app.module.ts +++ b/web/crux/src/app.module.ts @@ -12,6 +12,7 @@ import HealthModule from './app/health/health.module' import ImageModule from './app/image/image.module' import NodeModule from './app/node/node.module' import NotificationModule from './app/notification/notification.module' +import PackageModule from './app/package/package.module' import PipelineModule from './app/pipeline/pipeline.module' import ProjectModule from './app/project/project.module' import QualityAssuranceModule from './app/quality.assurance/quality-assurance.module' @@ -44,6 +45,7 @@ const imports = [ DashboardModule, StorageModule, PipelineModule, + PackageModule, ConfigBundleModule, ConfigModule.forRoot(appConfig), EmailModule, diff --git a/web/crux/src/app/audit.logger/audit.logger.service.ts b/web/crux/src/app/audit.logger/audit.logger.service.ts index 4e067b778..3aeecc6cd 100644 --- a/web/crux/src/app/audit.logger/audit.logger.service.ts +++ b/web/crux/src/app/audit.logger/audit.logger.service.ts @@ -50,7 +50,7 @@ export default class AuditLoggerService { // deploymentToken const { deploymentId } = actor - const deploymentToken = await this.prisma.deploymentToken.findUnique({ + const deploymentToken = await this.prisma.deploymentToken.findUniqueOrThrow({ where: { deploymentId, }, diff --git a/web/crux/src/app/deploy/deploy.dto.ts b/web/crux/src/app/deploy/deploy.dto.ts index 7fe71aca4..217e7b49d 100644 --- a/web/crux/src/app/deploy/deploy.dto.ts +++ b/web/crux/src/app/deploy/deploy.dto.ts @@ -51,6 +51,9 @@ export class BasicDeploymentDto { @ApiProperty({ enum: DEPLOYMENT_STATUS_VALUES }) @IsIn(DEPLOYMENT_STATUS_VALUES) status: DeploymentStatusDto + + @ValidateNested() + audit: AuditDto } export class DeploymentDto extends BasicDeploymentDto { @@ -58,9 +61,6 @@ export class DeploymentDto extends BasicDeploymentDto { @IsOptional() note?: string | null - @ValidateNested() - audit: AuditDto - @ValidateNested() project: BasicProjectDto diff --git a/web/crux/src/app/deploy/deploy.mapper.ts b/web/crux/src/app/deploy/deploy.mapper.ts index 77dbf0f8f..7a658e886 100644 --- a/web/crux/src/app/deploy/deploy.mapper.ts +++ b/web/crux/src/app/deploy/deploy.mapper.ts @@ -44,6 +44,7 @@ import NodeMapper from '../node/node.mapper' import ProjectMapper from '../project/project.mapper' import VersionMapper from '../version/version.mapper' import { + BasicDeploymentDto, DeploymentDetails, DeploymentDetailsDto, DeploymentDto, @@ -96,6 +97,17 @@ export default class DeployMapper { status: this.statusToDto(it.status), updatedAt: it.updatedAt ?? it.createdAt, node: this.nodeMapper.toBasicWithStatusDto(it.node, nodeStatus), + audit: this.auditMapper.toDto(it), + } + } + + toBasicDto(it: Deployment): BasicDeploymentDto { + return { + id: it.id, + prefix: it.prefix, + protected: it.protected, + status: this.statusToDto(it.status), + audit: this.auditMapper.toDto(it), } } diff --git a/web/crux/src/app/deploy/deploy.service.ts b/web/crux/src/app/deploy/deploy.service.ts index 754266388..4ff30a587 100644 --- a/web/crux/src/app/deploy/deploy.service.ts +++ b/web/crux/src/app/deploy/deploy.service.ts @@ -341,7 +341,7 @@ export default class DeployService { req: PatchInstanceDto, identity: Identity, ): Promise { - const instance = await this.prisma.instance.findUnique({ + const instance = await this.prisma.instance.findUniqueOrThrow({ where: { id: instanceId, }, diff --git a/web/crux/src/app/deploy/interceptors/deploy.create-deploy-token.interceptor.ts b/web/crux/src/app/deploy/interceptors/deploy.create-deploy-token.interceptor.ts index 3e0903af0..16ae5d00c 100644 --- a/web/crux/src/app/deploy/interceptors/deploy.create-deploy-token.interceptor.ts +++ b/web/crux/src/app/deploy/interceptors/deploy.create-deploy-token.interceptor.ts @@ -14,7 +14,7 @@ export default class DeployCreateDeployTokenValidationInterceptor implements Nes const deploymentId = req.params.deploymentId as string const body = req.body as CreateDeploymentTokenDto - const deployment = await this.prisma.deployment.findUnique({ + const deployment = await this.prisma.deployment.findUniqueOrThrow({ where: { id: deploymentId, }, diff --git a/web/crux/src/app/node/pipes/node.get-script.pipe.ts b/web/crux/src/app/node/pipes/node.get-script.pipe.ts index a293f6480..a59dde79f 100644 --- a/web/crux/src/app/node/pipes/node.get-script.pipe.ts +++ b/web/crux/src/app/node/pipes/node.get-script.pipe.ts @@ -11,7 +11,7 @@ export default class NodeGetScriptValidationPipe implements PipeTransform { ) {} async transform(id: string) { - const node = await this.prisma.node.findUnique({ + const node = await this.prisma.node.findUniqueOrThrow({ select: { id: true, }, diff --git a/web/crux/src/app/package/guards/package.node-access.guard.ts b/web/crux/src/app/package/guards/package.node-access.guard.ts new file mode 100644 index 000000000..808779034 --- /dev/null +++ b/web/crux/src/app/package/guards/package.node-access.guard.ts @@ -0,0 +1,26 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' +import PrismaService from 'src/services/prisma.service' +import { CreatePackageEnvironmentDto, UpdatePackageEnvironmentDto } from '../package.dto' + +@Injectable() +export default class PackageNodeAccessGuard implements CanActivate { + constructor(private prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + const teamSlug = req.params.teamSlug as string + + const body = req.body as CreatePackageEnvironmentDto | UpdatePackageEnvironmentDto + + const nodes = await this.prisma.node.count({ + where: { + team: { + slug: teamSlug, + }, + id: body.nodeId, + }, + }) + + return nodes > 0 + } +} diff --git a/web/crux/src/app/package/guards/package.team-access.guard.ts b/web/crux/src/app/package/guards/package.team-access.guard.ts new file mode 100644 index 000000000..9d7d9b82c --- /dev/null +++ b/web/crux/src/app/package/guards/package.team-access.guard.ts @@ -0,0 +1,36 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' +import PrismaService from 'src/services/prisma.service' + +@Injectable() +export default class PackageTeamAccessGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + const teamSlug = req.params.teamSlug as string + const packageId = req.params.packageId as string + const environmentId = req.params.environmentId as string + + if (!packageId) { + return true + } + + const packages = await this.prisma.package.count({ + where: { + id: packageId, + team: { + slug: teamSlug, + }, + environments: !environmentId + ? undefined + : { + some: { + id: environmentId, + }, + }, + }, + }) + + return packages > 0 + } +} diff --git a/web/crux/src/app/package/guards/package.version-access.guard.ts b/web/crux/src/app/package/guards/package.version-access.guard.ts new file mode 100644 index 000000000..f6aea54d7 --- /dev/null +++ b/web/crux/src/app/package/guards/package.version-access.guard.ts @@ -0,0 +1,36 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' +import PrismaService from 'src/services/prisma.service' +import { CreatePackageDeploymentDto } from '../package.dto' + +@Injectable() +export default class PackageVersionAccessGuard implements CanActivate { + constructor(private prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + const teamSlug = req.params.teamSlug as string + const packageId = req.params.packageId as string + + const body = req.body as CreatePackageDeploymentDto + + const versions = await this.prisma.version.count({ + where: { + project: { + team: { + slug: teamSlug, + }, + }, + id: body.versionId, + chain: { + packages: { + some: { + packageId, + }, + }, + }, + }, + }) + + return versions > 0 + } +} diff --git a/web/crux/src/app/package/guards/package.version-chain-access.guard.ts b/web/crux/src/app/package/guards/package.version-chain-access.guard.ts new file mode 100644 index 000000000..5e5f67da1 --- /dev/null +++ b/web/crux/src/app/package/guards/package.version-chain-access.guard.ts @@ -0,0 +1,30 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' +import PrismaService from 'src/services/prisma.service' +import { CreatePackageDto, UpdatePackageDto } from '../package.dto' + +@Injectable() +export default class PackageVersionChainAccessGuard implements CanActivate { + constructor(private prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + const teamSlug = req.params.teamSlug as string + + const body = req.body as CreatePackageDto | UpdatePackageDto + + const versions = await this.prisma.version.count({ + where: { + project: { + team: { + slug: teamSlug, + }, + }, + id: { + in: body.chainIds, + }, + }, + }) + + return versions === body.chainIds.length + } +} diff --git a/web/crux/src/app/package/interceptors/package.create-deployment.interceptor.ts b/web/crux/src/app/package/interceptors/package.create-deployment.interceptor.ts new file mode 100644 index 000000000..f4ebc5036 --- /dev/null +++ b/web/crux/src/app/package/interceptors/package.create-deployment.interceptor.ts @@ -0,0 +1,44 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common' +import { Observable } from 'rxjs' +import { CruxConflictException } from 'src/exception/crux-exception' +import PrismaService from 'src/services/prisma.service' +import { CreatePackageDeploymentDto } from '../package.dto' + +@Injectable() +export default class PackageCreateDeploymentInterceptor implements NestInterceptor { + constructor(private prisma: PrismaService) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const req = context.switchToHttp().getRequest() + const environmentId = req.params.environmentId as string + + const body = req.body as CreatePackageDeploymentDto + + const env = await this.prisma.packageEnvironment.findUniqueOrThrow({ + where: { + id: environmentId, + }, + }) + + const deployment = await this.prisma.deployment.findFirst({ + where: { + nodeId: env.nodeId, + prefix: env.prefix, + versionId: body.versionId, + }, + select: { + id: true, + }, + }) + + if (deployment) { + throw new CruxConflictException({ + message: 'Deployment already exists with the same prefix and node for the version.', + property: 'deploymentId', + value: deployment.id, + }) + } + + return next.handle() + } +} diff --git a/web/crux/src/app/package/package.dto.ts b/web/crux/src/app/package/package.dto.ts new file mode 100644 index 000000000..480f7b292 --- /dev/null +++ b/web/crux/src/app/package/package.dto.ts @@ -0,0 +1,115 @@ +import { OmitType, PickType } from '@nestjs/swagger' +import { IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator' +import { BasicDeploymentDto } from '../deploy/deploy.dto' +import { NodeDto } from '../node/node.dto' +import { BasicProjectDto } from '../project/project.dto' +import { BasicVersionDto, VersionChainDto } from '../version/version.dto' + +export class PackageEnvironmentDto { + @IsUUID() + id: string + + @IsString() + name: string + + @ValidateNested() + node: NodeDto + + @IsString() + prefix: string +} + +export class PackageVersionChainDto extends VersionChainDto { + @ValidateNested() + project: BasicProjectDto +} + +export class BasicPackageDto { + @IsUUID() + id: string + + @IsString() + name: string +} + +export class PackageDto extends BasicPackageDto { + @IsString() + @IsOptional() + description?: string + + @IsString() + @IsOptional() + icon?: string + + @ValidateNested({ each: true }) + versionChains: PackageVersionChainDto[] + + @IsString({ each: true }) + environments: string[] +} + +export class PackageDetailsDto extends OmitType(PackageDto, ['environments']) { + @ValidateNested({ each: true }) + environments: PackageEnvironmentDto[] +} + +export class UpdatePackageDto { + @IsString() + name: string + + @IsString() + @IsOptional() + description?: string + + @IsString() + @IsOptional() + icon?: string + + @IsUUID(undefined, { each: true }) + chainIds: string[] +} + +export class CreatePackageDto extends UpdatePackageDto {} + +export class PackageVersionDto extends PickType(BasicVersionDto, ['id', 'name']) { + @IsOptional() + @ValidateNested() + deployment?: BasicDeploymentDto +} + +export class PackageEnvironmentVersionChainDto { + @IsUUID() + chainId: string + + @ValidateNested() + project: BasicProjectDto + + @ValidateNested({ each: true }) + versions: PackageVersionDto[] +} + +export class PackageEnvironmentDetailsDto extends PackageEnvironmentDto { + @ValidateNested() + package: BasicPackageDto + + @ValidateNested({ each: true }) + versionChains: PackageEnvironmentVersionChainDto[] +} + +export class UpdatePackageEnvironmentDto { + @IsString() + name: string + + @IsUUID() + nodeId: string + + @IsString() + prefix: string +} + +export class CreatePackageEnvironmentDto extends UpdatePackageEnvironmentDto {} + +export class CreatePackageDeploymentDto { + @IsUUID() + versionId: string +} diff --git a/web/crux/src/app/package/package.http.controller.ts b/web/crux/src/app/package/package.http.controller.ts new file mode 100644 index 000000000..3db1c50fd --- /dev/null +++ b/web/crux/src/app/package/package.http.controller.ts @@ -0,0 +1,300 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + UseGuards, + UseInterceptors, +} from '@nestjs/common' +import { + ApiBadRequestResponse, + ApiBody, + ApiConflictResponse, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger' +import { Identity } from '@ory/kratos-client' +import UuidParams from 'src/decorators/api-params.decorator' +import { CreatedResponse, CreatedWithLocation } from 'src/interceptors/created-with-location.decorator' +import { BasicDeploymentDto } from '../deploy/deploy.dto' +import { IdentityFromRequest } from '../token/jwt-auth.guard' +import PackageNodeAccessGuard from './guards/package.node-access.guard' +import PackageTeamAccessGuard from './guards/package.team-access.guard' +import PackageVersionAccessGuard from './guards/package.version-access.guard' +import PackageVersionChainAccessGuard from './guards/package.version-chain-access.guard' +import PackageCreateDeploymentInterceptor from './interceptors/package.create-deployment.interceptor' +import { + CreatePackageDeploymentDto, + CreatePackageDto, + CreatePackageEnvironmentDto, + PackageDetailsDto, + PackageDto, + PackageEnvironmentDto, + UpdatePackageDto, + UpdatePackageEnvironmentDto, +} from './package.dto' +import PackageService from './package.service' + +const PARAM_PACKAGE_ID = 'packageId' +const PackageId = () => Param(PARAM_PACKAGE_ID) + +const PARAM_ENVIORNMENT_ID = 'environmentId' +const EnvironmentId = () => Param(PARAM_ENVIORNMENT_ID) + +const ROUTE_PACKAGES = 'packages' +const ROUTE_PACKAGE_ID = ':packageId' +const ROUTE_ENVIRONMENTS = 'environments' +const ROUTE_ENVIRONMENT_ID = ':environmentId' +const ROUTE_DEPLOYMENTS = 'deployments' + +const ROUTE_TEAM_SLUG = ':teamSlug' +const PARAM_TEAM_SLUG = 'teamSlug' +const TeamSlug = () => Param(PARAM_TEAM_SLUG) + +@Controller(`${ROUTE_TEAM_SLUG}/${ROUTE_PACKAGES}`) +@ApiTags(ROUTE_PACKAGES) +@UseGuards(PackageTeamAccessGuard) +class PackageHttpController { + constructor(private readonly service: PackageService) {} + + @Get() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + description: "Returns a list of a team's packages. `teamSlug` needs to be included in URL.", + summary: 'Fetch the packages list.', + }) + @ApiOkResponse({ + type: PackageDto, + isArray: true, + description: 'List of packages.', + }) + @ApiForbiddenResponse({ description: 'Unauthorized request for packages.' }) + async getPackages(@TeamSlug() teamSlug: string): Promise { + return this.service.getPackages(teamSlug) + } + + @Get(ROUTE_PACKAGE_ID) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + description: + "Returns a package's details. `teamSlug` and `packageId` needs to be included in URL. The response is consisting of the package's `name`, `id`, `description`, version chains and environment names.", + summary: 'Fetch details of a package.', + }) + @ApiOkResponse({ type: PackageDetailsDto, description: 'Details of a package.' }) + @ApiBadRequestResponse({ description: 'Bad request for package details.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for package details.' }) + @ApiNotFoundResponse({ description: 'Package not found.' }) + @UuidParams(PARAM_PACKAGE_ID) + async getPackageDetails(@TeamSlug() _: string, @PackageId() id: string): Promise { + return this.service.getPackageById(id) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + description: + 'Create a new package for a team. `teamSlug` needs to be included in URL. Newly created package has a `name`, `versionChains` as required variables, and optionally a `description`.', + summary: 'Create a new package for a team.', + }) + @CreatedWithLocation() + @ApiBody({ type: CreatePackageDto }) + @ApiCreatedResponse({ type: PackageDto, description: 'New package created.' }) + @ApiBadRequestResponse({ description: 'Bad request for package creation.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for package creation.' }) + @ApiConflictResponse({ description: 'Package name taken.' }) + @UseGuards(PackageVersionChainAccessGuard) + async createPackage( + @TeamSlug() teamSlug: string, + @Body() request: CreatePackageDto, + @IdentityFromRequest() identity: Identity, + ): Promise> { + const pack = await this.service.createPackage(teamSlug, request, identity) + + return { + url: PackageHttpController.packageUrlOf(teamSlug, pack.id), + body: pack, + } + } + + @Put(ROUTE_PACKAGE_ID) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + description: + 'Updates a package. `teamSlug` is required in URL, as well as `packageId` to identify which package is modified, `name`, `description` and `versionChains` can be adjusted with this call.', + summary: 'Update a package.', + }) + @ApiNoContentResponse({ description: 'Package details are modified.' }) + @ApiBadRequestResponse({ description: 'Bad request for package details modification.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for package details modification.' }) + @ApiNotFoundResponse({ description: 'Package not found.' }) + @ApiConflictResponse({ description: 'Package name taken.' }) + @UseGuards(PackageVersionChainAccessGuard) + @UuidParams(PARAM_PACKAGE_ID) + async updatePackage( + @TeamSlug() _: string, + @PackageId() id: string, + @Body() request: UpdatePackageDto, + @IdentityFromRequest() identity: Identity, + ): Promise { + await this.service.updatePackage(id, request, identity) + } + + @Delete(ROUTE_PACKAGE_ID) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + description: 'Deletes a package with the specified `packageId`. `teamSlug` and `packageId` are required in URL.', + summary: 'Delete a package.', + }) + @ApiNoContentResponse({ description: 'Package deleted.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for a package.' }) + @ApiNotFoundResponse({ description: 'Package not found.' }) + @UuidParams(PARAM_PACKAGE_ID) + async deleteProject(@TeamSlug() _: string, @PackageId() id: string): Promise { + await this.service.deletePackage(id) + } + + @Get(`${ROUTE_PACKAGE_ID}/${ROUTE_ENVIRONMENTS}/${ROUTE_ENVIRONMENT_ID}`) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + description: + "Returns a package environment's details. `teamSlug`, `packageId` and `environmentId` needs to be included in URL. The response is consisting of the package's `name`, `id`, `node`, `prefix` and the version chains.", + summary: 'Fetch details of a package environment.', + }) + @ApiOkResponse({ type: PackageDetailsDto, description: 'Details of a package environment.' }) + @ApiBadRequestResponse({ description: 'Bad request for package environment details.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for package environment details.' }) + @ApiNotFoundResponse({ description: 'Package environment not found.' }) + @UuidParams(PARAM_PACKAGE_ID) + async getPackageEnvironmentDetails( + @TeamSlug() _: string, + @PackageId() __: string, + @EnvironmentId() environmentId: string, + ): Promise { + return await this.service.getEnvironmentById(environmentId) + } + + @Post(`${ROUTE_PACKAGE_ID}/${ROUTE_ENVIRONMENTS}`) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + description: + 'Create a new package environment. `teamSlug`, `packageId` needs to be included in URL. Newly created environment has a `name`, `nodeId` and `prefix` as required variables.', + summary: 'Create a new package environment.', + }) + @CreatedWithLocation() + @ApiBody({ type: CreatePackageEnvironmentDto }) + @ApiCreatedResponse({ type: PackageEnvironmentDto, description: 'New package environment created.' }) + @ApiBadRequestResponse({ description: 'Bad request for package environment creation.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for package environment creation.' }) + @ApiConflictResponse({ description: 'Package environment name taken.' }) + @UseGuards(PackageNodeAccessGuard) + async createPackageEnvironment( + @TeamSlug() teamSlug: string, + @PackageId() packageId: string, + @Body() request: CreatePackageEnvironmentDto, + @IdentityFromRequest() identity: Identity, + ): Promise> { + const env = await this.service.createEnvironment(packageId, request, identity) + + return { + url: PackageHttpController.environmentUrlOf(teamSlug, packageId, env.id), + body: env, + } + } + + @Put(`${ROUTE_PACKAGE_ID}/${ROUTE_ENVIRONMENTS}/${ROUTE_ENVIRONMENT_ID}`) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + description: + 'Updates a package environment. `teamSlug` is required in URL, as well as `packageId` and `environmentId` to identify which environment is modified, `name`, `nodeId` and `prefix` can be adjusted with this call.', + summary: 'Update a package environment.', + }) + @ApiNoContentResponse({ description: 'Package environment is modified.' }) + @ApiBadRequestResponse({ description: 'Bad request for package environment modification.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for package environment modification.' }) + @ApiNotFoundResponse({ description: 'Package environment not found.' }) + @ApiConflictResponse({ description: 'Package environment name taken.' }) + @UseGuards(PackageNodeAccessGuard) + @UuidParams(PARAM_PACKAGE_ID, PARAM_ENVIORNMENT_ID) + async updatePackageEnvironment( + @TeamSlug() _: string, + @PackageId() packageId: string, + @EnvironmentId() environmentId: string, + @Body() request: UpdatePackageEnvironmentDto, + @IdentityFromRequest() identity: Identity, + ): Promise { + await this.service.updateEnviornment(packageId, environmentId, request, identity) + } + + @Delete(`${ROUTE_PACKAGE_ID}/${ROUTE_ENVIRONMENTS}/${ROUTE_ENVIRONMENT_ID}`) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + description: + 'Deletes a package environment with the specified `packageId` and `environmentId`. `teamSlug`, `packageId` and `environmentId` are required in URL.', + summary: 'Delete a package environment.', + }) + @ApiNoContentResponse({ description: 'Package environment deleted.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for a package environment.' }) + @ApiNotFoundResponse({ description: 'Package environment not found.' }) + @UuidParams(PARAM_PACKAGE_ID) + async deletePackageEnvironment( + @TeamSlug() _: string, + @PackageId() packageId: string, + @EnvironmentId() environmentId: string, + @IdentityFromRequest() identity: Identity, + ): Promise { + await this.service.deleteEnvironment(packageId, environmentId, identity) + } + + @Post(`${ROUTE_PACKAGE_ID}/${ROUTE_ENVIRONMENTS}/${ROUTE_ENVIRONMENT_ID}/${ROUTE_DEPLOYMENTS}`) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + description: + 'Create a deployment for the selected version. `teamSlug`, `packageId` and `environmentId` needs to be included in URL. Response should include deployment `id`, `prefix`, `status`, `note`, and `audit` log details, as well as the `project`, `version` and `node`.', + summary: 'Create a new deployment.', + }) + @CreatedWithLocation() + @ApiBody({ type: CreatePackageDto }) + @ApiCreatedResponse({ type: PackageDto, description: 'New deployment created.' }) + @ApiBadRequestResponse({ description: 'Bad request for deployment creation.' }) + @ApiForbiddenResponse({ description: 'Unauthorized request for deployment creation.' }) + @ApiConflictResponse({ + description: + 'A preparing deployment already exists on the same node with the same prefix for the selected version.', + }) + @UseGuards(PackageVersionAccessGuard) + @UseInterceptors(PackageCreateDeploymentInterceptor) + async createPackageDeployment( + @TeamSlug() teamSlug: string, + @PackageId() _: string, + @EnvironmentId() environmentId: string, + @Body() request: CreatePackageDeploymentDto, + @IdentityFromRequest() identity: Identity, + ): Promise> { + const deploy = await this.service.createPackageDeployment(environmentId, request, identity) + + return { + url: `${teamSlug}/${ROUTE_DEPLOYMENTS}/${deploy.id}`, + body: deploy, + } + } + + private static packageUrlOf(teamSlug: string, packageId: string) { + return `${teamSlug}/${ROUTE_PACKAGES}/${packageId}` + } + + private static environmentUrlOf(teamSlug: string, packageId: string, environmentId: string) { + return `${PackageHttpController.packageUrlOf(teamSlug, packageId)}/${ROUTE_ENVIRONMENTS}/${environmentId}` + } +} + +export default PackageHttpController diff --git a/web/crux/src/app/package/package.mapper.ts b/web/crux/src/app/package/package.mapper.ts new file mode 100644 index 000000000..86abd8e59 --- /dev/null +++ b/web/crux/src/app/package/package.mapper.ts @@ -0,0 +1,133 @@ +import { Injectable } from '@nestjs/common' +import { Deployment, Node, Package, PackageEnvironment, Project } from '@prisma/client' +import { VersionWithName } from 'src/domain/version' +import { VersionChainWithMembers, versionChainMembersOf } from 'src/domain/version-chain' +import DeployMapper from '../deploy/deploy.mapper' +import NodeMapper from '../node/node.mapper' +import ProjectMapper from '../project/project.mapper' +import VersionMapper from '../version/version.mapper' +import { PackageDetailsDto, PackageDto, PackageEnvironmentDetailsDto, PackageEnvironmentDto } from './package.dto' + +@Injectable() +class PackageMapper { + constructor( + private readonly projectMapper: ProjectMapper, + private readonly versionMapper: VersionMapper, + private readonly nodeMapper: NodeMapper, + private readonly deployMapper: DeployMapper, + ) {} + + toDto(pack: PackageWithChainsAndEnvironmentNames): PackageDto { + return { + id: pack.id, + name: pack.name, + description: pack.description, + icon: pack.icon, + environments: pack.environments.map(it => it.name), + versionChains: pack.chains.map(it => { + const chain = versionChainMembersOf(it.chain) + + return { + ...this.versionMapper.chainToDto(chain), + project: this.projectMapper.toBasicDto(it.chain.project), + } + }), + } + } + + detailsToDto(pack: PackageDetails): PackageDetailsDto { + return { + ...this.toDto(pack), + environments: pack.environments.map(it => this.environmentToDto(it)), + } + } + + environmentToDto(env: PackageEnvironmentWithNode): PackageEnvironmentDto { + return { + id: env.id, + name: env.name, + node: this.nodeMapper.toDto(env.node), + prefix: env.prefix, + } + } + + environmentDetailsToDto(env: PackageEnvironmentDetails): PackageEnvironmentDetailsDto { + const { package: pack } = env + + return { + id: env.id, + name: env.name, + package: { + id: pack.id, + name: pack.name, + }, + node: this.nodeMapper.toDto(env.node), + prefix: env.prefix, + versionChains: pack.chains.map(packageChain => { + const { chain } = packageChain + + return { + chainId: chain.id, + project: this.projectMapper.toDto(chain.project), + versions: chain.members.map(it => { + const deployment = it.deployments.at(0) + + return { + id: it.id, + name: it.name, + deployment: !deployment ? null : this.deployMapper.toBasicDto(deployment), + } + }), + } + }), + } + } +} + +type PackageEnvironmentWithNode = PackageEnvironment & { + node: Node +} + +type BasicVersionWithDeployments = VersionWithName & { + deployments: Deployment[] +} + +type VersionChainDetails = { + id: string + project: Project + members: BasicVersionWithDeployments[] +} + +type PackageEnvironmentVersionChain = { + chain: VersionChainDetails +} + +type PackageEnvironmentDetails = PackageEnvironmentWithNode & { + package: { + id: string + name: string + chains: PackageEnvironmentVersionChain[] + } +} + +type VersionChainWithMembersAndProject = VersionChainWithMembers & { + project: Project +} + +type PackageChain = { + chain: VersionChainWithMembersAndProject +} + +type PackageWithChainsAndEnvironmentNames = Package & { + environments: { + name: string + }[] + chains: PackageChain[] +} + +type PackageDetails = Package & { + environments: PackageEnvironmentWithNode[] + chains: PackageChain[] +} + +export default PackageMapper diff --git a/web/crux/src/app/package/package.module.ts b/web/crux/src/app/package/package.module.ts new file mode 100644 index 000000000..550dff83a --- /dev/null +++ b/web/crux/src/app/package/package.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import PrismaService from 'src/services/prisma.service' +import DeployModule from '../deploy/deploy.module' +import ImageModule from '../image/image.module' +import NodeModule from '../node/node.module' +import ProjectModule from '../project/project.module' +import TeamModule from '../team/team.module' +import TeamRepository from '../team/team.repository' +import VersionModule from '../version/version.module' +import PackageHttpController from './package.http.controller' +import PackageMapper from './package.mapper' +import PackageService from './package.service' + +@Module({ + imports: [ProjectModule, VersionModule, TeamModule, NodeModule, ImageModule, DeployModule], + exports: [], + controllers: [PackageHttpController], + providers: [PrismaService, PackageService, PackageMapper, TeamRepository], +}) +export default class PackageModule {} diff --git a/web/crux/src/app/package/package.service.ts b/web/crux/src/app/package/package.service.ts new file mode 100644 index 000000000..fa1bbf5f1 --- /dev/null +++ b/web/crux/src/app/package/package.service.ts @@ -0,0 +1,548 @@ +import { Injectable } from '@nestjs/common' +import { Identity } from '@ory/kratos-client' +import { DeploymentStatusEnum } from '@prisma/client' +import { VersionWithDeployments } from 'src/domain/version' +import { ImageWithConfig, copyDeployment } from 'src/domain/version-increase' +import PrismaService from 'src/services/prisma.service' +import { DeploymentDto } from '../deploy/deploy.dto' +import DeployMapper from '../deploy/deploy.mapper' +import ImageMapper from '../image/image.mapper' +import TeamRepository from '../team/team.repository' +import { + CreatePackageDeploymentDto, + CreatePackageDto, + CreatePackageEnvironmentDto, + PackageDetailsDto, + PackageDto, + PackageEnvironmentDetailsDto, + PackageEnvironmentDto, + UpdatePackageDto, + UpdatePackageEnvironmentDto, +} from './package.dto' +import PackageMapper from './package.mapper' + +@Injectable() +class PackageService { + constructor( + private readonly mapper: PackageMapper, + private readonly deployMapper: DeployMapper, + private readonly imageMapper: ImageMapper, + private readonly teamRepository: TeamRepository, + private readonly prisma: PrismaService, + ) {} + + async getPackages(teamSlug: string): Promise { + const teamId = await this.teamRepository.getTeamIdBySlug(teamSlug) + + const packages = await this.prisma.package.findMany({ + where: { + teamId, + }, + include: { + environments: { + select: { + name: true, + }, + }, + chains: PackageService.packageChainsQuery, + }, + }) + + return packages.map(it => this.mapper.toDto(it)) + } + + async getPackageById(id: string): Promise { + const pack = await this.prisma.package.findUniqueOrThrow({ + where: { + id, + }, + include: { + environments: { + include: { + node: true, + }, + }, + chains: PackageService.packageChainsQuery, + }, + }) + + return this.mapper.detailsToDto(pack) + } + + async createPackage(teamSlug: string, req: CreatePackageDto, identity: Identity): Promise { + const teamId = await this.teamRepository.getTeamIdBySlug(teamSlug) + + const pack = await this.prisma.package.create({ + data: { + name: req.name, + description: req.description, + icon: req.icon, + chains: { + createMany: { + data: req.chainIds.map(it => ({ + chainId: it, + })), + }, + }, + createdBy: identity.id, + teamId, + }, + include: { + environments: { + select: { + name: true, + }, + }, + chains: PackageService.packageChainsQuery, + }, + }) + + return this.mapper.toDto(pack) + } + + async updatePackage(id: string, req: UpdatePackageDto, identity: Identity): Promise { + await this.prisma.package.update({ + where: { + id, + }, + data: { + name: req.name, + description: req.description, + icon: req.icon, + chains: { + deleteMany: { + packageId: id, + chainId: { + notIn: req.chainIds, + }, + }, + connectOrCreate: req.chainIds.map(chainId => ({ + where: { + chainId_packageId: { + packageId: id, + chainId, + }, + }, + create: { + chainId, + }, + })), + }, + updatedBy: identity.id, + updatedAt: new Date(), + }, + }) + } + + async deletePackage(id: string): Promise { + await this.prisma.package.delete({ + where: { + id, + }, + }) + } + + async getEnvironmentById(environmentId: string): Promise { + const env = await this.prisma.packageEnvironment.findUniqueOrThrow({ + where: { + id: environmentId, + }, + include: { + node: true, + }, + }) + + const pack = await this.prisma.package.findUniqueOrThrow({ + where: { + id: env.packageId, + }, + select: { + id: true, + name: true, + chains: { + select: { + chain: { + include: { + project: true, + members: { + include: { + deployments: { + where: { + nodeId: env.nodeId, + prefix: env.prefix, + }, + orderBy: { + updatedAt: 'desc', + }, + take: 1, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + return this.mapper.environmentDetailsToDto({ + ...env, + package: pack, + }) + } + + async createEnvironment( + packageId: string, + req: CreatePackageEnvironmentDto, + identity: Identity, + ): Promise { + const env = await this.prisma.packageEnvironment.create({ + data: { + packageId, + name: req.name, + nodeId: req.nodeId, + prefix: req.prefix, + }, + include: { + node: true, + }, + }) + + await this.prisma.package.update({ + where: { + id: packageId, + }, + data: { + updatedAt: new Date(), + updatedBy: identity.id, + }, + }) + + return this.mapper.environmentToDto(env) + } + + async updateEnviornment( + packageId: string, + environmentId: string, + req: UpdatePackageEnvironmentDto, + identity: Identity, + ) { + await this.prisma.package.update({ + where: { + id: packageId, + }, + data: { + updatedAt: new Date(), + updatedBy: identity.id, + environments: { + update: { + where: { + id: environmentId, + }, + data: { + name: req.name, + nodeId: req.nodeId, + prefix: req.prefix, + }, + }, + }, + }, + }) + } + + async deleteEnvironment(packageId: string, environmentId: string, identity: Identity) { + await this.prisma.package.update({ + where: { + id: packageId, + }, + data: { + updatedAt: new Date(), + updatedBy: identity.id, + environments: { + delete: { + id: environmentId, + }, + }, + }, + }) + } + + async createPackageDeployment( + environmentId: string, + req: CreatePackageDeploymentDto, + identity: Identity, + ): Promise { + const target = await this.prisma.version.findUniqueOrThrow({ + where: { + id: req.versionId, + }, + include: { + images: { + select: { + id: true, + }, + }, + }, + }) + + const env = await this.prisma.packageEnvironment.findUniqueOrThrow({ + where: { + id: environmentId, + }, + }) + + const packageChain = await this.prisma.versionChainsOnPackage.findFirst({ + where: { + packageId: env.packageId, + chain: { + members: { + some: { + id: req.versionId, + }, + }, + }, + }, + select: { + chain: { + select: { + members: { + include: { + deployments: { + where: { + nodeId: env.nodeId, + prefix: env.prefix, + }, + }, + }, + }, + }, + }, + }, + }) + + const versions: VersionWithDeployments[] = packageChain.chain.members + + const sourceIndex = versions.findIndex(it => it.id === req.versionId) + let source = versions[sourceIndex] + + if (source.deployments.length < 1) { + // look for the nearest parent within deployments + + for (let i = sourceIndex - 1; i > -1; i--) { + const current = versions[i] + if (current.deployments.length > 0) { + source = current + break + } + } + } + + const sourceVersion = await this.prisma.version.findUniqueOrThrow({ + where: { + id: source.id, + }, + include: { + images: { + include: { + config: true, + }, + }, + deployments: { + where: { + AND: [ + { + nodeId: env.nodeId, + prefix: env.prefix, + }, + { + status: { + in: [ + DeploymentStatusEnum.successful, + DeploymentStatusEnum.failed, + DeploymentStatusEnum.preparing, + DeploymentStatusEnum.inProgress, + ], + }, + }, + ], + }, + include: { + instances: { + include: { + config: true, + }, + }, + }, + }, + }, + }) + + if (sourceVersion.deployments.length < 1) { + // create a new empty deployment + + const deployment = await this.prisma.deployment.create({ + data: { + nodeId: env.nodeId, + prefix: env.prefix, + versionId: target.id, + status: DeploymentStatusEnum.preparing, + createdBy: identity.id, + instances: { + createMany: { + data: sourceVersion.images.map(it => ({ + imageId: it.id, + })), + }, + }, + }, + include: { + node: true, + version: { + include: { + project: true, + }, + }, + }, + }) + + return this.deployMapper.toDto(deployment) + } + + // copy deployment from target + + const sourceDeployment = + sourceVersion.deployments.find(it => it.status === 'successful') ?? + sourceVersion.deployments.find(it => it.status === 'preparing') ?? + sourceVersion.deployments.find(it => it.status === 'failed') ?? + sourceVersion.deployments.at(0) + + const copiedDeployment = copyDeployment(sourceDeployment) + + const newDeployment = await this.prisma.deployment.create({ + data: { + ...copiedDeployment, + createdBy: identity.id, + versionId: target.id, + instances: undefined, + }, + include: { + node: true, + version: { + include: { + project: true, + }, + }, + }, + }) + + const originalImageIds = copiedDeployment.instances.map(it => it.originalImageId) + const originalImages = sourceVersion.images.filter(it => originalImageIds.includes(it.id)) + const originalImagesById = new Map(originalImages.map(it => [it.id, it])) + + const findCopiedInstance = (newImage: ImageWithConfig) => { + const matchingInstances = copiedDeployment.instances.filter(instance => { + const original = originalImagesById.get(instance.originalImageId) + if (!original) { + return false + } + + return newImage.registryId === original.registryId && newImage.name === original.name + }) + + if (matchingInstances.length < 1) { + return null + } + + if (matchingInstances.length === 1) { + return matchingInstances[0] + } + + // multiple candidates + + const matchingByContainerName = matchingInstances.find(instance => { + const original = originalImagesById.get(instance.originalImageId) + + return original.config.name === newImage.config.name || instance.config?.name === newImage.config.name + }) + + if (!matchingByContainerName) { + // no distinct instance found + return null + } + + return matchingByContainerName + } + + await Promise.all( + sourceVersion.images.map(async image => { + const instance = findCopiedInstance(image) + if (!instance) { + await this.prisma.instance.create({ + data: { + deploymentId: newDeployment.id, + imageId: image.id, + }, + }) + } + + delete instance.originalImageId + + await this.prisma.instance.create({ + data: { + ...instance, + deployment: { + connect: { + id: newDeployment.id, + }, + }, + image: { + connect: { + id: image.id, + }, + }, + config: !instance.config + ? undefined + : { + create: this.imageMapper.dbContainerConfigToCreateImageStatement({ + ...instance.config, + id: undefined, + instanceId: undefined, + }), + }, + }, + }) + }), + ) + + return this.deployMapper.toDto(newDeployment) + } + + private static packageChainsQuery = { + select: { + chain: { + select: { + id: true, + project: true, + members: { + select: { + id: true, + name: true, + parent: { + select: { + versionId: true, + }, + }, + _count: { + select: { + children: true, + }, + }, + }, + }, + }, + }, + }, + } +} + +export default PackageService diff --git a/web/crux/src/app/pipeline/guards/pipeline.auth.validation.guard.ts b/web/crux/src/app/pipeline/guards/pipeline.auth.validation.guard.ts index 79464cafa..6b2888259 100644 --- a/web/crux/src/app/pipeline/guards/pipeline.auth.validation.guard.ts +++ b/web/crux/src/app/pipeline/guards/pipeline.auth.validation.guard.ts @@ -31,7 +31,7 @@ export default class PipelineAccessValidationGuard implements CanActivate { if (pipelineId) { // update - const pipeline = await this.prisma.pipeline.findUnique({ + const pipeline = await this.prisma.pipeline.findUniqueOrThrow({ where: { id: pipelineId, }, diff --git a/web/crux/src/app/pipeline/pipeline.service.ts b/web/crux/src/app/pipeline/pipeline.service.ts index 36b4c6afc..e3486306c 100644 --- a/web/crux/src/app/pipeline/pipeline.service.ts +++ b/web/crux/src/app/pipeline/pipeline.service.ts @@ -153,7 +153,7 @@ export default class PipelineService { } async updatePipeline(teamSlug: string, id: string, req: UpdatePipelineDto, identity: Identity): Promise { - const oldPipeline = await this.prisma.pipeline.findUnique({ + const oldPipeline = await this.prisma.pipeline.findUniqueOrThrow({ where: { id, }, @@ -268,7 +268,7 @@ export default class PipelineService { } async trigger(id: string, req: TriggerPipelineDto, identity: Identity): Promise { - const pipeline = await this.prisma.pipeline.findUnique({ + const pipeline = await this.prisma.pipeline.findUniqueOrThrow({ where: { id, }, diff --git a/web/crux/src/app/project/project.http.controller.ts b/web/crux/src/app/project/project.http.controller.ts index f41e3e950..4de4753a4 100644 --- a/web/crux/src/app/project/project.http.controller.ts +++ b/web/crux/src/app/project/project.http.controller.ts @@ -68,7 +68,7 @@ export default class ProjectHttpController { @HttpCode(HttpStatus.OK) @ApiOperation({ description: - "Returns a project's details. `teamSlug` and `ProjectID` needs to be included in URL. The response should contain an array, consisting of the project's `name`, `id`, `type`, `description`, `deletability`, versions and version related data, including version `name` and `id`, `changelog`, increasibility.", + "Returns a project's details. `teamSlug` and `ProjectID` needs to be included in URL. The response is consisting of the project's `name`, `id`, `type`, `description`, `deletability`, versions and version related data, including version `name` and `id`, `changelog`, increasibility.", summary: 'Fetch details of a project.', }) @ApiOkResponse({ type: ProjectDetailsDto, description: 'Details of a project.' }) @@ -84,7 +84,7 @@ export default class ProjectHttpController { @HttpCode(HttpStatus.CREATED) @ApiOperation({ description: - 'Create a new project for a team. `teamSlug` needs to be included in URL. Newly created team has a `type` and a `name` as required variables, and optionally a `description` and a `changelog`.', + 'Create a new project for a team. `teamSlug` needs to be included in URL. Newly created project has a `type` and a `name` as required variables, and optionally a `description` and a `changelog`.', summary: 'Create a new project for a team.', }) @CreatedWithLocation() diff --git a/web/crux/src/app/project/project.service.ts b/web/crux/src/app/project/project.service.ts index 0cbded4b1..9591b4e01 100644 --- a/web/crux/src/app/project/project.service.ts +++ b/web/crux/src/app/project/project.service.ts @@ -94,7 +94,7 @@ export default class ProjectService { } async updateProject(id: string, req: UpdateProjectDto, identity: Identity): Promise { - const currentProject = await this.prisma.project.findUnique({ + const currentProject = await this.prisma.project.findUniqueOrThrow({ select: { type: true, }, diff --git a/web/crux/src/app/registry/registry.service.ts b/web/crux/src/app/registry/registry.service.ts index 53e0264cb..75562bdb1 100644 --- a/web/crux/src/app/registry/registry.service.ts +++ b/web/crux/src/app/registry/registry.service.ts @@ -251,7 +251,7 @@ export default class RegistryService { } async getRegistryConnectionInfoById(id: string): Promise { - const registry = await this.prisma.registry.findUnique({ + const registry = await this.prisma.registry.findUniqueOrThrow({ where: { id, }, diff --git a/web/crux/src/app/version/version-chains.http.controller.ts b/web/crux/src/app/version/version-chains.http.controller.ts new file mode 100644 index 000000000..d1e614603 --- /dev/null +++ b/web/crux/src/app/version/version-chains.http.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, HttpCode, HttpStatus, Param, UseGuards } from '@nestjs/common' +import { ApiForbiddenResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger' +import UuidParams from 'src/decorators/api-params.decorator' +import VersionTeamAccessGuard from './guards/version.team-access.guard' +import { VersionChainDto } from './version.dto' +import VersionService from './version.service' + +const PARAM_TEAM_SLUG = 'teamSlug' +const PARAM_PROJECT_ID = 'projectId' +const ProjectId = () => Param(PARAM_PROJECT_ID) +const TeamSlug = () => Param(PARAM_TEAM_SLUG) + +const ROUTE_TEAM_SLUG = ':teamSlug' +const ROUTE_PROJECTS = 'projects' +const ROUTE_PROJECT_ID = ':projectId' +const ROUTE_VERSION_CHAINS = 'version-chains' + +@Controller(`${ROUTE_TEAM_SLUG}/${ROUTE_PROJECTS}/${ROUTE_PROJECT_ID}/${ROUTE_VERSION_CHAINS}`) +@ApiTags(ROUTE_VERSION_CHAINS) +@UseGuards(VersionTeamAccessGuard) +export default class VersionChainHttpController { + constructor(private service: VersionService) {} + + @Get() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + description: + "Returns an array containing the every incremental version chain that belong to a project. `teamSlug` and `projectId` must be included in URL. The response includes the `earliest` and `latest` versions and the `chainId` which is equal to earliest version's id in the chain.", + summary: 'Fetch the list of all version chains under a project.', + }) + @ApiOkResponse({ + type: VersionChainDto, + isArray: true, + description: 'Returns an array with every version chain of a project.', + }) + @ApiForbiddenResponse({ description: 'Unauthorized request for project version chains.' }) + @UuidParams(PARAM_PROJECT_ID) + async getVersionChains(@TeamSlug() _: string, @ProjectId() projectId: string): Promise { + return await this.service.getVersionChainsByProject(projectId) + } +} diff --git a/web/crux/src/app/version/version.dto.ts b/web/crux/src/app/version/version.dto.ts index ee91c2c66..d2aed6915 100644 --- a/web/crux/src/app/version/version.dto.ts +++ b/web/crux/src/app/version/version.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' import { Type } from 'class-transformer' -import { IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator' +import { IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator' import { AuditDto } from '../audit/audit.dto' import { DeploymentWithBasicNodeDto } from '../deploy/deploy.dto' import { ImageDto } from '../image/image.dto' @@ -35,6 +35,17 @@ export class VersionDto extends BasicVersionDto { increasable: boolean } +export class VersionChainDto { + @IsUUID() + id: string + + @ValidateNested() + earliest: BasicVersionDto + + @ValidateNested() + latest: BasicVersionDto +} + export class UpdateVersionDto { @IsString() name: string diff --git a/web/crux/src/app/version/version.mapper.ts b/web/crux/src/app/version/version.mapper.ts index 2ad2c4712..1060134c6 100644 --- a/web/crux/src/app/version/version.mapper.ts +++ b/web/crux/src/app/version/version.mapper.ts @@ -2,13 +2,14 @@ import { Version } from '.prisma/client' import { Inject, Injectable, forwardRef } from '@nestjs/common' import { ProjectTypeEnum } from '@prisma/client' import { versionIsDeletable, versionIsIncreasable, versionIsMutable } from 'src/domain/version' +import { VersionChainWithEdges } from 'src/domain/version-chain' import { BasicProperties } from '../../shared/dtos/shared.dto' import AuditMapper from '../audit/audit.mapper' import { DeploymentWithNode } from '../deploy/deploy.dto' import DeployMapper from '../deploy/deploy.mapper' import ImageMapper, { ImageDetails } from '../image/image.mapper' import { NodeConnectionStatus } from '../node/node.dto' -import { BasicVersionDto, VersionDetailsDto, VersionDto } from './version.dto' +import { BasicVersionDto, VersionChainDto, VersionDetailsDto, VersionDto } from './version.dto' @Injectable() export default class VersionMapper { @@ -57,6 +58,20 @@ export default class VersionMapper { ), } } + + chainToDto(chain: VersionChainWithEdges): VersionChainDto { + return { + id: chain.id, + earliest: { + ...chain.earliest, + type: 'incremental', + }, + latest: { + ...chain.latest, + type: 'incremental', + }, + } + } } export type VersionWithChildren = Version & { diff --git a/web/crux/src/app/version/version.module.ts b/web/crux/src/app/version/version.module.ts index e3913e94c..3b1345852 100644 --- a/web/crux/src/app/version/version.module.ts +++ b/web/crux/src/app/version/version.module.ts @@ -11,6 +11,7 @@ import DeployModule from '../deploy/deploy.module' import EditorModule from '../editor/editor.module' import ImageModule from '../image/image.module' import TeamRepository from '../team/team.repository' +import VersionChainHttpController from './version-chains.http.controller' import VersionHttpController from './version.http.controller' import VersionMapper from './version.mapper' import VersionService from './version.service' @@ -19,7 +20,7 @@ import VersionWebSocketGateway from './version.ws.gateway' @Module({ imports: [ImageModule, HttpModule, DeployModule, AgentModule, EditorModule, AuditLoggerModule], exports: [VersionService, VersionMapper], - controllers: [VersionHttpController], + controllers: [VersionHttpController, VersionChainHttpController], providers: [ VersionService, VersionMapper, diff --git a/web/crux/src/app/version/version.service.ts b/web/crux/src/app/version/version.service.ts index f767d20c1..f5d32e63e 100644 --- a/web/crux/src/app/version/version.service.ts +++ b/web/crux/src/app/version/version.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common' import { Identity } from '@ory/kratos-client' import { DeploymentStatusEnum, Prisma } from '@prisma/client' import { VersionMessage } from 'src/domain/notification-templates' +import { versionChainMembersOf } from 'src/domain/version-chain' import { increaseIncrementalVersion } from 'src/domain/version-increase' import DomainNotificationService from 'src/services/domain.notification.service' import PrismaService from 'src/services/prisma.service' @@ -13,6 +14,7 @@ import { CreateVersionDto, IncreaseVersionDto, UpdateVersionDto, + VersionChainDto, VersionDetailsDto, VersionDto, VersionListQuery, @@ -97,6 +99,41 @@ export default class VersionService { return versions.map(it => this.mapper.toDto(it)) } + async getVersionChainsByProject(projectId: string): Promise { + // we have to select all members, until https://github.com/prisma/prisma/issues/8935 gets resolved + + const chains = await this.prisma.versionChain.findMany({ + where: { + projectId, + }, + select: { + id: true, + members: { + select: { + id: true, + name: true, + parent: { + select: { + versionId: true, + }, + }, + _count: { + select: { + children: true, + }, + }, + }, + }, + }, + }) + + return chains.map(it => { + const chain = versionChainMembersOf(it) + + return this.mapper.chainToDto(chain) + }) + } + async getVersionDetails(versionId: string): Promise { const version = await this.prisma.version.findUniqueOrThrow({ where: { @@ -190,6 +227,24 @@ export default class VersionService { }, }) + if (newVersion.type === 'incremental') { + await prisma.versionChain.create({ + data: { + id: newVersion.id, + project: { + connect: { + id: newVersion.projectId, + }, + }, + members: { + connect: { + id: newVersion.id, + }, + }, + }, + }) + } + if (defaultVersion) { const newImages = await Promise.all( defaultVersion.images.map(async image => { @@ -362,13 +417,13 @@ export default class VersionService { id: versionId, }, include: { + chain: true, images: { include: { config: true, }, }, deployments: { - distinct: ['nodeId', 'prefix'], where: { OR: [ { status: DeploymentStatusEnum.successful }, @@ -395,6 +450,11 @@ export default class VersionService { createdBy: identity.id, images: undefined, deployments: undefined, + chain: { + connect: { + id: parentVersion.chainId, + }, + }, project: { connect: { id: parentVersion.projectId, @@ -420,6 +480,17 @@ export default class VersionService { }, }) + if (!parentVersion.chain) { + // we need to create the chain + + await prisma.versionChain.create({ + data: { + id: version.id, + projectId: version.projectId, + }, + }) + } + // Create images const imageIdEntries: [string, string][] = await Promise.all( increased.images.map(async image => { diff --git a/web/crux/src/domain/version-chain.ts b/web/crux/src/domain/version-chain.ts new file mode 100644 index 000000000..b3896bc34 --- /dev/null +++ b/web/crux/src/domain/version-chain.ts @@ -0,0 +1,32 @@ +import { VersionChain } from '@prisma/client' +import { VersionWithName } from './version' + +export type VersionWithNameAndConnections = VersionWithName & { + parent?: { + versionId: string + } + _count: { + children: number + } +} + +export type VersionChainWithMembers = Pick & { + members: VersionWithNameAndConnections[] +} + +export type VersionChainWithEdges = { + id: string + earliest: VersionWithName + latest: VersionWithName +} + +export const versionChainMembersOf = (chain: VersionChainWithMembers): VersionChainWithEdges => { + const earliest = chain.members.find(it => !it.parent) + const latest = chain.members.find(it => it._count.children < 1) ?? earliest + + return { + id: chain.id, + earliest, + latest, + } +} diff --git a/web/crux/src/domain/version-increase.ts b/web/crux/src/domain/version-increase.ts index 370da66c1..5abe740ab 100644 --- a/web/crux/src/domain/version-increase.ts +++ b/web/crux/src/domain/version-increase.ts @@ -8,7 +8,7 @@ import { Version, } from '@prisma/client' -type ImageWithConfig = Image & { +export type ImageWithConfig = Image & { config: ContainerConfig } @@ -39,7 +39,7 @@ type CopiedDeploymentWithInstances = Omit & { +export type IncreasedVersion = Omit & { images: CopiedImageWithConfig[] deployments: CopiedDeploymentWithInstances[] } @@ -65,7 +65,7 @@ const copyInstance = (instance: InstanceWithConfig): CopiedInstanceWithConfig => return newInstance } -const copyDeployment = (deployment: DeploymentWithInstances): CopiedDeploymentWithInstances => { +export const copyDeployment = (deployment: DeploymentWithInstances): CopiedDeploymentWithInstances => { const newDeployment: CopiedDeploymentWithInstances = { note: deployment.note, prefix: deployment.prefix, diff --git a/web/crux/src/domain/version.ts b/web/crux/src/domain/version.ts index a5202e6ff..d02d6a443 100644 --- a/web/crux/src/domain/version.ts +++ b/web/crux/src/domain/version.ts @@ -1,7 +1,13 @@ -import { DeploymentStatusEnum, ProjectTypeEnum, VersionTypeEnum } from '@prisma/client' +import { Deployment, DeploymentStatusEnum, ProjectTypeEnum, Version, VersionTypeEnum } from '@prisma/client' import { CruxPreconditionFailedException } from 'src/exception/crux-exception' import { checkDeploymentMutability } from './deployment' +export type VersionWithName = Pick + +export type VersionWithDeployments = Version & { + deployments: Deployment[] +} + export type VersionIncreasabilityCheckDao = { type: VersionTypeEnum children: { versionId: string }[] diff --git a/web/crux/src/interceptors/prisma-error-interceptor.ts b/web/crux/src/interceptors/prisma-error-interceptor.ts index d5b71f5db..ff09645ab 100644 --- a/web/crux/src/interceptors/prisma-error-interceptor.ts +++ b/web/crux/src/interceptors/prisma-error-interceptor.ts @@ -112,5 +112,9 @@ export default class PrismaErrorInterceptor implements NestInterceptor { ConfigBundle: 'configBundle', ConfigBundleOnDeployments: 'configBundleOnDeployments', QualityAssuranceConfig: 'qualityAssuranceConfig', + Package: 'package', + PackageEnvironment: 'package', + VersionChain: 'version', + VersionChainsOnPackage: 'package', } } From 26915cfc49f3ad1e591d974dbba58f59dc5c85bc Mon Sep 17 00:00:00 2001 From: Levente Orban Date: Wed, 10 Jul 2024 11:06:05 +0200 Subject: [PATCH 12/14] fix(ci): add dev-deps to scopes in pr_title_validation.sh (#987) --- .github/workflows/pr_title_validation.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_title_validation.sh b/.github/workflows/pr_title_validation.sh index 99a407665..a7f9a3418 100755 --- a/.github/workflows/pr_title_validation.sh +++ b/.github/workflows/pr_title_validation.sh @@ -1,6 +1,6 @@ #!/bin/sh -filtered=$(echo $1 | grep -E '(cicd|chore|doc|feat|fix|hotfix|refactor|test|BREAKING CHANGE)(\((web|agent|ci|cli|deps|crux|crux-ui|kratos|crane|dagent)\))?: [\w\S]*') +filtered=$(echo $1 | grep -E '(cicd|chore|doc|feat|fix|hotfix|refactor|test|BREAKING CHANGE)(\((web|agent|ci|cli|deps|deps-dev|crux|crux-ui|kratos|crane|dagent)\))?: [\w\S]*') if [ -n "$filtered" ]; then echo "Title is valid." From 3e4f5d1e78094ae1f8aa776591f2b936c89faabf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:20:39 +0200 Subject: [PATCH 13/14] chore(deps-dev): bump braces from 3.0.2 to 3.0.3 in /web/crux-ui (#986) Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3. - [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3) --- updated-dependencies: - dependency-name: braces dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mate Vago --- web/crux-ui/package-lock.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/web/crux-ui/package-lock.json b/web/crux-ui/package-lock.json index 96757cf4e..00fc489ff 100644 --- a/web/crux-ui/package-lock.json +++ b/web/crux-ui/package-lock.json @@ -2949,12 +2949,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -4536,9 +4536,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -10760,12 +10760,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "browserslist": { @@ -11882,9 +11882,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" From 75b2d2e64acefbc659e6dff8ff3ae91c17e8b1c5 Mon Sep 17 00:00:00 2001 From: Levente Orban Date: Mon, 15 Jul 2024 11:07:01 +0200 Subject: [PATCH 14/14] release: 0.13.0 --- CHANGELOG.md | 62 +++++++++++++++++++++++++----- golang/internal/version/version.go | 2 +- web/crux-ui/package-lock.json | 4 +- web/crux-ui/package.json | 2 +- web/crux/package-lock.json | 4 +- web/crux/package.json | 2 +- web/crux/src/shared/const.ts | 2 +- 7 files changed, 61 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2528d0d..3965915cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ # CHANGELOG + +## [0.13.0](https://github.com/dyrector-io/dyrectorio/compare/0.12.0...0.13.0) (2024-07-10) + +### Ci + +* tag version only from release ([#975](https://github.com/dyrector-io/dyrectorio/issues/975)) + +### Doc + +* fix typo ([#984](https://github.com/dyrector-io/dyrectorio/issues/984)) + +### Feat + +* **(web):** packages ([#981](https://github.com/dyrector-io/dyrectorio/issues/981)) +* **(web):** add option to disable deployment copy on version increase ([#978](https://github.com/dyrector-io/dyrectorio/issues/978)) +* **(crux-ui):** reorganize the menu structure ([#977](https://github.com/dyrector-io/dyrectorio/issues/977)) +* **(crux):** get container list api ([#976](https://github.com/dyrector-io/dyrectorio/issues/976)) + +### Fix + +* **(ci):** add dev-deps to scopes in pr_title_validation.sh ([#987](https://github.com/dyrector-io/dyrectorio/issues/987)) +* **(crux-ui):** image ordering ([#979](https://github.com/dyrector-io/dyrectorio/issues/979)) +* **(crux-ui):** secret releated env keys ([#974](https://github.com/dyrector-io/dyrectorio/issues/974)) + + ## [0.12.0](https://github.com/dyrector-io/dyrectorio/compare/0.11.7...0.12.0) (2024-05-13) @@ -59,7 +84,11 @@ -## [0.11.5](https://github.com/dyrector-io/dyrectorio/compare/0.11.4...0.11.5) (2024-04-05) +## [0.11.5](https://github.com/dyrector-io/dyrectorio/compare/help...0.11.5) (2024-04-05) + + + +## [help](https://github.com/dyrector-io/dyrectorio/compare/0.11.4...help) (2024-04-05) ### Feat @@ -93,16 +122,11 @@ -## [0.11.3](https://github.com/dyrector-io/dyrectorio/compare/0.11.2...0.11.3) (2024-03-11) - -### Doc - -* **(crux):** add extra info encrpytion gey generation ([#926](https://github.com/dyrector-io/dyrectorio/issues/926)) +## [0.11.3](https://github.com/dyrector-io/dyrectorio/compare/ls...0.11.3) (2024-03-11) ### Feat * container log api ([#931](https://github.com/dyrector-io/dyrectorio/issues/931)) -* **(web):** update kratos to 1.1.0 ([#925](https://github.com/dyrector-io/dyrectorio/issues/925)) ### Fix @@ -110,6 +134,18 @@ * add ENCRYPTION_SECRET_KEY to compose files ([#927](https://github.com/dyrector-io/dyrectorio/issues/927)) + +## [ls](https://github.com/dyrector-io/dyrectorio/compare/0.11.2...ls) (2024-02-26) + +### Doc + +* **(crux):** add extra info encrpytion gey generation ([#926](https://github.com/dyrector-io/dyrectorio/issues/926)) + +### Feat + +* **(web):** update kratos to 1.1.0 ([#925](https://github.com/dyrector-io/dyrectorio/issues/925)) + + ## [0.11.2](https://github.com/dyrector-io/dyrectorio/compare/0.11.1...0.11.2) (2024-02-23) @@ -980,7 +1016,7 @@ -## [0.2.1](https://github.com/dyrector-io/dyrectorio/compare/0.1.1...0.2.1) (2022-09-27) +## [0.2.1](https://github.com/dyrector-io/dyrectorio/compare/v0.1.1...0.2.1) (2022-09-27) ### Chore @@ -1133,8 +1169,12 @@ * websocket message routing + +## [v0.1.1](https://github.com/dyrector-io/dyrectorio/compare/0.1.1...v0.1.1) (2022-08-03) + + -## [0.1.1](https://github.com/dyrector-io/dyrectorio/compare/0.1.0...0.1.1) (2022-08-03) +## [0.1.1](https://github.com/dyrector-io/dyrectorio/compare/v0.1.0...0.1.1) (2022-08-03) ### Chore @@ -1150,6 +1190,10 @@ * **(agent):** tests ([#54](https://github.com/dyrector-io/dyrectorio/issues/54)) + +## [v0.1.0](https://github.com/dyrector-io/dyrectorio/compare/0.1.0...v0.1.0) (2022-08-01) + + ## [0.1.0](https://github.com/dyrector-io/dyrectorio/compare/0.0.1...0.1.0) (2022-08-01) diff --git a/golang/internal/version/version.go b/golang/internal/version/version.go index 15415b493..99237fdc9 100644 --- a/golang/internal/version/version.go +++ b/golang/internal/version/version.go @@ -6,7 +6,7 @@ import ( var ( // Version represents the version of the application - Version = "0.12.0" + Version = "0.13.0" // CommitHash is the hash of the commit used for the build CommitHash = "n/a" // BuildTimestamp represents the timestamp when the build was created diff --git a/web/crux-ui/package-lock.json b/web/crux-ui/package-lock.json index 00fc489ff..876ce994a 100644 --- a/web/crux-ui/package-lock.json +++ b/web/crux-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "crux-ui", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "crux-ui", - "version": "0.12.0", + "version": "0.13.0", "license": "Apache-2.0", "dependencies": { "@ory/kratos-client": "^1.1.0", diff --git a/web/crux-ui/package.json b/web/crux-ui/package.json index c318232e2..fb1a29f40 100644 --- a/web/crux-ui/package.json +++ b/web/crux-ui/package.json @@ -1,6 +1,6 @@ { "name": "crux-ui", - "version": "0.12.0", + "version": "0.13.0", "description": "Open-source delivery platform that helps developers to deliver applications efficiently by simplifying software releases and operations in any environment.", "author": "dyrector.io", "private": true, diff --git a/web/crux/package-lock.json b/web/crux/package-lock.json index ca2344970..4a657ea53 100644 --- a/web/crux/package-lock.json +++ b/web/crux/package-lock.json @@ -1,12 +1,12 @@ { "name": "crux", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "crux", - "version": "0.12.0", + "version": "0.13.0", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.9.15", diff --git a/web/crux/package.json b/web/crux/package.json index 28b8e16bd..a2360fb5e 100644 --- a/web/crux/package.json +++ b/web/crux/package.json @@ -1,6 +1,6 @@ { "name": "crux", - "version": "0.12.0", + "version": "0.13.0", "description": "Open-source delivery platform that helps developers to deliver applications efficiently by simplifying software releases and operations in any environment.", "author": "dyrector.io", "private": true, diff --git a/web/crux/src/shared/const.ts b/web/crux/src/shared/const.ts index ded908a8e..4100b7d5d 100644 --- a/web/crux/src/shared/const.ts +++ b/web/crux/src/shared/const.ts @@ -21,7 +21,7 @@ export const AGENT_STREAM_TIMEOUT = 60_000 export const GET_CONTAINER_LOG_DEFAULT_TAKE = 100 // NOTE(@m8vago): This should be incremented, when a new release includes a proto file change -const AGENT_PROTO_COMPATIBILITY_MINIMUM_VERSION = '0.12.0' +const AGENT_PROTO_COMPATIBILITY_MINIMUM_VERSION = '0.13.0' export const AGENT_SUPPORTED_MINIMUM_VERSION = coerce(AGENT_PROTO_COMPATIBILITY_MINIMUM_VERSION) export const API_CREATED_LOCATION_HEADERS = {