diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index e0f840c73..6d77b82db 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1437,6 +1437,7 @@ export type Query = { plans: Array; search: Array; validatePasswordResetToken: ValidatePasswordResetToken; + versionInfo: VersionInfo; }; @@ -2176,6 +2177,12 @@ export type ValidatePasswordResetToken = { id: Scalars['String']; }; +export type VersionInfo = { + __typename?: 'VersionInfo'; + currentVersion?: Maybe; + latestVersion: Scalars['String']; +}; + export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']; @@ -2606,6 +2613,11 @@ export type GetEnvironmentVariablesGroupedQueryVariables = Exact<{ [key: string] export type GetEnvironmentVariablesGroupedQuery = { __typename?: 'Query', getEnvironmentVariablesGrouped: { __typename?: 'EnvironmentVariablesOutput', groups: Array<{ __typename?: 'EnvironmentVariablesGroupData', name: EnvironmentVariablesGroup, description: string, isHiddenOnLoad: boolean, variables: Array<{ __typename?: 'EnvironmentVariable', name: string, description: string, value: string, sensitive: boolean }> }> } }; +export type GetVersionInfoQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetVersionInfoQuery = { __typename?: 'Query', versionInfo: { __typename?: 'VersionInfo', currentVersion?: string | null, latestVersion: string } }; + export type GetIndicatorHealthStatusQueryVariables = Exact<{ indicatorId: HealthIndicatorId; }>; @@ -4555,6 +4567,41 @@ export function useGetEnvironmentVariablesGroupedLazyQuery(baseOptions?: Apollo. export type GetEnvironmentVariablesGroupedQueryHookResult = ReturnType; export type GetEnvironmentVariablesGroupedLazyQueryHookResult = ReturnType; export type GetEnvironmentVariablesGroupedQueryResult = Apollo.QueryResult; +export const GetVersionInfoDocument = gql` + query GetVersionInfo { + versionInfo { + currentVersion + latestVersion + } +} + `; + +/** + * __useGetVersionInfoQuery__ + * + * To run a query within a React component, call `useGetVersionInfoQuery` and pass it any options that fit your needs. + * When your component renders, `useGetVersionInfoQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetVersionInfoQuery({ + * variables: { + * }, + * }); + */ +export function useGetVersionInfoQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetVersionInfoDocument, options); + } +export function useGetVersionInfoLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetVersionInfoDocument, options); + } +export type GetVersionInfoQueryHookResult = ReturnType; +export type GetVersionInfoLazyQueryHookResult = ReturnType; +export type GetVersionInfoQueryResult = Apollo.QueryResult; export const GetIndicatorHealthStatusDocument = gql` query GetIndicatorHealthStatus($indicatorId: HealthIndicatorId!) { getIndicatorHealthStatus(indicatorId: $indicatorId) { diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminVersionContainer.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminVersionContainer.tsx index a85471870..aded804d0 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminVersionContainer.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminVersionContainer.tsx @@ -1,72 +1,34 @@ import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard'; -import { checkTwentyVersionExists } from '@/settings/admin-panel/utils/checkTwentyVersionExists'; -import { fetchLatestTwentyRelease } from '@/settings/admin-panel/utils/fetchLatestTwentyRelease'; -import styled from '@emotion/styled'; +import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay'; import { t } from '@lingui/core/macro'; -import { useEffect, useState } from 'react'; -import packageJson from '../../../../../package.json'; -import { GITHUB_LINK } from 'twenty-ui/navigation'; import { IconCircleDot, IconStatusChange } from 'twenty-ui/display'; - -const StyledActionLink = styled.a` - align-items: center; - color: ${({ theme }) => theme.font.color.primary}; - display: flex; - font-size: ${({ theme }) => theme.font.size.sm}; - font-weight: ${({ theme }) => theme.font.weight.regular}; - gap: ${({ theme }) => theme.spacing(1)}; - text-decoration: none; - - :hover { - color: ${({ theme }) => theme.font.color.primary}; - cursor: pointer; - } -`; - -const StyledSpan = styled.span` - color: ${({ theme }) => theme.font.color.primary}; - font-size: ${({ theme }) => theme.font.size.sm}; - font-weight: ${({ theme }) => theme.font.weight.regular}; -`; +import { useGetVersionInfoQuery } from '~/generated/graphql'; export const SettingsAdminVersionContainer = () => { - const [latestVersion, setLatestVersion] = useState(null); - const [currentVersionExists, setCurrentVersionExists] = useState(false); - - useEffect(() => { - fetchLatestTwentyRelease().then(setLatestVersion); - checkTwentyVersionExists(packageJson.version).then(setCurrentVersionExists); - }, []); + const { data, loading } = useGetVersionInfoQuery(); + const { currentVersion, latestVersion } = data?.versionInfo ?? {}; const versionItems = [ { Icon: IconCircleDot, label: t`Current version`, - value: currentVersionExists ? ( - - {packageJson.version} - - ) : ( - {packageJson.version} + value: ( + ), }, { Icon: IconStatusChange, label: t`Latest version`, - value: latestVersion ? ( - - {latestVersion} - - ) : ( - {latestVersion ?? 'Loading...'} + value: ( + ), }, ]; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminVersionDisplay.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminVersionDisplay.tsx new file mode 100644 index 000000000..076e074e7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminVersionDisplay.tsx @@ -0,0 +1,53 @@ +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; + +type SettingsAdminVersionDisplayProps = { + version: string | undefined | null; + loading: boolean; + noVersionMessage: string; +}; + +const StyledActionLink = styled.a` + align-items: center; + color: ${({ theme }) => theme.font.color.primary}; + display: flex; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + gap: ${({ theme }) => theme.spacing(1)}; + text-decoration: none; + + :hover { + color: ${({ theme }) => theme.font.color.primary}; + cursor: pointer; + } +`; + +const StyledSpan = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; +`; + +export const SettingsAdminVersionDisplay = ({ + version, + loading, + noVersionMessage, +}: SettingsAdminVersionDisplayProps) => { + if (loading) { + return {t`Loading...`}; + } + + if (!version) { + return {noVersionMessage}; + } + + return ( + + {version} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getVersionInfo.ts b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getVersionInfo.ts new file mode 100644 index 000000000..88de18842 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getVersionInfo.ts @@ -0,0 +1,10 @@ +import { gql } from '@apollo/client'; + +export const GET_VERSION_INFO = gql` + query GetVersionInfo { + versionInfo { + currentVersion + latestVersion + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/utils/checkTwentyVersionExists.ts b/packages/twenty-front/src/modules/settings/admin-panel/utils/checkTwentyVersionExists.ts deleted file mode 100644 index 196872ec1..000000000 --- a/packages/twenty-front/src/modules/settings/admin-panel/utils/checkTwentyVersionExists.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const checkTwentyVersionExists = async ( - version: string, -): Promise => { - try { - const response = await fetch( - `https://api.github.com/repos/twentyhq/twenty/releases/tags/v${version}`, - ); - return response.status === 200; - } catch (error) { - return false; - } -}; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/utils/fetchLatestTwentyRelease.ts b/packages/twenty-front/src/modules/settings/admin-panel/utils/fetchLatestTwentyRelease.ts deleted file mode 100644 index ec722601f..000000000 --- a/packages/twenty-front/src/modules/settings/admin-panel/utils/fetchLatestTwentyRelease.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const fetchLatestTwentyRelease = async (): Promise => { - try { - const response = await fetch( - 'https://api.github.com/repos/twentyhq/twenty/releases/latest', - ); - const data = await response.json(); - return data.tag_name.replace('v', ''); - } catch (error) { - return 'Could not fetch latest release'; - } -}; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts index 00a723cec..a47d09ad7 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts @@ -1,6 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import axios from 'axios'; + import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service'; import { AuthException, @@ -276,4 +278,102 @@ describe('AdminPanelService', () => { }); }); }); + + describe('getVersionInfo', () => { + const mockEnvironmentGet = jest.fn(); + const mockAxiosGet = jest.fn(); + + beforeEach(() => { + mockEnvironmentGet.mockReset(); + mockAxiosGet.mockReset(); + jest.spyOn(axios, 'get').mockImplementation(mockAxiosGet); + service['environmentService'].get = mockEnvironmentGet; + }); + + it('should return current and latest version when everything works', async () => { + mockEnvironmentGet.mockReturnValue('1.0.0'); + mockAxiosGet.mockResolvedValue({ + data: { + results: [ + { name: '2.0.0' }, + { name: '1.5.0' }, + { name: '1.0.0' }, + { name: 'latest' }, + ], + }, + }); + + const result = await service.getVersionInfo(); + + expect(result).toEqual({ + currentVersion: '1.0.0', + latestVersion: '2.0.0', + }); + }); + + it('should handle undefined APP_VERSION', async () => { + mockEnvironmentGet.mockReturnValue(undefined); + mockAxiosGet.mockResolvedValue({ + data: { + results: [{ name: '2.0.0' }, { name: 'latest' }], + }, + }); + + const result = await service.getVersionInfo(); + + expect(result).toEqual({ + currentVersion: undefined, + latestVersion: '2.0.0', + }); + }); + + it('should handle Docker Hub API error', async () => { + mockEnvironmentGet.mockReturnValue('1.0.0'); + mockAxiosGet.mockRejectedValue(new Error('API Error')); + + const result = await service.getVersionInfo(); + + expect(result).toEqual({ + currentVersion: '1.0.0', + latestVersion: 'latest', + }); + }); + + it('should handle empty Docker Hub tags', async () => { + mockEnvironmentGet.mockReturnValue('1.0.0'); + mockAxiosGet.mockResolvedValue({ + data: { + results: [], + }, + }); + + const result = await service.getVersionInfo(); + + expect(result).toEqual({ + currentVersion: '1.0.0', + latestVersion: 'latest', + }); + }); + + it('should handle invalid semver tags', async () => { + mockEnvironmentGet.mockReturnValue('1.0.0'); + mockAxiosGet.mockResolvedValue({ + data: { + results: [ + { name: '2.0.0' }, + { name: 'invalid-version' }, + { name: 'latest' }, + { name: '1.0.0' }, + ], + }, + }); + + const result = await service.getVersionInfo(); + + expect(result).toEqual({ + currentVersion: '1.0.0', + latestVersion: '2.0.0', + }); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts index fb527300c..42a5bfb5b 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -10,6 +10,7 @@ import { SystemHealth } from 'src/engine/core-modules/admin-panel/dtos/system-he import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input'; import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity'; import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input'; +import { VersionInfo } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto'; import { QueueMetricsTimeRange } from 'src/engine/core-modules/admin-panel/enums/queue-metrics-time-range.enum'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { FeatureFlagException } from 'src/engine/core-modules/feature-flag/feature-flag.exception'; @@ -114,4 +115,10 @@ export class AdminPanelResolver { timeRange, ); } + + @UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard) + @Query(() => VersionInfo) + async versionInfo(): Promise { + return this.adminService.getVersionInfo(); + } } diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts index fd375859a..110ea9a3b 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import axios from 'axios'; +import semver from 'semver'; import { Repository } from 'typeorm'; import { EnvironmentVariable } from 'src/engine/core-modules/admin-panel/dtos/environment-variable.dto'; import { EnvironmentVariablesGroupData } from 'src/engine/core-modules/admin-panel/dtos/environment-variables-group.dto'; import { EnvironmentVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/environment-variables.output'; import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity'; +import { VersionInfo } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto'; import { AuthException, AuthExceptionCode, @@ -163,4 +166,30 @@ export class AdminPanelService { return { groups }; } + + async getVersionInfo(): Promise { + const currentVersion = this.environmentService.get('APP_VERSION'); + + try { + const response = await axios.get( + 'https://hub.docker.com/v2/repositories/twentycrm/twenty/tags?page_size=100', + ); + + const versions = response.data.results + .filter((tag) => tag && tag.name !== 'latest') + .map((tag) => semver.coerce(tag.name)?.version) + .filter((version) => version !== undefined); + + if (versions.length === 0) { + return { currentVersion, latestVersion: 'latest' }; + } + + versions.sort((a, b) => semver.compare(b, a)); + const latestVersion = versions[0]; + + return { currentVersion, latestVersion }; + } catch (error) { + return { currentVersion, latestVersion: 'latest' }; + } + } } diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/version-info.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/version-info.dto.ts new file mode 100644 index 000000000..e616b12b5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/version-info.dto.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class VersionInfo { + @Field(() => String, { nullable: true }) + currentVersion?: string; + + @Field(() => String) + latestVersion: string; +} diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index 50a06be40..d7e7a88f5 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -11,4 +11,5 @@ export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountA export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords'; export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions'; export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl'; +export { TWENTY_DOCKER_HUB_LINK } from './TwentyDockerHubLink'; export { TWENTY_ICONS_BASE_URL } from './TwentyIconsBaseUrl';