Health monitor status for admin panel (#10186)
# Health Monitoring for Self-Hosted Instances
This PR implements basic health monitoring for self-hosted instances in
the admin panel.
## Service Status Checks
We're adding real-time health checks for:
- Redis Connection
- Database Connection
- Worker Status
- Message Sync Status
## Existing Functionality
We already have message sync and captcha counters that store aggregated
metrics in cache within a configurable time window (default: 5 minutes).
## New Endpoints
1. `/healthz` - Basic server health check for Kubernetes pod monitoring
2. `/healthz/{serviceName}` - Individual service health checks (returns
200 if healthy)
3. `/metricsz/{metricName}` - Time-windowed metrics (message sync,
captcha)
4. GraphQL resolver in admin panel for UI consumption
All endpoints use the same underlying service, with different
presentation layers for infrastructure and UI needs.
---------
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -39,7 +39,7 @@
|
|||||||
"@nestjs/passport": "^9.0.3",
|
"@nestjs/passport": "^9.0.3",
|
||||||
"@nestjs/platform-express": "^9.0.0",
|
"@nestjs/platform-express": "^9.0.0",
|
||||||
"@nestjs/serve-static": "^4.0.1",
|
"@nestjs/serve-static": "^4.0.1",
|
||||||
"@nestjs/terminus": "^9.2.2",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/typeorm": "^10.0.0",
|
"@nestjs/typeorm": "^10.0.0",
|
||||||
"@nx/eslint-plugin": "^17.2.8",
|
"@nx/eslint-plugin": "^17.2.8",
|
||||||
"@octokit/graphql": "^7.0.2",
|
"@octokit/graphql": "^7.0.2",
|
||||||
|
|||||||
@ -25,6 +25,33 @@ export type ActivateWorkspaceInput = {
|
|||||||
displayName?: InputMaybe<Scalars['String']>;
|
displayName?: InputMaybe<Scalars['String']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminPanelHealthServiceData = {
|
||||||
|
__typename?: 'AdminPanelHealthServiceData';
|
||||||
|
details?: Maybe<Scalars['String']>;
|
||||||
|
queues?: Maybe<Array<AdminPanelWorkerQueueHealth>>;
|
||||||
|
status: AdminPanelHealthServiceStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum AdminPanelHealthServiceStatus {
|
||||||
|
OPERATIONAL = 'OPERATIONAL',
|
||||||
|
OUTAGE = 'OUTAGE'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AdminPanelIndicatorHealthStatusInputEnum {
|
||||||
|
DATABASE = 'DATABASE',
|
||||||
|
MESSAGE_SYNC = 'MESSAGE_SYNC',
|
||||||
|
REDIS = 'REDIS',
|
||||||
|
WORKER = 'WORKER'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminPanelWorkerQueueHealth = {
|
||||||
|
__typename?: 'AdminPanelWorkerQueueHealth';
|
||||||
|
metrics: WorkerQueueMetrics;
|
||||||
|
name: Scalars['String'];
|
||||||
|
status: AdminPanelHealthServiceStatus;
|
||||||
|
workers: Scalars['Float'];
|
||||||
|
};
|
||||||
|
|
||||||
export type Analytics = {
|
export type Analytics = {
|
||||||
__typename?: 'Analytics';
|
__typename?: 'Analytics';
|
||||||
/** Boolean that confirms query was dispatched */
|
/** Boolean that confirms query was dispatched */
|
||||||
@ -1232,11 +1259,13 @@ export type Query = {
|
|||||||
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
||||||
getAvailablePackages: Scalars['JSON'];
|
getAvailablePackages: Scalars['JSON'];
|
||||||
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
|
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
|
||||||
|
getIndicatorHealthStatus: AdminPanelHealthServiceData;
|
||||||
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
||||||
getProductPrices: BillingProductPricesOutput;
|
getProductPrices: BillingProductPricesOutput;
|
||||||
getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput;
|
getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput;
|
||||||
getRoles: Array<Role>;
|
getRoles: Array<Role>;
|
||||||
getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']>;
|
getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']>;
|
||||||
|
getSystemHealthStatus: SystemHealth;
|
||||||
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
|
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
|
||||||
getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal;
|
getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal;
|
||||||
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
|
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
|
||||||
@ -1287,6 +1316,11 @@ export type QueryGetAvailablePackagesArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryGetIndicatorHealthStatusArgs = {
|
||||||
|
indicatorName: AdminPanelIndicatorHealthStatusInputEnum;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryGetProductPricesArgs = {
|
export type QueryGetProductPricesArgs = {
|
||||||
product: Scalars['String'];
|
product: Scalars['String'];
|
||||||
};
|
};
|
||||||
@ -1605,6 +1639,14 @@ export type Support = {
|
|||||||
supportFrontChatId?: Maybe<Scalars['String']>;
|
supportFrontChatId?: Maybe<Scalars['String']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SystemHealth = {
|
||||||
|
__typename?: 'SystemHealth';
|
||||||
|
database: AdminPanelHealthServiceData;
|
||||||
|
messageSync: AdminPanelHealthServiceData;
|
||||||
|
redis: AdminPanelHealthServiceData;
|
||||||
|
worker: AdminPanelHealthServiceData;
|
||||||
|
};
|
||||||
|
|
||||||
export type TimelineCalendarEvent = {
|
export type TimelineCalendarEvent = {
|
||||||
__typename?: 'TimelineCalendarEvent';
|
__typename?: 'TimelineCalendarEvent';
|
||||||
conferenceLink: LinksMetadata;
|
conferenceLink: LinksMetadata;
|
||||||
@ -1854,6 +1896,16 @@ export type ValidatePasswordResetToken = {
|
|||||||
id: Scalars['String'];
|
id: Scalars['String'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkerQueueMetrics = {
|
||||||
|
__typename?: 'WorkerQueueMetrics';
|
||||||
|
active: Scalars['Float'];
|
||||||
|
completed: Scalars['Float'];
|
||||||
|
delayed: Scalars['Float'];
|
||||||
|
failed: Scalars['Float'];
|
||||||
|
prioritized: Scalars['Float'];
|
||||||
|
waiting: Scalars['Float'];
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowAction = {
|
export type WorkflowAction = {
|
||||||
__typename?: 'WorkflowAction';
|
__typename?: 'WorkflowAction';
|
||||||
id: Scalars['UUID'];
|
id: Scalars['UUID'];
|
||||||
@ -2250,6 +2302,18 @@ 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 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 GetIndicatorHealthStatusQueryVariables = Exact<{
|
||||||
|
indicatorName: AdminPanelIndicatorHealthStatusInputEnum;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type GetIndicatorHealthStatusQuery = { __typename?: 'Query', getIndicatorHealthStatus: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, details?: string | null, queues?: Array<{ __typename?: 'AdminPanelWorkerQueueHealth', name: string, status: AdminPanelHealthServiceStatus, workers: number, metrics: { __typename?: 'WorkerQueueMetrics', failed: number, completed: number, waiting: number, active: number, delayed: number, prioritized: number } }> | null } };
|
||||||
|
|
||||||
|
export type GetSystemHealthStatusQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
export type GetSystemHealthStatusQuery = { __typename?: 'Query', getSystemHealthStatus: { __typename?: 'SystemHealth', database: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, details?: string | null }, redis: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, details?: string | null }, worker: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, queues?: Array<{ __typename?: 'AdminPanelWorkerQueueHealth', name: string, workers: number, status: AdminPanelHealthServiceStatus, metrics: { __typename?: 'WorkerQueueMetrics', failed: number, completed: number, waiting: number, active: number, delayed: number, prioritized: number } }> | null }, messageSync: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, details?: string | null } } };
|
||||||
|
|
||||||
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
|
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
|
||||||
input: UpdateLabPublicFeatureFlagInput;
|
input: UpdateLabPublicFeatureFlagInput;
|
||||||
}>;
|
}>;
|
||||||
@ -3960,6 +4024,116 @@ export function useGetEnvironmentVariablesGroupedLazyQuery(baseOptions?: Apollo.
|
|||||||
export type GetEnvironmentVariablesGroupedQueryHookResult = ReturnType<typeof useGetEnvironmentVariablesGroupedQuery>;
|
export type GetEnvironmentVariablesGroupedQueryHookResult = ReturnType<typeof useGetEnvironmentVariablesGroupedQuery>;
|
||||||
export type GetEnvironmentVariablesGroupedLazyQueryHookResult = ReturnType<typeof useGetEnvironmentVariablesGroupedLazyQuery>;
|
export type GetEnvironmentVariablesGroupedLazyQueryHookResult = ReturnType<typeof useGetEnvironmentVariablesGroupedLazyQuery>;
|
||||||
export type GetEnvironmentVariablesGroupedQueryResult = Apollo.QueryResult<GetEnvironmentVariablesGroupedQuery, GetEnvironmentVariablesGroupedQueryVariables>;
|
export type GetEnvironmentVariablesGroupedQueryResult = Apollo.QueryResult<GetEnvironmentVariablesGroupedQuery, GetEnvironmentVariablesGroupedQueryVariables>;
|
||||||
|
export const GetIndicatorHealthStatusDocument = gql`
|
||||||
|
query GetIndicatorHealthStatus($indicatorName: AdminPanelIndicatorHealthStatusInputEnum!) {
|
||||||
|
getIndicatorHealthStatus(indicatorName: $indicatorName) {
|
||||||
|
status
|
||||||
|
details
|
||||||
|
queues {
|
||||||
|
name
|
||||||
|
status
|
||||||
|
workers
|
||||||
|
metrics {
|
||||||
|
failed
|
||||||
|
completed
|
||||||
|
waiting
|
||||||
|
active
|
||||||
|
delayed
|
||||||
|
prioritized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useGetIndicatorHealthStatusQuery__
|
||||||
|
*
|
||||||
|
* To run a query within a React component, call `useGetIndicatorHealthStatusQuery` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useGetIndicatorHealthStatusQuery` 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 } = useGetIndicatorHealthStatusQuery({
|
||||||
|
* variables: {
|
||||||
|
* indicatorName: // value for 'indicatorName'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useGetIndicatorHealthStatusQuery(baseOptions: Apollo.QueryHookOptions<GetIndicatorHealthStatusQuery, GetIndicatorHealthStatusQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useQuery<GetIndicatorHealthStatusQuery, GetIndicatorHealthStatusQueryVariables>(GetIndicatorHealthStatusDocument, options);
|
||||||
|
}
|
||||||
|
export function useGetIndicatorHealthStatusLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetIndicatorHealthStatusQuery, GetIndicatorHealthStatusQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useLazyQuery<GetIndicatorHealthStatusQuery, GetIndicatorHealthStatusQueryVariables>(GetIndicatorHealthStatusDocument, options);
|
||||||
|
}
|
||||||
|
export type GetIndicatorHealthStatusQueryHookResult = ReturnType<typeof useGetIndicatorHealthStatusQuery>;
|
||||||
|
export type GetIndicatorHealthStatusLazyQueryHookResult = ReturnType<typeof useGetIndicatorHealthStatusLazyQuery>;
|
||||||
|
export type GetIndicatorHealthStatusQueryResult = Apollo.QueryResult<GetIndicatorHealthStatusQuery, GetIndicatorHealthStatusQueryVariables>;
|
||||||
|
export const GetSystemHealthStatusDocument = gql`
|
||||||
|
query GetSystemHealthStatus {
|
||||||
|
getSystemHealthStatus {
|
||||||
|
database {
|
||||||
|
status
|
||||||
|
details
|
||||||
|
}
|
||||||
|
redis {
|
||||||
|
status
|
||||||
|
details
|
||||||
|
}
|
||||||
|
worker {
|
||||||
|
status
|
||||||
|
queues {
|
||||||
|
name
|
||||||
|
workers
|
||||||
|
status
|
||||||
|
metrics {
|
||||||
|
failed
|
||||||
|
completed
|
||||||
|
waiting
|
||||||
|
active
|
||||||
|
delayed
|
||||||
|
prioritized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageSync {
|
||||||
|
status
|
||||||
|
details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useGetSystemHealthStatusQuery__
|
||||||
|
*
|
||||||
|
* To run a query within a React component, call `useGetSystemHealthStatusQuery` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useGetSystemHealthStatusQuery` 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 } = useGetSystemHealthStatusQuery({
|
||||||
|
* variables: {
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useGetSystemHealthStatusQuery(baseOptions?: Apollo.QueryHookOptions<GetSystemHealthStatusQuery, GetSystemHealthStatusQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useQuery<GetSystemHealthStatusQuery, GetSystemHealthStatusQueryVariables>(GetSystemHealthStatusDocument, options);
|
||||||
|
}
|
||||||
|
export function useGetSystemHealthStatusLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetSystemHealthStatusQuery, GetSystemHealthStatusQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useLazyQuery<GetSystemHealthStatusQuery, GetSystemHealthStatusQueryVariables>(GetSystemHealthStatusDocument, options);
|
||||||
|
}
|
||||||
|
export type GetSystemHealthStatusQueryHookResult = ReturnType<typeof useGetSystemHealthStatusQuery>;
|
||||||
|
export type GetSystemHealthStatusLazyQueryHookResult = ReturnType<typeof useGetSystemHealthStatusLazyQuery>;
|
||||||
|
export type GetSystemHealthStatusQueryResult = Apollo.QueryResult<GetSystemHealthStatusQuery, GetSystemHealthStatusQueryVariables>;
|
||||||
export const UpdateLabPublicFeatureFlagDocument = gql`
|
export const UpdateLabPublicFeatureFlagDocument = gql`
|
||||||
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
|
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
|
||||||
updateLabPublicFeatureFlag(input: $input) {
|
updateLabPublicFeatureFlag(input: $input) {
|
||||||
|
|||||||
@ -248,6 +248,14 @@ const SettingsAdminContent = lazy(() =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const SettingsAdminIndicatorHealthStatus = lazy(() =>
|
||||||
|
import(
|
||||||
|
'~/pages/settings/admin-panel/SettingsAdminIndicatorHealthStatus'
|
||||||
|
).then((module) => ({
|
||||||
|
default: module.SettingsAdminIndicatorHealthStatus,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const SettingsLab = lazy(() =>
|
const SettingsLab = lazy(() =>
|
||||||
import('~/pages/settings/lab/SettingsLab').then((module) => ({
|
import('~/pages/settings/lab/SettingsLab').then((module) => ({
|
||||||
default: module.SettingsLab,
|
default: module.SettingsLab,
|
||||||
@ -407,6 +415,10 @@ export const SettingsRoutes = ({
|
|||||||
path={SettingsPath.FeatureFlags}
|
path={SettingsPath.FeatureFlags}
|
||||||
element={<SettingsAdminContent />}
|
element={<SettingsAdminContent />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path={SettingsPath.AdminPanelIndicatorHealthStatus}
|
||||||
|
element={<SettingsAdminIndicatorHealthStatus />}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Route path={SettingsPath.Lab} element={<SettingsLab />} />
|
<Route path={SettingsPath.Lab} element={<SettingsLab />} />
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAd
|
|||||||
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
|
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
|
||||||
import { TabList } from '@/ui/layout/tab/components/TabList';
|
import { TabList } from '@/ui/layout/tab/components/TabList';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { IconSettings2, IconVariable } from 'twenty-ui';
|
import { IconHeart, IconSettings2, IconVariable } from 'twenty-ui';
|
||||||
|
|
||||||
const StyledTabListContainer = styled.div`
|
const StyledTabListContainer = styled.div`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -25,6 +25,11 @@ export const SettingsAdminContent = () => {
|
|||||||
title: 'Env Variables',
|
title: 'Env Variables',
|
||||||
Icon: IconVariable,
|
Icon: IconVariable,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: SETTINGS_ADMIN_TABS.HEALTH_STATUS,
|
||||||
|
title: 'Health Status',
|
||||||
|
Icon: IconHeart,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
|
|||||||
import { getImageAbsoluteURI, isDefined } from 'twenty-shared';
|
import { getImageAbsoluteURI, isDefined } from 'twenty-shared';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
GithubVersionLink,
|
||||||
H1Title,
|
H1Title,
|
||||||
H1TitleFontColor,
|
H1TitleFontColor,
|
||||||
H2Title,
|
H2Title,
|
||||||
@ -24,6 +25,8 @@ import {
|
|||||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||||
import { useUserLookupAdminPanelMutation } from '~/generated/graphql';
|
import { useUserLookupAdminPanelMutation } from '~/generated/graphql';
|
||||||
|
|
||||||
|
import packageJson from '../../../../../package.json';
|
||||||
|
|
||||||
const StyledLinkContainer = styled.div`
|
const StyledLinkContainer = styled.div`
|
||||||
margin-right: ${({ theme }) => theme.spacing(2)};
|
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -120,6 +123,11 @@ export const SettingsAdminGeneral = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Section>
|
||||||
|
<H2Title title="About" description="Version of the application" />
|
||||||
|
<GithubVersionLink version={packageJson.version} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<H2Title
|
<H2Title
|
||||||
title={
|
title={
|
||||||
@ -176,6 +184,7 @@ export const SettingsAdminGeneral = () => {
|
|||||||
behaveAsLinks={false}
|
behaveAsLinks={false}
|
||||||
/>
|
/>
|
||||||
</StyledTabListContainer>
|
</StyledTabListContainer>
|
||||||
|
|
||||||
<StyledContentContainer>
|
<StyledContentContainer>
|
||||||
<SettingsAdminWorkspaceContent activeWorkspace={activeWorkspace} />
|
<SettingsAdminWorkspaceContent activeWorkspace={activeWorkspace} />
|
||||||
</StyledContentContainer>
|
</StyledContentContainer>
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { Table } from '@/ui/layout/table/components/Table';
|
||||||
|
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||||
|
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||||
|
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||||
|
|
||||||
|
export const SettingsAdminHealthMessageSyncCountersTable = ({
|
||||||
|
details,
|
||||||
|
}: {
|
||||||
|
details: string | null | undefined;
|
||||||
|
}) => {
|
||||||
|
const parsedDetails = details ? JSON.parse(details) : null;
|
||||||
|
if (!parsedDetails) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>Status</TableHeader>
|
||||||
|
<TableHeader align="right">Count</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Message Not Synced</TableCell>
|
||||||
|
<TableCell align="right">{parsedDetails.counters.NOT_SYNCED}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Message Sync Ongoing</TableCell>
|
||||||
|
<TableCell align="right">{parsedDetails.counters.ONGOING}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Total Jobs</TableCell>
|
||||||
|
<TableCell align="right">{parsedDetails.totalJobs}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Failed Jobs</TableCell>
|
||||||
|
<TableCell align="right">{parsedDetails.failedJobs}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Failure Rate</TableCell>
|
||||||
|
<TableCell align="right">{parsedDetails.failureRate}%</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import { SettingsAdminHealthMessageSyncCountersTable } from '@/settings/admin-panel/components/SettingsAdminHealthMessageSyncCountersTable';
|
||||||
|
import { SettingsHealthStatusListCard } from '@/settings/admin-panel/components/SettingsHealthStatusListCard';
|
||||||
|
import { AdminHealthService } from '@/settings/admin-panel/types/AdminHealthService';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { H2Title, Section } from 'twenty-ui';
|
||||||
|
import {
|
||||||
|
AdminPanelHealthServiceStatus,
|
||||||
|
useGetSystemHealthStatusQuery,
|
||||||
|
} from '~/generated/graphql';
|
||||||
|
|
||||||
|
const StyledErrorMessage = styled.div`
|
||||||
|
color: ${({ theme }) => theme.color.red};
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SettingsAdminHealthStatus = () => {
|
||||||
|
const { data, loading } = useGetSystemHealthStatusQuery({
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
{
|
||||||
|
id: 'DATABASE',
|
||||||
|
name: 'Database Status',
|
||||||
|
...data?.getSystemHealthStatus.database,
|
||||||
|
},
|
||||||
|
{ id: 'REDIS', name: 'Redis Status', ...data?.getSystemHealthStatus.redis },
|
||||||
|
{
|
||||||
|
id: 'WORKER',
|
||||||
|
name: 'Worker Status',
|
||||||
|
status: data?.getSystemHealthStatus.worker.status,
|
||||||
|
queues: data?.getSystemHealthStatus.worker.queues,
|
||||||
|
},
|
||||||
|
].filter((service): service is AdminHealthService => !!service.status);
|
||||||
|
|
||||||
|
const isMessageSyncCounterDown =
|
||||||
|
!data?.getSystemHealthStatus.messageSync.status ||
|
||||||
|
data?.getSystemHealthStatus.messageSync.status ===
|
||||||
|
AdminPanelHealthServiceStatus.OUTAGE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<H2Title title="Health Status" description="How your system is doing" />
|
||||||
|
<SettingsHealthStatusListCard services={services} loading={loading} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title="Message Sync Status"
|
||||||
|
description="How your message sync is doing"
|
||||||
|
/>
|
||||||
|
{isMessageSyncCounterDown ? (
|
||||||
|
<StyledErrorMessage>
|
||||||
|
{data?.getSystemHealthStatus.messageSync.details ||
|
||||||
|
'Message sync status is unavailable'}
|
||||||
|
</StyledErrorMessage>
|
||||||
|
) : (
|
||||||
|
<SettingsAdminHealthMessageSyncCountersTable
|
||||||
|
details={data?.getSystemHealthStatus.messageSync.details}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { AdminHealthService } from '@/settings/admin-panel/types/AdminHealthService';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Status } from 'twenty-ui';
|
||||||
|
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
|
||||||
|
|
||||||
|
const StyledRowRightContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: ${({ theme }) => theme.spacing(1)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SettingsAdminHealthStatusRightContainer = ({
|
||||||
|
service,
|
||||||
|
}: {
|
||||||
|
service: AdminHealthService;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<StyledRowRightContainer>
|
||||||
|
{service.status === AdminPanelHealthServiceStatus.OPERATIONAL && (
|
||||||
|
<Status color="green" text="Operational" weight="medium" />
|
||||||
|
)}
|
||||||
|
{service.status === AdminPanelHealthServiceStatus.OUTAGE && (
|
||||||
|
<Status color="red" text="Outage" weight="medium" />
|
||||||
|
)}
|
||||||
|
</StyledRowRightContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
import { SettingsListCard } from '@/settings/components/SettingsListCard';
|
||||||
|
import { Table } from '@/ui/layout/table/components/Table';
|
||||||
|
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||||
|
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { AnimatedExpandableContainer, Status } from 'twenty-ui';
|
||||||
|
import {
|
||||||
|
AdminPanelHealthServiceStatus,
|
||||||
|
AdminPanelWorkerQueueHealth,
|
||||||
|
} from '~/generated/graphql';
|
||||||
|
|
||||||
|
const StyledExpandedContent = styled.div`
|
||||||
|
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
|
padding-top: ${({ theme }) => theme.spacing(1)};
|
||||||
|
padding-bottom: ${({ theme }) => theme.spacing(3)};
|
||||||
|
padding-left: ${({ theme }) => theme.spacing(3)};
|
||||||
|
padding-right: ${({ theme }) => theme.spacing(3)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledContainer = styled.div`
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(5)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledTableRow = styled(TableRow)`
|
||||||
|
height: ${({ theme }) => theme.spacing(6)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledQueueMetricsTitle = styled.div`
|
||||||
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
|
font-size: ${({ theme }) => theme.font.size.sm};
|
||||||
|
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
||||||
|
padding-left: ${({ theme }) => theme.spacing(3)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SettingsAdminQueueExpandableContainer = ({
|
||||||
|
queues,
|
||||||
|
selectedQueue,
|
||||||
|
}: {
|
||||||
|
queues: AdminPanelWorkerQueueHealth[];
|
||||||
|
selectedQueue: string | null;
|
||||||
|
}) => {
|
||||||
|
const selectedQueueData = queues.find(
|
||||||
|
(queue) => queue.name === selectedQueue,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedExpandableContainer
|
||||||
|
isExpanded={!!selectedQueue}
|
||||||
|
mode="fit-content"
|
||||||
|
>
|
||||||
|
{selectedQueueData && (
|
||||||
|
<>
|
||||||
|
<StyledContainer>
|
||||||
|
<SettingsListCard
|
||||||
|
items={[{ ...selectedQueueData, id: selectedQueueData.name }]}
|
||||||
|
getItemLabel={(
|
||||||
|
item: AdminPanelWorkerQueueHealth & { id: string },
|
||||||
|
) => item.name}
|
||||||
|
isLoading={false}
|
||||||
|
RowRightComponent={({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: AdminPanelWorkerQueueHealth;
|
||||||
|
}) => (
|
||||||
|
<Status
|
||||||
|
color={
|
||||||
|
item.status === AdminPanelHealthServiceStatus.OPERATIONAL
|
||||||
|
? 'green'
|
||||||
|
: 'red'
|
||||||
|
}
|
||||||
|
text={item.status.toLowerCase()}
|
||||||
|
weight="medium"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</StyledContainer>
|
||||||
|
<StyledQueueMetricsTitle> Metrics:</StyledQueueMetricsTitle>
|
||||||
|
<StyledExpandedContent>
|
||||||
|
<Table>
|
||||||
|
<StyledTableRow>
|
||||||
|
<TableCell align="left">Workers</TableCell>
|
||||||
|
<TableCell align="right">{selectedQueueData.workers}</TableCell>
|
||||||
|
</StyledTableRow>
|
||||||
|
{Object.entries(selectedQueueData.metrics)
|
||||||
|
.filter(([key]) => key !== '__typename')
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<StyledTableRow key={key}>
|
||||||
|
<TableCell align="left">
|
||||||
|
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">{value}</TableCell>
|
||||||
|
</StyledTableRow>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
</StyledExpandedContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatedExpandableContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Button } from 'twenty-ui';
|
||||||
|
import {
|
||||||
|
AdminPanelHealthServiceStatus,
|
||||||
|
AdminPanelWorkerQueueHealth,
|
||||||
|
} from '~/generated/graphql';
|
||||||
|
|
||||||
|
const StyledQueueButtonsRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(6)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledQueueHealthButton = styled(Button)<{
|
||||||
|
isSelected?: boolean;
|
||||||
|
status: AdminPanelHealthServiceStatus;
|
||||||
|
}>`
|
||||||
|
${({ isSelected, theme, status }) =>
|
||||||
|
isSelected &&
|
||||||
|
`
|
||||||
|
background-color: ${
|
||||||
|
status === AdminPanelHealthServiceStatus.OPERATIONAL
|
||||||
|
? theme.tag.background.green
|
||||||
|
: theme.tag.background.red
|
||||||
|
};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
export const SettingsAdminQueueHealthButtons = ({
|
||||||
|
queues,
|
||||||
|
selectedQueue,
|
||||||
|
toggleQueueVisibility,
|
||||||
|
}: {
|
||||||
|
queues: AdminPanelWorkerQueueHealth[];
|
||||||
|
selectedQueue: string | null;
|
||||||
|
toggleQueueVisibility: (queueName: string) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<StyledQueueButtonsRow>
|
||||||
|
{queues.map((queue) => (
|
||||||
|
<StyledQueueHealthButton
|
||||||
|
key={queue.name}
|
||||||
|
onClick={() => toggleQueueVisibility(queue.name)}
|
||||||
|
title={queue.name}
|
||||||
|
variant="secondary"
|
||||||
|
isSelected={selectedQueue === queue.name}
|
||||||
|
status={queue.status}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</StyledQueueButtonsRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { SettingsAdminEnvVariables } from '@/settings/admin-panel/components/SettingsAdminEnvVariables';
|
import { SettingsAdminEnvVariables } from '@/settings/admin-panel/components/SettingsAdminEnvVariables';
|
||||||
import { SettingsAdminGeneral } from '@/settings/admin-panel/components/SettingsAdminGeneral';
|
import { SettingsAdminGeneral } from '@/settings/admin-panel/components/SettingsAdminGeneral';
|
||||||
|
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/components/SettingsAdminHealthStatus';
|
||||||
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
|
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
|
||||||
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
|
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
|
||||||
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
||||||
@ -12,6 +13,8 @@ export const SettingsAdminTabContent = () => {
|
|||||||
return <SettingsAdminGeneral />;
|
return <SettingsAdminGeneral />;
|
||||||
case SETTINGS_ADMIN_TABS.ENV_VARIABLES:
|
case SETTINGS_ADMIN_TABS.ENV_VARIABLES:
|
||||||
return <SettingsAdminEnvVariables />;
|
return <SettingsAdminEnvVariables />;
|
||||||
|
case SETTINGS_ADMIN_TABS.HEALTH_STATUS:
|
||||||
|
return <SettingsAdminHealthStatus />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { AdminHealthService } from '@/settings/admin-panel/types/AdminHealthService';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
import { SettingsListCard } from '../../components/SettingsListCard';
|
||||||
|
import { SettingsAdminHealthStatusRightContainer } from './SettingsAdminHealthStatusRightContainer';
|
||||||
|
|
||||||
|
const StyledLink = styled(Link)`
|
||||||
|
text-decoration: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SettingsHealthStatusListCard = ({
|
||||||
|
services,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
services: Array<AdminHealthService>;
|
||||||
|
loading?: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{services.map((service) => (
|
||||||
|
<>
|
||||||
|
<StyledLink
|
||||||
|
to={getSettingsPath(SettingsPath.AdminPanelIndicatorHealthStatus, {
|
||||||
|
indicatorName: service.id,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SettingsListCard
|
||||||
|
items={[service]}
|
||||||
|
getItemLabel={(service) => service.name}
|
||||||
|
isLoading={loading}
|
||||||
|
RowRightComponent={({ item: service }) => (
|
||||||
|
<SettingsAdminHealthStatusRightContainer service={service} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</StyledLink>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export const SETTINGS_ADMIN_TABS = {
|
export const SETTINGS_ADMIN_TABS = {
|
||||||
GENERAL: 'general',
|
GENERAL: 'general',
|
||||||
ENV_VARIABLES: 'env-variables',
|
ENV_VARIABLES: 'env-variables',
|
||||||
|
HEALTH_STATUS: 'health-status',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const GET_INDICATOR_HEALTH_STATUS = gql`
|
||||||
|
query GetIndicatorHealthStatus(
|
||||||
|
$indicatorName: AdminPanelIndicatorHealthStatusInputEnum!
|
||||||
|
) {
|
||||||
|
getIndicatorHealthStatus(indicatorName: $indicatorName) {
|
||||||
|
status
|
||||||
|
details
|
||||||
|
queues {
|
||||||
|
name
|
||||||
|
status
|
||||||
|
workers
|
||||||
|
metrics {
|
||||||
|
failed
|
||||||
|
completed
|
||||||
|
waiting
|
||||||
|
active
|
||||||
|
delayed
|
||||||
|
prioritized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const GET_SYSTEM_HEALTH_STATUS = gql`
|
||||||
|
query GetSystemHealthStatus {
|
||||||
|
getSystemHealthStatus {
|
||||||
|
database {
|
||||||
|
status
|
||||||
|
details
|
||||||
|
}
|
||||||
|
redis {
|
||||||
|
status
|
||||||
|
details
|
||||||
|
}
|
||||||
|
worker {
|
||||||
|
status
|
||||||
|
queues {
|
||||||
|
name
|
||||||
|
workers
|
||||||
|
status
|
||||||
|
metrics {
|
||||||
|
failed
|
||||||
|
completed
|
||||||
|
waiting
|
||||||
|
active
|
||||||
|
delayed
|
||||||
|
prioritized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageSync {
|
||||||
|
status
|
||||||
|
details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { useGetSystemHealthStatusQuery } from '~/generated/graphql';
|
||||||
|
|
||||||
|
export const useGetUptoDateHealthStatus = () => {
|
||||||
|
const { data, loading } = useGetSystemHealthStatusQuery({
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthStatus: data?.getSystemHealthStatus,
|
||||||
|
healthStatusLoading: loading,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import {
|
||||||
|
AdminPanelHealthServiceData,
|
||||||
|
AdminPanelWorkerQueueHealth,
|
||||||
|
} from '~/generated/graphql';
|
||||||
|
|
||||||
|
type AdminWorkerService = AdminPanelHealthServiceData & {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
queues: AdminPanelWorkerQueueHealth[] | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminHealthService = AdminWorkerService;
|
||||||
@ -34,6 +34,8 @@ export enum SettingsPath {
|
|||||||
Releases = 'releases',
|
Releases = 'releases',
|
||||||
AdminPanel = 'admin-panel',
|
AdminPanel = 'admin-panel',
|
||||||
FeatureFlags = 'admin-panel/feature-flags',
|
FeatureFlags = 'admin-panel/feature-flags',
|
||||||
|
AdminPanelHealthStatus = 'admin-panel#health-status',
|
||||||
|
AdminPanelIndicatorHealthStatus = 'admin-panel/health-status/:indicatorName',
|
||||||
Lab = 'lab',
|
Lab = 'lab',
|
||||||
Roles = 'roles',
|
Roles = 'roles',
|
||||||
RoleDetail = 'roles/:roleId',
|
RoleDetail = 'roles/:roleId',
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import {
|
import { H2Title, IconWorld, Section, UndecoratedLink } from 'twenty-ui';
|
||||||
GithubVersionLink,
|
|
||||||
H2Title,
|
|
||||||
IconWorld,
|
|
||||||
Section,
|
|
||||||
UndecoratedLink,
|
|
||||||
} from 'twenty-ui';
|
|
||||||
|
|
||||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
import { SettingsCard } from '@/settings/components/SettingsCard';
|
||||||
@ -18,7 +12,6 @@ import { WorkspaceLogoUploader } from '@/settings/workspace/components/Workspace
|
|||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
import packageJson from '../../../package.json';
|
|
||||||
|
|
||||||
export const SettingsWorkspace = () => {
|
export const SettingsWorkspace = () => {
|
||||||
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
|
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
|
||||||
@ -70,9 +63,6 @@ export const SettingsWorkspace = () => {
|
|||||||
<Section>
|
<Section>
|
||||||
<DeleteWorkspace />
|
<DeleteWorkspace />
|
||||||
</Section>
|
</Section>
|
||||||
<Section>
|
|
||||||
<GithubVersionLink version={packageJson.version} />
|
|
||||||
</Section>
|
|
||||||
</SettingsPageContainer>
|
</SettingsPageContainer>
|
||||||
</SubMenuTopBarContainer>
|
</SubMenuTopBarContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,137 @@
|
|||||||
|
import { SettingsAdminQueueExpandableContainer } from '@/settings/admin-panel/components/SettingsAdminQueueExpandableContainer';
|
||||||
|
import { SettingsAdminQueueHealthButtons } from '@/settings/admin-panel/components/SettingsAdminQueueHealthButtons';
|
||||||
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { H2Title, Section, Status } from 'twenty-ui';
|
||||||
|
import {
|
||||||
|
AdminPanelHealthServiceStatus,
|
||||||
|
AdminPanelIndicatorHealthStatusInputEnum,
|
||||||
|
useGetIndicatorHealthStatusQuery,
|
||||||
|
} from '~/generated/graphql';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
|
const StyledStatusContainer = styled.div``;
|
||||||
|
|
||||||
|
const StyledTitleContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledErrorMessage = styled.div`
|
||||||
|
color: ${({ theme }) => theme.color.red};
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledDetailsContainer = styled.pre`
|
||||||
|
background-color: ${({ theme }) => theme.background.quaternary};
|
||||||
|
padding: ${({ theme }) => theme.spacing(6)};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: ${({ theme }) => theme.font.size.sm};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SettingsAdminIndicatorHealthStatus = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { indicatorName } = useParams();
|
||||||
|
const { data, loading } = useGetIndicatorHealthStatusQuery({
|
||||||
|
variables: {
|
||||||
|
indicatorName: indicatorName as AdminPanelIndicatorHealthStatusInputEnum,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDetails = data?.getIndicatorHealthStatus.details
|
||||||
|
? JSON.stringify(JSON.parse(data.getIndicatorHealthStatus.details), null, 2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isWorkerDown =
|
||||||
|
!data?.getIndicatorHealthStatus.status ||
|
||||||
|
data?.getIndicatorHealthStatus.status ===
|
||||||
|
AdminPanelHealthServiceStatus.OUTAGE;
|
||||||
|
|
||||||
|
const [selectedQueue, setSelectedQueue] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggleQueueVisibility = (queueName: string) => {
|
||||||
|
setSelectedQueue(selectedQueue === queueName ? null : queueName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubMenuTopBarContainer
|
||||||
|
links={[
|
||||||
|
{
|
||||||
|
children: t`Other`,
|
||||||
|
href: getSettingsPath(SettingsPath.AdminPanel),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: t`Server Admin Panel`,
|
||||||
|
href: getSettingsPath(SettingsPath.AdminPanel),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: t`Health Status`,
|
||||||
|
href: getSettingsPath(SettingsPath.AdminPanelHealthStatus),
|
||||||
|
},
|
||||||
|
{ children: `${indicatorName}` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<SettingsPageContainer>
|
||||||
|
<Section>
|
||||||
|
<H2Title title={`${indicatorName}`} description="Health status" />
|
||||||
|
<StyledStatusContainer>
|
||||||
|
{data?.getIndicatorHealthStatus.status ===
|
||||||
|
AdminPanelHealthServiceStatus.OPERATIONAL && (
|
||||||
|
<Status color="green" text="Operational" weight="medium" />
|
||||||
|
)}
|
||||||
|
{data?.getIndicatorHealthStatus.status ===
|
||||||
|
AdminPanelHealthServiceStatus.OUTAGE && (
|
||||||
|
<Status color="red" text="Outage" weight="medium" />
|
||||||
|
)}
|
||||||
|
</StyledStatusContainer>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{indicatorName === AdminPanelIndicatorHealthStatusInputEnum.WORKER ? (
|
||||||
|
<Section>
|
||||||
|
<StyledTitleContainer>
|
||||||
|
<H2Title
|
||||||
|
title="Queue Status"
|
||||||
|
description="Background job processing status and metrics"
|
||||||
|
/>
|
||||||
|
</StyledTitleContainer>
|
||||||
|
{isWorkerDown && !loading ? (
|
||||||
|
<StyledErrorMessage>
|
||||||
|
Queue information is not available because the worker is down
|
||||||
|
</StyledErrorMessage>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SettingsAdminQueueHealthButtons
|
||||||
|
queues={data?.getIndicatorHealthStatus.queues ?? []}
|
||||||
|
selectedQueue={selectedQueue}
|
||||||
|
toggleQueueVisibility={toggleQueueVisibility}
|
||||||
|
/>
|
||||||
|
<SettingsAdminQueueExpandableContainer
|
||||||
|
queues={data?.getIndicatorHealthStatus.queues ?? []}
|
||||||
|
selectedQueue={selectedQueue}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{indicatorName === AdminPanelIndicatorHealthStatusInputEnum.DATABASE ||
|
||||||
|
indicatorName === AdminPanelIndicatorHealthStatusInputEnum.REDIS ? (
|
||||||
|
<Section>
|
||||||
|
{formattedDetails && (
|
||||||
|
<StyledDetailsContainer>
|
||||||
|
{formattedDetails}
|
||||||
|
</StyledDetailsContainer>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</SettingsPageContainer>
|
||||||
|
</SubMenuTopBarContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
|
||||||
|
import { SystemHealth } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
|
||||||
|
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
|
||||||
|
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||||
|
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||||
|
import { MessageSyncHealthIndicator } from 'src/engine/core-modules/health/indicators/message-sync.health';
|
||||||
|
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||||
|
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||||
|
|
||||||
|
describe('AdminPanelHealthService', () => {
|
||||||
|
let service: AdminPanelHealthService;
|
||||||
|
let databaseHealth: jest.Mocked<DatabaseHealthIndicator>;
|
||||||
|
let redisHealth: jest.Mocked<RedisHealthIndicator>;
|
||||||
|
let workerHealth: jest.Mocked<WorkerHealthIndicator>;
|
||||||
|
let messageSyncHealth: jest.Mocked<MessageSyncHealthIndicator>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
databaseHealth = {
|
||||||
|
isHealthy: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
redisHealth = {
|
||||||
|
isHealthy: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
workerHealth = {
|
||||||
|
isHealthy: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
messageSyncHealth = {
|
||||||
|
isHealthy: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AdminPanelHealthService,
|
||||||
|
{
|
||||||
|
provide: DatabaseHealthIndicator,
|
||||||
|
useValue: databaseHealth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: RedisHealthIndicator,
|
||||||
|
useValue: redisHealth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: WorkerHealthIndicator,
|
||||||
|
useValue: workerHealth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MessageSyncHealthIndicator,
|
||||||
|
useValue: messageSyncHealth,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AdminPanelHealthService>(AdminPanelHealthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform health check response to SystemHealth format', async () => {
|
||||||
|
databaseHealth.isHealthy.mockResolvedValue({
|
||||||
|
database: {
|
||||||
|
status: 'up',
|
||||||
|
details: 'Database is healthy',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
redisHealth.isHealthy.mockResolvedValue({
|
||||||
|
redis: {
|
||||||
|
status: 'up',
|
||||||
|
details: 'Redis is connected',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
workerHealth.isHealthy.mockResolvedValue({
|
||||||
|
worker: {
|
||||||
|
status: 'up',
|
||||||
|
queues: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
workers: 1,
|
||||||
|
metrics: {
|
||||||
|
active: 1,
|
||||||
|
completed: 0,
|
||||||
|
delayed: 4,
|
||||||
|
failed: 3,
|
||||||
|
waiting: 0,
|
||||||
|
prioritized: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
messageSyncHealth.isHealthy.mockResolvedValue({
|
||||||
|
messageSync: {
|
||||||
|
status: 'up',
|
||||||
|
details: 'Message sync is operational',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getSystemHealthStatus();
|
||||||
|
|
||||||
|
const expected: SystemHealth = {
|
||||||
|
database: {
|
||||||
|
status: AdminPanelHealthServiceStatus.OPERATIONAL,
|
||||||
|
details: '"Database is healthy"',
|
||||||
|
queues: undefined,
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
status: AdminPanelHealthServiceStatus.OPERATIONAL,
|
||||||
|
details: '"Redis is connected"',
|
||||||
|
queues: undefined,
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
status: AdminPanelHealthServiceStatus.OPERATIONAL,
|
||||||
|
details: undefined,
|
||||||
|
queues: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
workers: 1,
|
||||||
|
status: AdminPanelHealthServiceStatus.OPERATIONAL,
|
||||||
|
metrics: {
|
||||||
|
active: 1,
|
||||||
|
completed: 0,
|
||||||
|
delayed: 4,
|
||||||
|
failed: 3,
|
||||||
|
waiting: 0,
|
||||||
|
prioritized: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
messageSync: {
|
||||||
|
status: AdminPanelHealthServiceStatus.OPERATIONAL,
|
||||||
|
details: '"Message sync is operational"',
|
||||||
|
queues: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed health statuses', async () => {
|
||||||
|
databaseHealth.isHealthy.mockResolvedValue({
|
||||||
|
database: { status: 'up' },
|
||||||
|
});
|
||||||
|
redisHealth.isHealthy.mockRejectedValue(
|
||||||
|
new Error(HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED),
|
||||||
|
);
|
||||||
|
workerHealth.isHealthy.mockResolvedValue({
|
||||||
|
worker: { status: 'up', queues: [] },
|
||||||
|
});
|
||||||
|
messageSyncHealth.isHealthy.mockResolvedValue({
|
||||||
|
messageSync: { status: 'up' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getSystemHealthStatus();
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
database: { status: AdminPanelHealthServiceStatus.OPERATIONAL },
|
||||||
|
redis: { status: AdminPanelHealthServiceStatus.OUTAGE },
|
||||||
|
worker: { status: AdminPanelHealthServiceStatus.OPERATIONAL },
|
||||||
|
messageSync: { status: AdminPanelHealthServiceStatus.OPERATIONAL },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all services down', async () => {
|
||||||
|
databaseHealth.isHealthy.mockRejectedValue(
|
||||||
|
new Error(HEALTH_ERROR_MESSAGES.DATABASE_CONNECTION_FAILED),
|
||||||
|
);
|
||||||
|
redisHealth.isHealthy.mockRejectedValue(
|
||||||
|
new Error(HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED),
|
||||||
|
);
|
||||||
|
workerHealth.isHealthy.mockRejectedValue(
|
||||||
|
new Error(HEALTH_ERROR_MESSAGES.NO_ACTIVE_WORKERS),
|
||||||
|
);
|
||||||
|
messageSyncHealth.isHealthy.mockRejectedValue(
|
||||||
|
new Error(HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_CHECK_FAILED),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getSystemHealthStatus();
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
database: { status: AdminPanelHealthServiceStatus.OUTAGE },
|
||||||
|
redis: { status: AdminPanelHealthServiceStatus.OUTAGE },
|
||||||
|
worker: { status: AdminPanelHealthServiceStatus.OUTAGE },
|
||||||
|
messageSync: { status: AdminPanelHealthServiceStatus.OUTAGE },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HealthIndicatorResult } from '@nestjs/terminus';
|
||||||
|
|
||||||
|
import { AdminPanelHealthServiceData } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-health-service-data.dto';
|
||||||
|
import { AdminPanelIndicatorHealthStatusInputEnum } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-indicator-health-status.input';
|
||||||
|
import { SystemHealth } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
|
||||||
|
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
|
||||||
|
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||||
|
import { MessageSyncHealthIndicator } from 'src/engine/core-modules/health/indicators/message-sync.health';
|
||||||
|
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||||
|
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminPanelHealthService {
|
||||||
|
constructor(
|
||||||
|
private readonly databaseHealth: DatabaseHealthIndicator,
|
||||||
|
private readonly redisHealth: RedisHealthIndicator,
|
||||||
|
private readonly workerHealth: WorkerHealthIndicator,
|
||||||
|
private readonly messageSyncHealth: MessageSyncHealthIndicator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private readonly healthIndicators = {
|
||||||
|
database: this.databaseHealth,
|
||||||
|
redis: this.redisHealth,
|
||||||
|
worker: this.workerHealth,
|
||||||
|
messageSync: this.messageSyncHealth,
|
||||||
|
};
|
||||||
|
|
||||||
|
private getServiceStatus(
|
||||||
|
result: PromiseSettledResult<HealthIndicatorResult>,
|
||||||
|
) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
const key = Object.keys(result.value)[0];
|
||||||
|
const serviceResult = result.value[key];
|
||||||
|
const details = serviceResult.details;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status:
|
||||||
|
serviceResult.status === 'up'
|
||||||
|
? AdminPanelHealthServiceStatus.OPERATIONAL
|
||||||
|
: AdminPanelHealthServiceStatus.OUTAGE,
|
||||||
|
details: details ? JSON.stringify(details) : undefined,
|
||||||
|
queues: serviceResult.queues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: AdminPanelHealthServiceStatus.OUTAGE,
|
||||||
|
details: result.reason?.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIndicatorHealthStatus(
|
||||||
|
indicatorName: AdminPanelIndicatorHealthStatusInputEnum,
|
||||||
|
): Promise<AdminPanelHealthServiceData> {
|
||||||
|
const healthIndicator = this.healthIndicators[indicatorName];
|
||||||
|
|
||||||
|
if (!healthIndicator) {
|
||||||
|
throw new Error(`Health indicator not found: ${indicatorName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Promise.allSettled([healthIndicator.isHealthy()]);
|
||||||
|
const indicatorStatus = this.getServiceStatus(result[0]);
|
||||||
|
|
||||||
|
if (indicatorName === 'worker') {
|
||||||
|
return {
|
||||||
|
...indicatorStatus,
|
||||||
|
queues: (indicatorStatus?.queues ?? []).map((queue) => ({
|
||||||
|
...queue,
|
||||||
|
status:
|
||||||
|
queue.workers > 0
|
||||||
|
? AdminPanelHealthServiceStatus.OPERATIONAL
|
||||||
|
: AdminPanelHealthServiceStatus.OUTAGE,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicatorStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSystemHealthStatus(): Promise<SystemHealth> {
|
||||||
|
const [databaseResult, redisResult, workerResult, messageSyncResult] =
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.databaseHealth.isHealthy(),
|
||||||
|
this.redisHealth.isHealthy(),
|
||||||
|
this.workerHealth.isHealthy(),
|
||||||
|
this.messageSyncHealth.isHealthy(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workerStatus = this.getServiceStatus(workerResult);
|
||||||
|
|
||||||
|
return {
|
||||||
|
database: this.getServiceStatus(databaseResult),
|
||||||
|
redis: this.getServiceStatus(redisResult),
|
||||||
|
worker: {
|
||||||
|
...workerStatus,
|
||||||
|
queues: (workerStatus?.queues ?? []).map((queue) => ({
|
||||||
|
...queue,
|
||||||
|
status:
|
||||||
|
queue.workers > 0
|
||||||
|
? AdminPanelHealthServiceStatus.OPERATIONAL
|
||||||
|
: AdminPanelHealthServiceStatus.OUTAGE,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
messageSync: this.getServiceStatus(messageSyncResult),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,21 +1,28 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TerminusModule } from '@nestjs/terminus';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
|
||||||
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
|
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
|
||||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||||
|
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||||
|
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||||
|
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([User, Workspace, FeatureFlag], 'core'),
|
TypeOrmModule.forFeature([User, Workspace, FeatureFlag], 'core'),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
DomainManagerModule,
|
DomainManagerModule,
|
||||||
|
HealthModule,
|
||||||
|
RedisClientModule,
|
||||||
|
TerminusModule,
|
||||||
],
|
],
|
||||||
providers: [AdminPanelResolver, AdminPanelService],
|
providers: [AdminPanelResolver, AdminPanelService, AdminPanelHealthService],
|
||||||
exports: [AdminPanelService],
|
exports: [AdminPanelService],
|
||||||
})
|
})
|
||||||
export class AdminPanelModule {}
|
export class AdminPanelModule {}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
|
||||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||||
import { EnvironmentVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/environment-variables.output';
|
import { EnvironmentVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/environment-variables.output';
|
||||||
import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
|
import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
|
||||||
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
|
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
|
||||||
|
import { SystemHealth } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
|
||||||
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
|
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 { 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 { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input';
|
||||||
@ -13,10 +15,16 @@ import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
|
|||||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||||
|
|
||||||
|
import { AdminPanelHealthServiceData } from './dtos/admin-panel-health-service-data.dto';
|
||||||
|
import { AdminPanelIndicatorHealthStatusInputEnum } from './dtos/admin-panel-indicator-health-status.input';
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||||
export class AdminPanelResolver {
|
export class AdminPanelResolver {
|
||||||
constructor(private adminService: AdminPanelService) {}
|
constructor(
|
||||||
|
private adminService: AdminPanelService,
|
||||||
|
private adminPanelHealthService: AdminPanelHealthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
|
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
|
||||||
@Mutation(() => ImpersonateOutput)
|
@Mutation(() => ImpersonateOutput)
|
||||||
@ -53,4 +61,20 @@ export class AdminPanelResolver {
|
|||||||
async getEnvironmentVariablesGrouped(): Promise<EnvironmentVariablesOutput> {
|
async getEnvironmentVariablesGrouped(): Promise<EnvironmentVariablesOutput> {
|
||||||
return this.adminService.getEnvironmentVariablesGrouped();
|
return this.adminService.getEnvironmentVariablesGrouped();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
|
||||||
|
@Query(() => SystemHealth)
|
||||||
|
async getSystemHealthStatus(): Promise<SystemHealth> {
|
||||||
|
return this.adminPanelHealthService.getSystemHealthStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => AdminPanelHealthServiceData)
|
||||||
|
async getIndicatorHealthStatus(
|
||||||
|
@Args('indicatorName', {
|
||||||
|
type: () => AdminPanelIndicatorHealthStatusInputEnum,
|
||||||
|
})
|
||||||
|
indicatorName: AdminPanelIndicatorHealthStatusInputEnum,
|
||||||
|
): Promise<AdminPanelHealthServiceData> {
|
||||||
|
return this.adminPanelHealthService.getIndicatorHealthStatus(indicatorName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { AdminPanelWorkerQueueHealth } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-worker-queue-health.dto';
|
||||||
|
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class AdminPanelHealthServiceData {
|
||||||
|
@Field(() => AdminPanelHealthServiceStatus)
|
||||||
|
status: AdminPanelHealthServiceStatus;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
details?: string;
|
||||||
|
|
||||||
|
@Field(() => [AdminPanelWorkerQueueHealth], { nullable: true })
|
||||||
|
queues?: AdminPanelWorkerQueueHealth[];
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { Field, registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
export enum AdminPanelIndicatorHealthStatusInputEnum {
|
||||||
|
DATABASE = 'database',
|
||||||
|
REDIS = 'redis',
|
||||||
|
WORKER = 'worker',
|
||||||
|
MESSAGE_SYNC = 'messageSync',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(AdminPanelIndicatorHealthStatusInputEnum, {
|
||||||
|
name: 'AdminPanelIndicatorHealthStatusInputEnum',
|
||||||
|
});
|
||||||
|
|
||||||
|
export class AdminPanelIndicatorHealthStatusInput {
|
||||||
|
@Field(() => AdminPanelIndicatorHealthStatusInputEnum)
|
||||||
|
indicatorName: AdminPanelIndicatorHealthStatusInputEnum;
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
|
||||||
|
import { WorkerQueueHealth } from 'src/engine/core-modules/health/types/worker-queue-health.type';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class AdminPanelWorkerQueueHealth extends WorkerQueueHealth {
|
||||||
|
@Field(() => AdminPanelHealthServiceStatus)
|
||||||
|
status: AdminPanelHealthServiceStatus;
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { AdminPanelHealthServiceData } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-health-service-data.dto';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class SystemHealth {
|
||||||
|
@Field(() => AdminPanelHealthServiceData)
|
||||||
|
database: AdminPanelHealthServiceData;
|
||||||
|
|
||||||
|
@Field(() => AdminPanelHealthServiceData)
|
||||||
|
redis: AdminPanelHealthServiceData;
|
||||||
|
|
||||||
|
@Field(() => AdminPanelHealthServiceData)
|
||||||
|
worker: AdminPanelHealthServiceData;
|
||||||
|
|
||||||
|
@Field(() => AdminPanelHealthServiceData)
|
||||||
|
messageSync: AdminPanelHealthServiceData;
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
export enum AdminPanelHealthServiceStatus {
|
||||||
|
OPERATIONAL = 'operational',
|
||||||
|
OUTAGE = 'outage',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(AdminPanelHealthServiceStatus, {
|
||||||
|
name: 'AdminPanelHealthServiceStatus',
|
||||||
|
});
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
export const HEALTH_ERROR_MESSAGES = {
|
||||||
|
NO_ACTIVE_WORKERS: 'No active workers found',
|
||||||
|
WORKER_TIMEOUT: 'Worker check timeout',
|
||||||
|
DATABASE_TIMEOUT: 'Database timeout',
|
||||||
|
REDIS_TIMEOUT: 'Redis timeout',
|
||||||
|
DATABASE_CONNECTION_FAILED: 'Database connection failed',
|
||||||
|
REDIS_CONNECTION_FAILED: 'Unknown Redis error',
|
||||||
|
WORKER_CHECK_FAILED: 'Worker check failed',
|
||||||
|
MESSAGE_SYNC_TIMEOUT: 'Message sync check timeout',
|
||||||
|
MESSAGE_SYNC_CHECK_FAILED: 'Message sync check failed',
|
||||||
|
MESSAGE_SYNC_HIGH_FAILURE_RATE: 'High failure rate in message sync jobs',
|
||||||
|
} as const;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export const HEALTH_INDICATORS_TIMEOUT = 3000;
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import { HealthCheckService } from '@nestjs/terminus';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { HealthController } from 'src/engine/core-modules/health/controllers/health.controller';
|
||||||
|
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||||
|
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||||
|
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||||
|
|
||||||
|
describe('HealthController', () => {
|
||||||
|
let healthController: HealthController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const testingModule: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: HealthCheckService,
|
||||||
|
useValue: { check: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DatabaseHealthIndicator,
|
||||||
|
useValue: { isHealthy: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: RedisHealthIndicator,
|
||||||
|
useValue: { isHealthy: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: WorkerHealthIndicator,
|
||||||
|
useValue: { isHealthy: jest.fn() },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
healthController = testingModule.get<HealthController>(HealthController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(healthController).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { MetricsController } from 'src/engine/core-modules/health/controllers/metrics.controller';
|
||||||
|
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||||
|
|
||||||
|
describe('MetricsController', () => {
|
||||||
|
let metricsController: MetricsController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const testingModule: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [MetricsController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: HealthCacheService,
|
||||||
|
useValue: {
|
||||||
|
getMessageChannelSyncJobByStatusCounter: jest.fn(),
|
||||||
|
getInvalidCaptchaCounter: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
metricsController = testingModule.get<MetricsController>(MetricsController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(metricsController).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import { BadRequestException, Controller, Get, Param } from '@nestjs/common';
|
||||||
|
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
||||||
|
|
||||||
|
import { HealthServiceName } from 'src/engine/core-modules/health/enums/health-service-name.enum';
|
||||||
|
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||||
|
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||||
|
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||||
|
|
||||||
|
@Controller('healthz')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(
|
||||||
|
private readonly health: HealthCheckService,
|
||||||
|
private readonly databaseHealth: DatabaseHealthIndicator,
|
||||||
|
private readonly redisHealth: RedisHealthIndicator,
|
||||||
|
private readonly workerHealth: WorkerHealthIndicator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@HealthCheck()
|
||||||
|
check() {
|
||||||
|
return this.health.check([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/:serviceName')
|
||||||
|
@HealthCheck()
|
||||||
|
checkService(@Param('serviceName') serviceName: HealthServiceName) {
|
||||||
|
const checks = {
|
||||||
|
[HealthServiceName.DATABASE]: () => this.databaseHealth.isHealthy(),
|
||||||
|
[HealthServiceName.REDIS]: () => this.redisHealth.isHealthy(),
|
||||||
|
[HealthServiceName.WORKER]: () => this.workerHealth.isHealthy(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!(serviceName in checks)) {
|
||||||
|
throw new BadRequestException(`Invalid service name: ${serviceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.health.check([checks[serviceName]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,20 +1,10 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
|
||||||
|
|
||||||
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||||
|
|
||||||
@Controller('healthz')
|
@Controller('metricsz')
|
||||||
export class HealthController {
|
export class MetricsController {
|
||||||
constructor(
|
constructor(private readonly healthCacheService: HealthCacheService) {}
|
||||||
private health: HealthCheckService,
|
|
||||||
private healthCacheService: HealthCacheService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@HealthCheck()
|
|
||||||
check() {
|
|
||||||
return this.health.check([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/message-channel-sync-job-by-status-counter')
|
@Get('/message-channel-sync-job-by-status-counter')
|
||||||
getMessageChannelSyncJobByStatusCounter() {
|
getMessageChannelSyncJobByStatusCounter() {
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export enum HealthServiceName {
|
||||||
|
DATABASE = 'database',
|
||||||
|
REDIS = 'redis',
|
||||||
|
WORKER = 'worker',
|
||||||
|
MESSAGE_SYNC = 'messageSync',
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import { CacheStorageService } from 'src/engine/core-modules/cache-storage/servi
|
|||||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { HealthCounterCacheKeys } from 'src/engine/core-modules/health/types/health-counter-cache-keys.type';
|
import { HealthCounterCacheKeys } from 'src/engine/core-modules/health/types/health-counter-cache-keys.type';
|
||||||
import { MessageChannelSyncJobByStatusCounter } from 'src/engine/core-modules/health/types/health-metrics.types';
|
import { MessageChannelSyncJobByStatusCounter } from 'src/engine/core-modules/health/types/message-sync-metrics.types';
|
||||||
import { MessageChannelSyncStatus } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
import { MessageChannelSyncStatus } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
import { HealthCheckService, HttpHealthIndicator } from '@nestjs/terminus';
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
|
|
||||||
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
|
||||||
import { HealthController } from 'src/engine/core-modules/health/health.controller';
|
|
||||||
|
|
||||||
describe('HealthController', () => {
|
|
||||||
let healthController: HealthController;
|
|
||||||
let testingModule: TestingModule;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
testingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
HealthController,
|
|
||||||
{
|
|
||||||
provide: HealthCheckService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: HttpHealthIndicator,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: HealthCacheService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
healthController = testingModule.get<HealthController>(HealthController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(healthController).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,13 +1,32 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TerminusModule } from '@nestjs/terminus';
|
import { TerminusModule } from '@nestjs/terminus';
|
||||||
|
|
||||||
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
import { HealthController } from 'src/engine/core-modules/health/controllers/health.controller';
|
||||||
import { HealthController } from 'src/engine/core-modules/health/health.controller';
|
import { MetricsController } from 'src/engine/core-modules/health/controllers/metrics.controller';
|
||||||
|
import { MessageSyncHealthIndicator } from 'src/engine/core-modules/health/indicators/message-sync.health';
|
||||||
|
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
|
||||||
|
|
||||||
|
import { HealthCacheService } from './health-cache.service';
|
||||||
|
|
||||||
|
import { DatabaseHealthIndicator } from './indicators/database.health';
|
||||||
|
import { RedisHealthIndicator } from './indicators/redis.health';
|
||||||
|
import { WorkerHealthIndicator } from './indicators/worker.health';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TerminusModule],
|
imports: [TerminusModule, RedisClientModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController, MetricsController],
|
||||||
providers: [HealthCacheService],
|
providers: [
|
||||||
exports: [HealthCacheService],
|
HealthCacheService,
|
||||||
|
DatabaseHealthIndicator,
|
||||||
|
RedisHealthIndicator,
|
||||||
|
WorkerHealthIndicator,
|
||||||
|
MessageSyncHealthIndicator,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
HealthCacheService,
|
||||||
|
DatabaseHealthIndicator,
|
||||||
|
RedisHealthIndicator,
|
||||||
|
WorkerHealthIndicator,
|
||||||
|
MessageSyncHealthIndicator,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
@ -0,0 +1,116 @@
|
|||||||
|
import { HealthIndicatorService } from '@nestjs/terminus';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||||
|
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||||
|
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||||
|
|
||||||
|
describe('DatabaseHealthIndicator', () => {
|
||||||
|
let service: DatabaseHealthIndicator;
|
||||||
|
let dataSource: jest.Mocked<DataSource>;
|
||||||
|
let healthIndicatorService: jest.Mocked<HealthIndicatorService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dataSource = {
|
||||||
|
query: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
healthIndicatorService = {
|
||||||
|
check: jest.fn().mockReturnValue({
|
||||||
|
up: jest.fn().mockImplementation((data) => ({
|
||||||
|
database: { status: 'up', ...data },
|
||||||
|
})),
|
||||||
|
down: jest.fn().mockImplementation((error) => ({
|
||||||
|
database: {
|
||||||
|
status: 'down',
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DatabaseHealthIndicator,
|
||||||
|
{
|
||||||
|
provide: 'coreDataSource',
|
||||||
|
useValue: dataSource,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: HealthIndicatorService,
|
||||||
|
useValue: healthIndicatorService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<DatabaseHealthIndicator>(DatabaseHealthIndicator);
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return up status with details when database responds', async () => {
|
||||||
|
const mockResponses = [
|
||||||
|
[{ version: 'PostgreSQL 15.6' }],
|
||||||
|
[{ count: '5' }],
|
||||||
|
[{ max_connections: '100' }],
|
||||||
|
[{ uptime: '3600' }],
|
||||||
|
[{ size: '1 GB' }],
|
||||||
|
[{ table_stats: [] }],
|
||||||
|
[{ ratio: '95.5' }],
|
||||||
|
[{ deadlocks: '0' }],
|
||||||
|
[{ count: '0' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
mockResponses.forEach((response) => {
|
||||||
|
dataSource.query.mockResolvedValueOnce(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.database.status).toBe('up');
|
||||||
|
expect(result.database.details).toBeDefined();
|
||||||
|
expect(result.database.details.version).toBeDefined();
|
||||||
|
expect(result.database.details.connections).toBeDefined();
|
||||||
|
expect(result.database.details.performance).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return down status when database fails', async () => {
|
||||||
|
dataSource.query.mockRejectedValueOnce(
|
||||||
|
new Error(HEALTH_ERROR_MESSAGES.DATABASE_CONNECTION_FAILED),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.database.status).toBe('down');
|
||||||
|
expect(result.database.error).toBe(
|
||||||
|
HEALTH_ERROR_MESSAGES.DATABASE_CONNECTION_FAILED,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should timeout after specified duration', async () => {
|
||||||
|
dataSource.query.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, HEALTH_INDICATORS_TIMEOUT + 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const healthCheckPromise = service.isHealthy();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(HEALTH_INDICATORS_TIMEOUT + 1);
|
||||||
|
|
||||||
|
const result = await healthCheckPromise;
|
||||||
|
|
||||||
|
expect(result.database.status).toBe('down');
|
||||||
|
expect(result.database.error).toBe(HEALTH_ERROR_MESSAGES.DATABASE_TIMEOUT);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
import { HealthIndicatorService } from '@nestjs/terminus';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||||
|
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||||
|
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||||
|
import { MessageSyncHealthIndicator } from 'src/engine/core-modules/health/indicators/message-sync.health';
|
||||||
|
import { MessageChannelSyncStatus } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||||
|
|
||||||
|
describe('MessageSyncHealthIndicator', () => {
|
||||||
|
let service: MessageSyncHealthIndicator;
|
||||||
|
let healthCacheService: jest.Mocked<HealthCacheService>;
|
||||||
|
let healthIndicatorService: jest.Mocked<HealthIndicatorService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
healthCacheService = {
|
||||||
|
getMessageChannelSyncJobByStatusCounter: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
healthIndicatorService = {
|
||||||
|
check: jest.fn().mockReturnValue({
|
||||||
|
up: jest.fn().mockImplementation((data) => ({
|
||||||
|
messageSync: { status: 'up', ...data },
|
||||||
|
})),
|
||||||
|
down: jest.fn().mockImplementation((error) => ({
|
||||||
|
messageSync: { status: 'down', error },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MessageSyncHealthIndicator,
|
||||||
|
{
|
||||||
|
provide: HealthCacheService,
|
||||||
|
useValue: healthCacheService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: HealthIndicatorService,
|
||||||
|
useValue: healthIndicatorService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<MessageSyncHealthIndicator>(
|
||||||
|
MessageSyncHealthIndicator,
|
||||||
|
);
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return up status when no jobs are present', async () => {
|
||||||
|
healthCacheService.getMessageChannelSyncJobByStatusCounter.mockResolvedValue(
|
||||||
|
{
|
||||||
|
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||||
|
[MessageChannelSyncStatus.ONGOING]: 0,
|
||||||
|
[MessageChannelSyncStatus.ACTIVE]: 0,
|
||||||
|
[MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS]: 0,
|
||||||
|
[MessageChannelSyncStatus.FAILED_UNKNOWN]: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.messageSync.status).toBe('up');
|
||||||
|
expect(result.messageSync.details.totalJobs).toBe(0);
|
||||||
|
expect(result.messageSync.details.failedJobs).toBe(0);
|
||||||
|
expect(result.messageSync.details.failureRate).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return up status when failure rate is below 20%', async () => {
|
||||||
|
healthCacheService.getMessageChannelSyncJobByStatusCounter.mockResolvedValue(
|
||||||
|
{
|
||||||
|
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||||
|
[MessageChannelSyncStatus.ONGOING]: 2,
|
||||||
|
[MessageChannelSyncStatus.ACTIVE]: 8,
|
||||||
|
[MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS]: 0,
|
||||||
|
[MessageChannelSyncStatus.FAILED_UNKNOWN]: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.messageSync.status).toBe('up');
|
||||||
|
expect(result.messageSync.details.totalJobs).toBe(11);
|
||||||
|
expect(result.messageSync.details.failedJobs).toBe(1);
|
||||||
|
expect(result.messageSync.details.failureRate).toBe(9.09);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return down status when failure rate is above 20%', async () => {
|
||||||
|
healthCacheService.getMessageChannelSyncJobByStatusCounter.mockResolvedValue(
|
||||||
|
{
|
||||||
|
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||||
|
[MessageChannelSyncStatus.ONGOING]: 1,
|
||||||
|
[MessageChannelSyncStatus.ACTIVE]: 1,
|
||||||
|
[MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS]: 2,
|
||||||
|
[MessageChannelSyncStatus.FAILED_UNKNOWN]: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.messageSync.status).toBe('down');
|
||||||
|
expect(result.messageSync.error.error).toBe(
|
||||||
|
HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_HIGH_FAILURE_RATE,
|
||||||
|
);
|
||||||
|
expect(result.messageSync.error.details).toBeDefined();
|
||||||
|
expect(result.messageSync.error.details.failureRate).toBe(33.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should timeout after specified duration', async () => {
|
||||||
|
healthCacheService.getMessageChannelSyncJobByStatusCounter.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, HEALTH_INDICATORS_TIMEOUT + 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const healthCheckPromise = service.isHealthy();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(HEALTH_INDICATORS_TIMEOUT + 1);
|
||||||
|
|
||||||
|
const result = await healthCheckPromise;
|
||||||
|
|
||||||
|
expect(result.messageSync.status).toBe('down');
|
||||||
|
expect(result.messageSync.error).toBe(
|
||||||
|
HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
import { HealthIndicatorService } from '@nestjs/terminus';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||||
|
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||||
|
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||||
|
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||||
|
|
||||||
|
describe('RedisHealthIndicator', () => {
|
||||||
|
let service: RedisHealthIndicator;
|
||||||
|
let mockRedis: jest.Mocked<
|
||||||
|
Pick<Redis, 'ping' | 'info' | 'dbsize' | 'memory'>
|
||||||
|
>;
|
||||||
|
let healthIndicatorService: jest.Mocked<HealthIndicatorService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockRedis = {
|
||||||
|
ping: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
dbsize: jest.fn(),
|
||||||
|
memory: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRedisService = {
|
||||||
|
getClient: () => mockRedis,
|
||||||
|
} as unknown as RedisClientService;
|
||||||
|
|
||||||
|
healthIndicatorService = {
|
||||||
|
check: jest.fn().mockReturnValue({
|
||||||
|
up: jest.fn().mockImplementation((data) => ({
|
||||||
|
redis: { status: 'up', ...data },
|
||||||
|
})),
|
||||||
|
down: jest.fn().mockImplementation((error) => ({
|
||||||
|
redis: {
|
||||||
|
status: 'down',
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
RedisHealthIndicator,
|
||||||
|
{
|
||||||
|
provide: RedisClientService,
|
||||||
|
useValue: mockRedisService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: HealthIndicatorService,
|
||||||
|
useValue: healthIndicatorService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<RedisHealthIndicator>(RedisHealthIndicator);
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return up status with details when redis responds', async () => {
|
||||||
|
// ai generated mock
|
||||||
|
mockRedis.info
|
||||||
|
.mockResolvedValueOnce('redis_version:7.0.0\r\n')
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
'used_memory_human:1.2G\nused_memory_peak_human:1.5G\nmem_fragmentation_ratio:1.5\n',
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce('connected_clients:5\n')
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
'total_connections_received:100\nkeyspace_hits:90\nkeyspace_misses:10\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.redis.status).toBe('up');
|
||||||
|
expect(result.redis.details).toBeDefined();
|
||||||
|
expect(result.redis.details.version).toBe('7.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return down status when redis fails', async () => {
|
||||||
|
mockRedis.ping.mockRejectedValueOnce(
|
||||||
|
new Error(HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.redis.status).toBe('down');
|
||||||
|
expect(result.redis.error).toBe(
|
||||||
|
HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should timeout after specified duration', async () => {
|
||||||
|
mockRedis.ping.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, HEALTH_INDICATORS_TIMEOUT + 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const healthCheckPromise = service.isHealthy();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(HEALTH_INDICATORS_TIMEOUT + 1);
|
||||||
|
|
||||||
|
const result = await healthCheckPromise;
|
||||||
|
|
||||||
|
expect(result.redis.status).toBe('down');
|
||||||
|
expect(result.redis.error).toBe(HEALTH_ERROR_MESSAGES.REDIS_TIMEOUT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial failures in health details collection', async () => {
|
||||||
|
mockRedis.info
|
||||||
|
.mockResolvedValueOnce('redis_version:7.0.0') // info
|
||||||
|
.mockResolvedValueOnce('used_memory_human:1.2G') // memory
|
||||||
|
.mockResolvedValueOnce('connected_clients:5') // clients
|
||||||
|
.mockResolvedValueOnce('total_connections_received:100'); // stats
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.redis.status).toBe('up');
|
||||||
|
expect(result.redis.details).toBeDefined();
|
||||||
|
expect(result.redis.details.version).toBe('7.0.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
import { HealthIndicatorService } from '@nestjs/terminus';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||||
|
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||||
|
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||||
|
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
|
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||||
|
|
||||||
|
const mockQueueInstance = {
|
||||||
|
getWorkers: jest.fn().mockResolvedValue([]),
|
||||||
|
close: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getFailedCount: jest.fn().mockResolvedValue(0),
|
||||||
|
getCompletedCount: jest.fn().mockResolvedValue(0),
|
||||||
|
getWaitingCount: jest.fn().mockResolvedValue(0),
|
||||||
|
getActiveCount: jest.fn().mockResolvedValue(0),
|
||||||
|
getDelayedCount: jest.fn().mockResolvedValue(0),
|
||||||
|
getPrioritizedCount: jest.fn().mockResolvedValue(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('bullmq', () => ({
|
||||||
|
Queue: jest.fn(() => mockQueueInstance),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('WorkerHealthIndicator', () => {
|
||||||
|
let service: WorkerHealthIndicator;
|
||||||
|
let mockRedis: jest.Mocked<Pick<Redis, 'ping'>>;
|
||||||
|
let healthIndicatorService: jest.Mocked<HealthIndicatorService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockRedis = {
|
||||||
|
ping: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRedisService = {
|
||||||
|
getClient: () => mockRedis,
|
||||||
|
} as unknown as RedisClientService;
|
||||||
|
|
||||||
|
healthIndicatorService = {
|
||||||
|
check: jest.fn().mockReturnValue({
|
||||||
|
up: jest.fn().mockImplementation((data) => ({
|
||||||
|
worker: { status: 'up', ...data },
|
||||||
|
})),
|
||||||
|
down: jest.fn().mockImplementation((error) => ({
|
||||||
|
worker: { status: 'down', error },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
WorkerHealthIndicator,
|
||||||
|
{
|
||||||
|
provide: RedisClientService,
|
||||||
|
useValue: mockRedisService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: HealthIndicatorService,
|
||||||
|
useValue: healthIndicatorService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<WorkerHealthIndicator>(WorkerHealthIndicator);
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return up status when workers are active', async () => {
|
||||||
|
mockQueueInstance.getWorkers.mockResolvedValue([{ id: 'worker1' }]);
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.worker.status).toBe('up');
|
||||||
|
expect('queues' in result.worker).toBe(true);
|
||||||
|
if ('queues' in result.worker) {
|
||||||
|
expect(result.worker.queues.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return down status when no workers are active', async () => {
|
||||||
|
mockQueueInstance.getWorkers.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.isHealthy();
|
||||||
|
|
||||||
|
expect(result.worker.status).toBe('down');
|
||||||
|
expect('error' in result.worker).toBe(true);
|
||||||
|
if ('error' in result.worker) {
|
||||||
|
expect(result.worker.error).toBe(HEALTH_ERROR_MESSAGES.NO_ACTIVE_WORKERS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should timeout after specified duration', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockQueueInstance.getWorkers.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, HEALTH_INDICATORS_TIMEOUT + 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultPromise = service.isHealthy();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(HEALTH_INDICATORS_TIMEOUT + 200);
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result.worker.status).toBe('down');
|
||||||
|
expect('error' in result.worker).toBe(true);
|
||||||
|
if ('error' in result.worker) {
|
||||||
|
expect(result.worker.error).toBe(HEALTH_ERROR_MESSAGES.WORKER_TIMEOUT);
|
||||||
|
}
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check all message queues', async () => {
|
||||||
|
mockQueueInstance.getWorkers.mockResolvedValue([{ id: 'worker1' }]);
|
||||||
|
|
||||||
|
await service.isHealthy();
|
||||||
|
|
||||||
|
expect(mockQueueInstance.getWorkers).toHaveBeenCalledTimes(
|
||||||
|
Object.keys(MessageQueue).length,
|
||||||
|
);
|
||||||
|
expect(mockQueueInstance.close).toHaveBeenCalledTimes(
|
||||||
|
Object.keys(MessageQueue).length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
HealthIndicatorResult,
|
||||||
|
HealthIndicatorService,
|
||||||
|
} from '@nestjs/terminus';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||||
|
import { withHealthCheckTimeout } from 'src/engine/core-modules/health/utils/health-check-timeout.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DatabaseHealthIndicator {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource('core')
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly healthIndicatorService: HealthIndicatorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async isHealthy(): Promise<HealthIndicatorResult> {
|
||||||
|
const indicator = this.healthIndicatorService.check('database');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
[versionResult],
|
||||||
|
[activeConnections],
|
||||||
|
[maxConnections],
|
||||||
|
[uptime],
|
||||||
|
[databaseSize],
|
||||||
|
tableStats,
|
||||||
|
[cacheHitRatio],
|
||||||
|
[deadlocks],
|
||||||
|
[slowQueries],
|
||||||
|
] = await withHealthCheckTimeout(
|
||||||
|
Promise.all([
|
||||||
|
this.dataSource.query('SELECT version()'),
|
||||||
|
this.dataSource.query(
|
||||||
|
'SELECT count(*) as count FROM pg_stat_activity',
|
||||||
|
),
|
||||||
|
this.dataSource.query('SHOW max_connections'),
|
||||||
|
this.dataSource.query(
|
||||||
|
'SELECT extract(epoch from current_timestamp - pg_postmaster_start_time()) as uptime',
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
'SELECT pg_size_pretty(pg_database_size(current_database())) as size',
|
||||||
|
),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT schemaname, relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
ORDER BY n_live_tup DESC
|
||||||
|
LIMIT 10
|
||||||
|
`),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
sum(heap_blks_hit) * 100.0 / (sum(heap_blks_hit) + sum(heap_blks_read)) as ratio
|
||||||
|
FROM pg_statio_user_tables
|
||||||
|
`),
|
||||||
|
this.dataSource.query(
|
||||||
|
'SELECT deadlocks FROM pg_stat_database WHERE datname = current_database()',
|
||||||
|
),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT count(*) as count
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE state = 'active'
|
||||||
|
AND query_start < now() - interval '1 minute'
|
||||||
|
`),
|
||||||
|
]),
|
||||||
|
HEALTH_ERROR_MESSAGES.DATABASE_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
return indicator.up({
|
||||||
|
details: {
|
||||||
|
version: versionResult.version,
|
||||||
|
connections: {
|
||||||
|
active: parseInt(activeConnections.count),
|
||||||
|
max: parseInt(maxConnections.max_connections),
|
||||||
|
utilizationPercent: Math.round(
|
||||||
|
(parseInt(activeConnections.count) /
|
||||||
|
parseInt(maxConnections.max_connections)) *
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
uptime: Math.round(uptime.uptime / 3600) + ' hours',
|
||||||
|
databaseSize: databaseSize.size,
|
||||||
|
performance: {
|
||||||
|
cacheHitRatio: Math.round(parseFloat(cacheHitRatio.ratio)) + '%',
|
||||||
|
deadlocks: parseInt(deadlocks.deadlocks),
|
||||||
|
slowQueries: parseInt(slowQueries.count),
|
||||||
|
},
|
||||||
|
top10Tables: tableStats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error.message === HEALTH_ERROR_MESSAGES.DATABASE_TIMEOUT
|
||||||
|
? HEALTH_ERROR_MESSAGES.DATABASE_TIMEOUT
|
||||||
|
: HEALTH_ERROR_MESSAGES.DATABASE_CONNECTION_FAILED;
|
||||||
|
|
||||||
|
return indicator.down(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
HealthIndicatorResult,
|
||||||
|
HealthIndicatorService,
|
||||||
|
} from '@nestjs/terminus';
|
||||||
|
|
||||||
|
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||||
|
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||||
|
import { withHealthCheckTimeout } from 'src/engine/core-modules/health/utils/health-check-timeout.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessageSyncHealthIndicator {
|
||||||
|
constructor(
|
||||||
|
private readonly healthIndicatorService: HealthIndicatorService,
|
||||||
|
private readonly healthCacheService: HealthCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async isHealthy(): Promise<HealthIndicatorResult> {
|
||||||
|
const indicator = this.healthIndicatorService.check('messageSync');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const counters = await withHealthCheckTimeout(
|
||||||
|
this.healthCacheService.getMessageChannelSyncJobByStatusCounter(),
|
||||||
|
HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalJobs = Object.values(counters).reduce(
|
||||||
|
(sum, count) => sum + (count || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedJobs = counters.FAILED_UNKNOWN || 0;
|
||||||
|
// + (counters.FAILED_INSUFFICIENT_PERMISSIONS || 0)
|
||||||
|
|
||||||
|
const failureRate =
|
||||||
|
totalJobs > 0
|
||||||
|
? Math.round((failedJobs / totalJobs) * 100 * 100) / 100
|
||||||
|
: 0;
|
||||||
|
const details = {
|
||||||
|
counters,
|
||||||
|
totalJobs,
|
||||||
|
failedJobs,
|
||||||
|
failureRate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (totalJobs === 0 || failureRate < 20) {
|
||||||
|
return indicator.up({ details });
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicator.down({
|
||||||
|
error: HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_HIGH_FAILURE_RATE,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error.message === HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_TIMEOUT
|
||||||
|
? HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_TIMEOUT
|
||||||
|
: HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_CHECK_FAILED;
|
||||||
|
|
||||||
|
return indicator.down(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
HealthIndicatorResult,
|
||||||
|
HealthIndicatorService,
|
||||||
|
} from '@nestjs/terminus';
|
||||||
|
|
||||||
|
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||||
|
import { withHealthCheckTimeout } from 'src/engine/core-modules/health/utils/health-check-timeout.util';
|
||||||
|
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisHealthIndicator {
|
||||||
|
constructor(
|
||||||
|
private readonly redisClient: RedisClientService,
|
||||||
|
private readonly healthIndicatorService: HealthIndicatorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async isHealthy(): Promise<HealthIndicatorResult> {
|
||||||
|
const indicator = this.healthIndicatorService.check('redis');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [info, memory, clients, stats] = await withHealthCheckTimeout(
|
||||||
|
Promise.all([
|
||||||
|
this.redisClient.getClient().info(),
|
||||||
|
this.redisClient.getClient().info('memory'),
|
||||||
|
this.redisClient.getClient().info('clients'),
|
||||||
|
this.redisClient.getClient().info('stats'),
|
||||||
|
]),
|
||||||
|
HEALTH_ERROR_MESSAGES.REDIS_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parseInfo = (info: string) => {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
info.split('\r\n').forEach((line) => {
|
||||||
|
const [key, value] = line.split(':');
|
||||||
|
|
||||||
|
if (key && value) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const infoData = parseInfo(info);
|
||||||
|
const memoryData = parseInfo(memory);
|
||||||
|
const clientsData = parseInfo(clients);
|
||||||
|
const statsData = parseInfo(stats);
|
||||||
|
|
||||||
|
return indicator.up({
|
||||||
|
details: {
|
||||||
|
version: infoData.redis_version,
|
||||||
|
uptime:
|
||||||
|
Math.round(parseInt(infoData.uptime_in_seconds) / 3600) + ' hours',
|
||||||
|
memory: {
|
||||||
|
used: memoryData.used_memory_human,
|
||||||
|
peak: memoryData.used_memory_peak_human,
|
||||||
|
fragmentation: parseFloat(memoryData.mem_fragmentation_ratio),
|
||||||
|
},
|
||||||
|
connections: {
|
||||||
|
current: parseInt(clientsData.connected_clients),
|
||||||
|
total: parseInt(statsData.total_connections_received),
|
||||||
|
rejected: parseInt(statsData.rejected_connections),
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
opsPerSecond: parseInt(statsData.instantaneous_ops_per_sec),
|
||||||
|
hitRate: statsData.keyspace_hits
|
||||||
|
? Math.round(
|
||||||
|
(parseInt(statsData.keyspace_hits) /
|
||||||
|
(parseInt(statsData.keyspace_hits) +
|
||||||
|
parseInt(statsData.keyspace_misses))) *
|
||||||
|
100,
|
||||||
|
) + '%'
|
||||||
|
: '0%',
|
||||||
|
evictedKeys: parseInt(statsData.evicted_keys),
|
||||||
|
expiredKeys: parseInt(statsData.expired_keys),
|
||||||
|
},
|
||||||
|
replication: {
|
||||||
|
role: infoData.role,
|
||||||
|
connectedSlaves: parseInt(infoData.connected_slaves || '0'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error.message === HEALTH_ERROR_MESSAGES.REDIS_TIMEOUT
|
||||||
|
? HEALTH_ERROR_MESSAGES.REDIS_TIMEOUT
|
||||||
|
: HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED;
|
||||||
|
|
||||||
|
return indicator.down(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HealthIndicatorService } from '@nestjs/terminus';
|
||||||
|
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
|
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||||
|
import { WorkerQueueHealth } from 'src/engine/core-modules/health/types/worker-queue-health.type';
|
||||||
|
import { withHealthCheckTimeout } from 'src/engine/core-modules/health/utils/health-check-timeout.util';
|
||||||
|
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
|
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkerHealthIndicator {
|
||||||
|
constructor(
|
||||||
|
private readonly redisClient: RedisClientService,
|
||||||
|
private readonly healthIndicatorService: HealthIndicatorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async isHealthy() {
|
||||||
|
const indicator = this.healthIndicatorService.check('worker');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workerStatus = await withHealthCheckTimeout(
|
||||||
|
this.checkWorkers(),
|
||||||
|
HEALTH_ERROR_MESSAGES.WORKER_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (workerStatus.status === 'up') {
|
||||||
|
return indicator.up({
|
||||||
|
queues: workerStatus.queues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicator.down(workerStatus.error);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error.message === HEALTH_ERROR_MESSAGES.WORKER_TIMEOUT
|
||||||
|
? HEALTH_ERROR_MESSAGES.WORKER_TIMEOUT
|
||||||
|
: HEALTH_ERROR_MESSAGES.WORKER_CHECK_FAILED;
|
||||||
|
|
||||||
|
return indicator.down(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkWorkers() {
|
||||||
|
const redis = this.redisClient.getClient();
|
||||||
|
const queues = Object.values(MessageQueue);
|
||||||
|
const queueStatuses: WorkerQueueHealth[] = [];
|
||||||
|
|
||||||
|
for (const queueName of queues) {
|
||||||
|
const queue = new Queue(queueName, { connection: redis });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workers = await queue.getWorkers();
|
||||||
|
|
||||||
|
if (workers.length > 0) {
|
||||||
|
const [
|
||||||
|
failedCount,
|
||||||
|
completedCount,
|
||||||
|
waitingCount,
|
||||||
|
activeCount,
|
||||||
|
delayedCount,
|
||||||
|
prioritizedCount,
|
||||||
|
] = await Promise.all([
|
||||||
|
queue.getFailedCount(),
|
||||||
|
queue.getCompletedCount(),
|
||||||
|
queue.getWaitingCount(),
|
||||||
|
queue.getActiveCount(),
|
||||||
|
queue.getDelayedCount(),
|
||||||
|
queue.getPrioritizedCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
queueStatuses.push({
|
||||||
|
name: queueName,
|
||||||
|
workers: workers.length,
|
||||||
|
metrics: {
|
||||||
|
failed: failedCount,
|
||||||
|
completed: completedCount,
|
||||||
|
waiting: waitingCount,
|
||||||
|
active: activeCount,
|
||||||
|
delayed: delayedCount,
|
||||||
|
prioritized: prioritizedCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await queue.close();
|
||||||
|
} catch (error) {
|
||||||
|
await queue.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveWorkers = queueStatuses.some((q) => q.workers > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: hasActiveWorkers ? 'up' : 'down',
|
||||||
|
error: hasActiveWorkers
|
||||||
|
? undefined
|
||||||
|
: HEALTH_ERROR_MESSAGES.NO_ACTIVE_WORKERS,
|
||||||
|
queues: queueStatuses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { MessageChannelSyncStatus } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
|
||||||
|
|
||||||
export type MessageChannelSyncJobByStatusCounter = {
|
|
||||||
[key in MessageChannelSyncStatus]?: number;
|
|
||||||
};
|
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class MessageChannelSyncJobByStatusCounter {
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
NOT_SYNCED?: number;
|
||||||
|
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
ONGOING?: number;
|
||||||
|
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
ACTIVE?: number;
|
||||||
|
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
FAILED_INSUFFICIENT_PERMISSIONS?: number;
|
||||||
|
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
FAILED_UNKNOWN?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { WorkerQueueMetrics } from 'src/engine/core-modules/health/types/worker-queue-metrics.type';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class WorkerQueueHealth {
|
||||||
|
@Field(() => String)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
workers: number;
|
||||||
|
|
||||||
|
@Field(() => WorkerQueueMetrics)
|
||||||
|
metrics: WorkerQueueMetrics;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class WorkerQueueMetrics {
|
||||||
|
@Field(() => Number)
|
||||||
|
failed: number;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
completed: number;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
waiting: number;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
active: number;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
delayed: number;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
prioritized: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||||
|
|
||||||
|
export const withHealthCheckTimeout = async <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
errorMessage: string,
|
||||||
|
): Promise<T> => {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error(errorMessage)),
|
||||||
|
HEALTH_INDICATORS_TIMEOUT,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
};
|
||||||
39
yarn.lock
39
yarn.lock
@ -8421,26 +8421,27 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@nestjs/terminus@npm:^9.2.2":
|
"@nestjs/terminus@npm:^11.0.0":
|
||||||
version: 9.2.2
|
version: 11.0.0
|
||||||
resolution: "@nestjs/terminus@npm:9.2.2"
|
resolution: "@nestjs/terminus@npm:11.0.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
boxen: "npm:5.1.2"
|
boxen: "npm:5.1.2"
|
||||||
check-disk-space: "npm:3.3.1"
|
check-disk-space: "npm:3.4.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@grpc/grpc-js": "*"
|
"@grpc/grpc-js": "*"
|
||||||
"@grpc/proto-loader": "*"
|
"@grpc/proto-loader": "*"
|
||||||
"@mikro-orm/core": "*"
|
"@mikro-orm/core": "*"
|
||||||
"@mikro-orm/nestjs": "*"
|
"@mikro-orm/nestjs": "*"
|
||||||
"@nestjs/axios": "*"
|
"@nestjs/axios": ^2.0.0 || ^3.0.0 || ^4.0.0
|
||||||
"@nestjs/common": 9.x
|
"@nestjs/common": ^10.0.0 || ^11.0.0
|
||||||
"@nestjs/core": 9.x
|
"@nestjs/core": ^10.0.0 || ^11.0.0
|
||||||
"@nestjs/microservices": "*"
|
"@nestjs/microservices": ^10.0.0 || ^11.0.0
|
||||||
"@nestjs/mongoose": "*"
|
"@nestjs/mongoose": ^11.0.0
|
||||||
"@nestjs/sequelize": "*"
|
"@nestjs/sequelize": ^10.0.0 || ^11.0.0
|
||||||
"@nestjs/typeorm": "*"
|
"@nestjs/typeorm": ^10.0.0 || ^11.0.0
|
||||||
|
"@prisma/client": "*"
|
||||||
mongoose: "*"
|
mongoose: "*"
|
||||||
reflect-metadata: 0.1.x
|
reflect-metadata: 0.1.x || 0.2.x
|
||||||
rxjs: 7.x
|
rxjs: 7.x
|
||||||
sequelize: "*"
|
sequelize: "*"
|
||||||
typeorm: "*"
|
typeorm: "*"
|
||||||
@ -8463,13 +8464,15 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
"@nestjs/typeorm":
|
"@nestjs/typeorm":
|
||||||
optional: true
|
optional: true
|
||||||
|
"@prisma/client":
|
||||||
|
optional: true
|
||||||
mongoose:
|
mongoose:
|
||||||
optional: true
|
optional: true
|
||||||
sequelize:
|
sequelize:
|
||||||
optional: true
|
optional: true
|
||||||
typeorm:
|
typeorm:
|
||||||
optional: true
|
optional: true
|
||||||
checksum: 10c0/3de4a3ce831c5a31ee2ce7327ffdb8180a1d4c270c18ff405484cd4bb60d225c001705add3df76b86221c3c7e1cc386ae60133b4cb4ac7f5e8dac62d2fd7cddb
|
checksum: 10c0/6c2d019b7a0f91bc68e654b44e45d93998d3a30919aaee8792e337d33183452817cd462706101f39157813d2aabb5d2ac81bc392c2ba943bfeaeb65920da15db
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -23510,10 +23513,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"check-disk-space@npm:3.3.1":
|
"check-disk-space@npm:3.4.0":
|
||||||
version: 3.3.1
|
version: 3.4.0
|
||||||
resolution: "check-disk-space@npm:3.3.1"
|
resolution: "check-disk-space@npm:3.4.0"
|
||||||
checksum: 10c0/c5717bf4a2ae7b03bc6aff7a5c87149cbede3802f078ff56c8524d64329e788e2abcc270943ebc132819c40d110a0338c2670e962ce4f13d572da7a6c06af87c
|
checksum: 10c0/cc39c91e1337e974fb5069c2fbd9eb92aceca6e35f3da6863a4eada58f15c1bf6970055bffed1e41c15cde1fd0ad2580bb99bef8275791ed56d69947f8657aa5
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -47133,7 +47136,7 @@ __metadata:
|
|||||||
"@nestjs/platform-express": "npm:^9.0.0"
|
"@nestjs/platform-express": "npm:^9.0.0"
|
||||||
"@nestjs/schematics": "npm:^9.0.0"
|
"@nestjs/schematics": "npm:^9.0.0"
|
||||||
"@nestjs/serve-static": "npm:^4.0.1"
|
"@nestjs/serve-static": "npm:^4.0.1"
|
||||||
"@nestjs/terminus": "npm:^9.2.2"
|
"@nestjs/terminus": "npm:^11.0.0"
|
||||||
"@nestjs/testing": "npm:^9.0.0"
|
"@nestjs/testing": "npm:^9.0.0"
|
||||||
"@nestjs/typeorm": "npm:^10.0.0"
|
"@nestjs/typeorm": "npm:^10.0.0"
|
||||||
"@next/eslint-plugin-next": "npm:^14.1.4"
|
"@next/eslint-plugin-next": "npm:^14.1.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user