Health status worker metrics improvements (#10442)

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
nitin
2025-03-04 12:47:12 +05:30
committed by GitHub
parent 41db10daff
commit 327f0cd370
27 changed files with 1468 additions and 312 deletions

View File

@ -1,4 +1,5 @@
import { SettingsAdminEnvVariablesTable } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesTable';
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import styled from '@emotion/styled';
import { useState } from 'react';
import { Button, H1Title, H1TitleFontColor, Section } from 'twenty-ui';
@ -37,11 +38,10 @@ const StyledShowMoreButton = styled(Button)<{ isSelected?: boolean }>`
`;
export const SettingsAdminEnvVariables = () => {
const { data: environmentVariables } = useGetEnvironmentVariablesGroupedQuery(
{
const { data: environmentVariables, loading: environmentVariablesLoading } =
useGetEnvironmentVariablesGroupedQuery({
fetchPolicy: 'network-only',
},
);
});
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
@ -64,6 +64,10 @@ export const SettingsAdminEnvVariables = () => {
(group) => group.name === selectedGroup,
);
if (environmentVariablesLoading) {
return <SettingsAdminTabSkeletonLoader />;
}
return (
<>
<Section>

View File

@ -0,0 +1,17 @@
import { useTheme } from '@emotion/react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
export const SettingsAdminTabSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} width={120} />
</SkeletonTheme>
);
};

View File

@ -19,9 +19,7 @@ const StyledErrorMessage = styled.div`
`;
export const DatabaseAndRedisHealthStatus = () => {
const { indicatorHealth, loading } = useContext(
SettingsAdminIndicatorHealthContext,
);
const { indicatorHealth } = useContext(SettingsAdminIndicatorHealthContext);
const formattedDetails = indicatorHealth.details
? JSON.stringify(JSON.parse(indicatorHealth.details), null, 2)
@ -33,7 +31,7 @@ export const DatabaseAndRedisHealthStatus = () => {
return (
<Section>
{isDatabaseOrRedisDown && !loading ? (
{isDatabaseOrRedisDown ? (
<StyledErrorMessage>
{`${indicatorHealth.label} information is not available because the service is down`}
</StyledErrorMessage>

View File

@ -1,18 +1,27 @@
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsHealthStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsHealthStatusListCard';
import { H2Title, Section } from 'twenty-ui';
import { useGetSystemHealthStatusQuery } from '~/generated/graphql';
export const SettingsAdminHealthStatus = () => {
const { data, loading } = useGetSystemHealthStatusQuery({
const { data, loading: loadingHealthStatus } = useGetSystemHealthStatusQuery({
fetchPolicy: 'network-only',
});
const services = data?.getSystemHealthStatus.services ?? [];
if (loadingHealthStatus) {
return <SettingsAdminTabSkeletonLoader />;
}
return (
<>
<Section>
<H2Title title="Health Status" description="How your system is doing" />
<SettingsHealthStatusListCard services={services} loading={loading} />
<SettingsHealthStatusListCard
services={services}
loading={loadingHealthStatus}
/>
</Section>
</>
);

View File

@ -1,105 +0,0 @@
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.queueName === selectedQueue,
);
return (
<AnimatedExpandableContainer
isExpanded={!!selectedQueue}
mode="fit-content"
>
{selectedQueueData && (
<>
<StyledContainer>
<SettingsListCard
items={[
{ ...selectedQueueData, id: selectedQueueData.queueName },
]}
getItemLabel={(
item: AdminPanelWorkerQueueHealth & { id: string },
) => item.queueName}
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>
);
};

View File

@ -1,52 +0,0 @@
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.queueName}
onClick={() => toggleQueueVisibility(queue.queueName)}
title={queue.queueName}
variant="secondary"
isSelected={selectedQueue === queue.queueName}
status={queue.status}
/>
))}
</StyledQueueButtonsRow>
);
};

View File

@ -1,16 +1,8 @@
import { SettingsAdminQueueExpandableContainer } from '@/settings/admin-panel/health-status/components/SettingsAdminQueueExpandableContainer';
import { SettingsAdminQueueHealthButtons } from '@/settings/admin-panel/health-status/components/SettingsAdminQueueHealthButtons';
import { SettingsAdminIndicatorHealthContext } from '@/settings/admin-panel/health-status/contexts/SettingsAdminIndicatorHealthContext';
import { WorkerQueueMetricsSection } from '@/settings/admin-panel/health-status/components/WorkerQueueMetricsSection';
import styled from '@emotion/styled';
import { useContext, useState } from 'react';
import { H2Title, Section } from 'twenty-ui';
import { useContext } from 'react';
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
const StyledTitleContainer = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`;
import { SettingsAdminIndicatorHealthContext } from '../contexts/SettingsAdminIndicatorHealthContext';
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.color.red};
@ -18,45 +10,23 @@ const StyledErrorMessage = styled.div`
`;
export const WorkerHealthStatus = () => {
const { indicatorHealth, loading } = useContext(
SettingsAdminIndicatorHealthContext,
);
const { indicatorHealth } = useContext(SettingsAdminIndicatorHealthContext);
const isWorkerDown =
!indicatorHealth.status ||
indicatorHealth.status === AdminPanelHealthServiceStatus.OUTAGE;
const [selectedQueue, setSelectedQueue] = useState<string | null>(null);
const toggleQueueVisibility = (queueName: string) => {
setSelectedQueue(selectedQueue === queueName ? null : queueName);
};
return (
<Section>
<StyledTitleContainer>
<H2Title
title="Queue Status"
description="Background job processing status and metrics"
/>
</StyledTitleContainer>
{isWorkerDown && !loading ? (
<>
{isWorkerDown ? (
<StyledErrorMessage>
Queue information is not available because the worker is down
</StyledErrorMessage>
) : (
<>
<SettingsAdminQueueHealthButtons
queues={indicatorHealth.queues ?? []}
selectedQueue={selectedQueue}
toggleQueueVisibility={toggleQueueVisibility}
/>
<SettingsAdminQueueExpandableContainer
queues={indicatorHealth.queues ?? []}
selectedQueue={selectedQueue}
/>
</>
(indicatorHealth.queues ?? []).map((queue) => (
<WorkerQueueMetricsSection key={queue.queueName} queue={queue} />
))
)}
</Section>
</>
);
};

View File

@ -0,0 +1,315 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ResponsiveLine } from '@nivo/line';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
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 {
QueueMetricsTimeRange,
useGetQueueMetricsQuery,
} from '~/generated/graphql';
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)};
`;
const StyledGraphContainer = styled.div`
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm};
height: 230px;
margin-bottom: ${({ theme }) => theme.spacing(4)};
padding-top: ${({ theme }) => theme.spacing(4)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledQueueMetricsContainer = 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 StyledGraphControls = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: flex-end;
margin-bottom: ${({ theme }) => theme.spacing(2)};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
const StyledNoDataMessage = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
height: 100%;
justify-content: center;
`;
const StyledTooltipContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: ${({ theme }) => theme.spacing(2)};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledTooltipItem = styled.div<{ color: string }>`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
padding: ${({ theme }) => theme.spacing(0.5)} 0;
`;
const StyledTooltipColorSquare = styled.div<{ color: string }>`
width: 12px;
height: 12px;
background-color: ${({ color }) => color};
margin-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledTooltipValue = styled.span`
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
type WorkerMetricsGraphProps = {
queueName: string;
timeRange: QueueMetricsTimeRange;
onTimeRangeChange: (range: QueueMetricsTimeRange) => void;
};
export const WorkerMetricsGraph = ({
queueName,
timeRange,
onTimeRangeChange,
}: WorkerMetricsGraphProps) => {
const theme = useTheme();
const { enqueueSnackBar } = useSnackBar();
const { loading, data } = useGetQueueMetricsQuery({
variables: {
queueName,
timeRange,
},
fetchPolicy: 'no-cache',
onError: (error) => {
enqueueSnackBar(`Error fetching worker metrics: ${error.message}`, {
variant: SnackBarVariant.Error,
});
},
});
const metricsData = data?.getQueueMetrics?.data || [];
const hasData =
metricsData.length > 0 &&
metricsData.some((series) => series.data.length > 0);
const metricsDetails = {
workers: data?.getQueueMetrics?.workers,
...data?.getQueueMetrics?.details,
};
const getMaxYValue = () => {
if (!hasData) return 2;
let maxValue = 0;
metricsData.forEach((series) => {
series.data.forEach((point) => {
if (typeof point.y === 'number' && point.y > maxValue) {
maxValue = point.y;
}
});
});
return maxValue === 0 ? 2 : maxValue * 1.1;
};
const getAxisLabel = () => {
switch (timeRange) {
case QueueMetricsTimeRange.OneHour:
return 'Last 1 Hour (oldest → newest)';
case QueueMetricsTimeRange.FourHours:
return 'Last 4 Hours (oldest → newest)';
case QueueMetricsTimeRange.TwelveHours:
return 'Last 12 Hours (oldest → newest)';
case QueueMetricsTimeRange.OneDay:
return 'Last 24 Hours (oldest → newest)';
case QueueMetricsTimeRange.SevenDays:
return 'Last 7 Days (oldest → newest)';
default:
return 'Recent Events (oldest → newest)';
}
};
return (
<>
<StyledGraphControls>
<Select
dropdownId={`timerange-${queueName}`}
value={timeRange}
options={[
{ value: QueueMetricsTimeRange.SevenDays, label: 'This week' },
{ value: QueueMetricsTimeRange.OneDay, label: 'Today' },
{
value: QueueMetricsTimeRange.TwelveHours,
label: 'Last 12 hours',
},
{ value: QueueMetricsTimeRange.FourHours, label: 'Last 4 hours' },
{ value: QueueMetricsTimeRange.OneHour, label: 'Last 1 hour' },
]}
onChange={onTimeRangeChange}
needIconCheck
/>
</StyledGraphControls>
<StyledGraphContainer>
{loading ? (
<StyledNoDataMessage>Loading metrics data...</StyledNoDataMessage>
) : hasData ? (
<ResponsiveLine
data={metricsData}
curve="monotoneX"
enableArea={true}
colors={[theme.color.green, theme.color.red]}
theme={{
text: {
fill: theme.font.color.light,
fontSize: theme.font.size.sm,
fontFamily: theme.font.family,
},
axis: {
domain: {
line: {
stroke: theme.border.color.strong,
},
},
ticks: {
line: {
stroke: theme.border.color.strong,
},
},
},
grid: {
line: {
stroke: theme.border.color.medium,
},
},
crosshair: {
line: {
stroke: theme.font.color.primary,
strokeDasharray: '2 2',
},
},
}}
margin={{ top: 40, right: 30, bottom: 40, left: 50 }}
xScale={{
type: 'linear',
min: 0,
max: 'auto',
}}
yScale={{
type: 'linear',
min: 0,
max: getMaxYValue(),
stacked: false,
}}
axisBottom={{
legend: getAxisLabel(),
legendOffset: 30,
legendPosition: 'middle',
tickSize: 5,
tickPadding: 5,
tickValues: 5,
format: () => '',
}}
axisLeft={{
tickSize: 6,
tickPadding: 5,
tickValues: 4,
legend: 'Count',
legendOffset: -40,
legendPosition: 'middle',
}}
enableGridX={false}
gridYValues={4}
pointSize={0}
enableSlices="x"
sliceTooltip={({ slice }) => (
<StyledTooltipContainer>
{slice.points.map((point) => (
<StyledTooltipItem key={point.id} color={point.serieColor}>
<StyledTooltipColorSquare color={point.serieColor} />
<span>
{point.serieId}:{' '}
<StyledTooltipValue>
{String(point.data.y)}
</StyledTooltipValue>
</span>
</StyledTooltipItem>
))}
</StyledTooltipContainer>
)}
useMesh={true}
legends={[
{
anchor: 'top-right',
direction: 'row',
justify: false,
translateX: 0,
translateY: -40,
itemsSpacing: 10,
itemDirection: 'left-to-right',
itemWidth: 100,
itemHeight: 20,
symbolSize: 12,
symbolShape: 'square',
},
]}
/>
) : (
<StyledNoDataMessage>No metrics data available</StyledNoDataMessage>
)}
</StyledGraphContainer>
{metricsDetails && (
<>
<StyledQueueMetricsTitle>Metrics:</StyledQueueMetricsTitle>
<StyledQueueMetricsContainer>
<Table>
{Object.entries(metricsDetails)
.filter(([key]) => key !== '__typename')
.map(([key, value]) => (
<StyledTableRow key={key}>
<TableCell align="left">
{key.charAt(0).toUpperCase() + key.slice(1)}
</TableCell>
<TableCell align="right">
{typeof value === 'number'
? value
: Array.isArray(value)
? value.length
: String(value)}
</TableCell>
</StyledTableRow>
))}
</Table>
</StyledQueueMetricsContainer>
</>
)}
</>
);
};

View File

@ -0,0 +1,28 @@
import { useState } from 'react';
import { H2Title, Section } from 'twenty-ui';
import {
AdminPanelWorkerQueueHealth,
QueueMetricsTimeRange,
} from '~/generated/graphql';
import { WorkerMetricsGraph } from './WorkerMetricsGraph';
type WorkerQueueMetricsSectionProps = {
queue: AdminPanelWorkerQueueHealth;
};
export const WorkerQueueMetricsSection = ({
queue,
}: WorkerQueueMetricsSectionProps) => {
const [timeRange, setTimeRange] = useState(QueueMetricsTimeRange.OneHour);
return (
<Section>
<H2Title title={queue.queueName} description="Queue performance" />
<WorkerMetricsGraph
queueName={queue.queueName}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
/>
</Section>
);
};

View File

@ -6,7 +6,6 @@ import {
type SettingsAdminIndicatorHealthContextType = {
indicatorHealth: AdminPanelHealthServiceData;
loading: boolean;
};
export const SettingsAdminIndicatorHealthContext =
@ -19,5 +18,4 @@ export const SettingsAdminIndicatorHealthContext =
details: '',
queues: [],
},
loading: false,
});

View File

@ -12,15 +12,6 @@ export const GET_INDICATOR_HEALTH_STATUS = gql`
id
queueName
status
workers
metrics {
failed
completed
waiting
active
delayed
prioritized
}
}
}
}

View File

@ -0,0 +1,29 @@
import { gql } from '@apollo/client';
export const GET_QUEUE_METRICS = gql`
query GetQueueMetrics(
$queueName: String!
$timeRange: QueueMetricsTimeRange
) {
getQueueMetrics(queueName: $queueName, timeRange: $timeRange) {
queueName
timeRange
workers
details {
failed
completed
waiting
active
delayed
failureRate
}
data {
id
data {
x
y
}
}
}
}
`;