Add queue management dashboard (#15202)

Adds a comprehensive queue management interface to the admin panel for
viewing and managing background jobs.

**Features:**
- Queue detail pages showing paginated job lists (50 per page)
- Filter jobs by state: completed, failed, active, waiting, delayed,
paused
- Checkbox selection with bulk actions (delete jobs, retry failed jobs)
- Per-job dropdown menu for individual retry/delete
- Expandable rows showing error messages, stack traces, and job data
- Relative timestamps with hover tooltips
- Display attempt counts on failed jobs
- Dynamic retention policy info from backend

**Changes:**
- Backend: New AdminPanelQueueService with GraphQL endpoints for job
listing, retry, and delete
- Frontend: Queue detail page with QueueJobsTable component
- Updated retention policy: completed jobs kept 4 hours, failed jobs
kept 7 days (max 1000 each)
- Added JobState enum for type safety

<img width="634" height="696" alt="Screenshot_2025-10-20_at_11 45 25"
src="https://github.com/user-attachments/assets/c67bcd27-26cf-47f5-9575-3cd5684d006b"
/>
<img width="484" height="680" alt="Screenshot_2025-10-20_at_11 45 14"
src="https://github.com/user-attachments/assets/68725cc6-b3ec-4098-99ca-f9a717d6f8f1"
/>
<img width="490" height="643" alt="Screenshot_2025-10-20_at_11 45 05"
src="https://github.com/user-attachments/assets/b68a5809-33ff-4452-b48b-741aff7f1dd6"
/>



<img width="685" height="662" alt="Screenshot 2025-10-20 at 13 15 01"
src="https://github.com/user-attachments/assets/eeb5207b-de5c-4b18-bdde-392892053dab"
/>
This commit is contained in:
Félix Malfait
2025-10-21 16:02:50 +02:00
committed by GitHub
parent 27f50c4f4e
commit f7421c5fc0
144 changed files with 2914 additions and 955 deletions
@@ -16,7 +16,7 @@ const StyledContainer = styled.div`
gap: ${({ theme }) => theme.spacing(8)};
`;
export const ConnectedAccountHealthStatus = () => {
export const SettingsAdminConnectedAccountHealthStatus = () => {
const { indicatorHealth } = useContext(SettingsAdminIndicatorHealthContext);
const details = indicatorHealth.details;
if (!details) {
@@ -32,21 +32,24 @@ export const ConnectedAccountHealthStatus = () => {
serviceDetails.calendarSync?.status ===
AdminPanelHealthServiceStatus.OUTAGE;
const errorMessages = [];
if (isMessageSyncDown) {
errorMessages.push('Message Sync');
}
if (isCalendarSyncDown) {
errorMessages.push('Calendar Sync');
}
const getErrorMessage = () => {
if (isMessageSyncDown && isCalendarSyncDown) {
return t`Message Sync and Calendar Sync are not available because the service is down`;
}
if (isMessageSyncDown) {
return t`Message Sync is not available because the service is down`;
}
if (isCalendarSyncDown) {
return t`Calendar Sync is not available because the service is down`;
}
return null;
};
const errorMessage = getErrorMessage();
return (
<StyledContainer>
{errorMessages.length > 0 && (
<StyledErrorMessage>
{`${errorMessages.join(' and ')} ${errorMessages.length > 1 ? 'are' : 'is'} not available because the service is down`}
</StyledErrorMessage>
)}
{errorMessage && <StyledErrorMessage>{errorMessage}</StyledErrorMessage>}
{!isMessageSyncDown && serviceDetails.messageSync?.details && (
<SettingsAdminHealthAccountSyncCountersTable
@@ -0,0 +1,38 @@
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { plural, t } from '@lingui/core/macro';
type SettingsAdminDeleteJobsConfirmationModalProps = {
modalId: string;
jobCount: number;
onConfirm: () => void;
onClose?: () => void;
};
export const SettingsAdminDeleteJobsConfirmationModal = ({
modalId,
jobCount,
onConfirm,
onClose,
}: SettingsAdminDeleteJobsConfirmationModalProps) => {
const title = plural(jobCount, {
one: `Delete ${jobCount} Job`,
other: `Delete ${jobCount} Jobs`,
});
const subtitle = plural(jobCount, {
one: `This will permanently remove it from the queue. This action cannot be undone.`,
other: `This will permanently remove them from the queue. This action cannot be undone.`,
});
return (
<ConfirmationModal
modalId={modalId}
title={title}
subtitle={subtitle}
onConfirmClick={onConfirm}
onClose={onClose}
confirmButtonText={t`Delete`}
confirmButtonAccent="danger"
/>
);
};
@@ -1,5 +1,5 @@
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsHealthStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsHealthStatusListCard';
import { SettingsAdminHealthStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatusListCard';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
@@ -23,7 +23,7 @@ export const SettingsAdminHealthStatus = () => {
title={t`Health Status`}
description={t`How your system is doing`}
/>
<SettingsHealthStatusListCard
<SettingsAdminHealthStatusListCard
services={services}
loading={loadingHealthStatus}
/>
@@ -25,7 +25,7 @@ const HealthStatusIcons: { [k in HealthIndicatorId]: IconComponent } = {
[HealthIndicatorId.app]: IconAppWindow,
};
export const SettingsHealthStatusListCard = ({
export const SettingsAdminHealthStatusListCard = ({
services,
loading,
}: {
@@ -1,6 +1,6 @@
import { ConnectedAccountHealthStatus } from '@/settings/admin-panel/health-status/components/ConnectedAccountHealthStatus';
import { JsonDataIndicatorHealthStatus } from '@/settings/admin-panel/health-status/components/JsonDataIndicatorHealthStatus';
import { WorkerHealthStatus } from '@/settings/admin-panel/health-status/components/WorkerHealthStatus';
import { SettingsAdminConnectedAccountHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminConnectedAccountHealthStatus';
import { SettingsAdminJsonDataIndicatorHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminJsonDataIndicatorHealthStatus';
import { SettingsAdminWorkerHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminWorkerHealthStatus';
import { useParams } from 'react-router-dom';
import { HealthIndicatorId } from '~/generated/graphql';
@@ -11,11 +11,11 @@ export const SettingsAdminIndicatorHealthStatusContent = () => {
case HealthIndicatorId.database:
case HealthIndicatorId.redis:
case HealthIndicatorId.app:
return <JsonDataIndicatorHealthStatus />;
return <SettingsAdminJsonDataIndicatorHealthStatus />;
case HealthIndicatorId.worker:
return <WorkerHealthStatus />;
return <SettingsAdminWorkerHealthStatus />;
case HealthIndicatorId.connectedAccount:
return <ConnectedAccountHealthStatus />;
return <SettingsAdminConnectedAccountHealthStatus />;
default:
return null;
@@ -0,0 +1,140 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { JsonTree } from 'twenty-ui/json-visualizer';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { type QueueJob } from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
type SettingsAdminJobDetailsExpandableProps = {
job: QueueJob;
isExpanded: boolean;
};
const StyledDetailsContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
padding: ${({ theme }) => theme.spacing(4)};
`;
const StyledSection = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(4)};
&:last-child {
margin-bottom: 0;
}
`;
const StyledSectionTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledPreformattedText = styled.pre`
background-color: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.danger};
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.sm};
margin: 0;
max-height: 300px;
overflow: auto;
padding: ${({ theme }) => theme.spacing(2)};
white-space: pre-wrap;
word-break: break-word;
`;
const StyledLogEntry = styled.div`
background-color: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.sm};
margin-bottom: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAdminJobDetailsExpandable = ({
job,
isExpanded,
}: SettingsAdminJobDetailsExpandableProps) => {
const { copyToClipboard } = useCopyToClipboard();
const hasData = job.data && Object.keys(job.data).length > 0;
const hasReturnValue =
job.returnValue && Object.keys(job.returnValue).length > 0;
const hasLogs = job.logs && job.logs.length > 0;
const hasStacktrace = job.stackTrace && job.stackTrace.length > 0;
const hasFailedReason = job.failedReason;
const isAnyNode = () => true;
return (
<AnimatedExpandableContainer
isExpanded={isExpanded}
dimension="height"
mode="scroll-height"
>
<StyledDetailsContainer>
{hasFailedReason && (
<StyledSection>
<StyledSectionTitle>{t`Error Message`}</StyledSectionTitle>
<StyledPreformattedText>{job.failedReason}</StyledPreformattedText>
</StyledSection>
)}
{hasStacktrace && job.stackTrace && (
<StyledSection>
<StyledSectionTitle>{t`Stack Trace`}</StyledSectionTitle>
<StyledPreformattedText>
{job.stackTrace.join('\n')}
</StyledPreformattedText>
</StyledSection>
)}
{hasReturnValue && (
<StyledSection>
<StyledSectionTitle>{t`Return Value`}</StyledSectionTitle>
<JsonTree
value={job.returnValue}
shouldExpandNodeInitially={isAnyNode}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</StyledSection>
)}
{hasData && (
<StyledSection>
<StyledSectionTitle>{t`Job Data`}</StyledSectionTitle>
<JsonTree
value={job.data}
shouldExpandNodeInitially={isAnyNode}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</StyledSection>
)}
{hasLogs && (
<StyledSection>
<StyledSectionTitle>{t`Logs`}</StyledSectionTitle>
{job.logs?.map((log, index) => (
<StyledLogEntry key={index}>{log}</StyledLogEntry>
))}
</StyledSection>
)}
</StyledDetailsContainer>
</AnimatedExpandableContainer>
);
};
@@ -0,0 +1,52 @@
import styled from '@emotion/styled';
import { Tag, type TagColor } from 'twenty-ui/components';
import { JobState } from '~/generated-metadata/graphql';
type SettingsAdminJobStateBadgeProps = {
state: JobState;
attemptsMade?: number;
};
const JOB_STATE_COLORS: Record<JobState, TagColor> = {
[JobState.COMPLETED]: 'green',
[JobState.FAILED]: 'red',
[JobState.ACTIVE]: 'blue',
[JobState.WAITING]: 'gray',
[JobState.DELAYED]: 'orange',
[JobState.PRIORITIZED]: 'blue',
[JobState.WAITING_CHILDREN]: 'gray',
};
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledAttemptBadge = styled.span`
background-color: ${({ theme }) => theme.background.danger};
border: 1px solid ${({ theme }) => theme.border.color.danger};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.danger};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding: ${({ theme }) => `${theme.spacing(0.5)} ${theme.spacing(1)}`};
white-space: nowrap;
`;
export const SettingsAdminJobStateBadge = ({
state,
attemptsMade = 1,
}: SettingsAdminJobStateBadgeProps) => {
const color = JOB_STATE_COLORS[state] || 'gray';
const showAttempts = attemptsMade > 1;
return (
<StyledContainer>
<Tag color={color} text={state} />
{showAttempts && (
<StyledAttemptBadge>{attemptsMade} attempts</StyledAttemptBadge>
)}
</StyledContainer>
);
};
@@ -22,7 +22,7 @@ const StyledErrorMessage = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
export const JsonDataIndicatorHealthStatus = () => {
export const SettingsAdminJsonDataIndicatorHealthStatus = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
@@ -0,0 +1,69 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { t } from '@lingui/core/macro';
import { IconDotsVertical, IconRefresh, IconTrash } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { JobState } from '~/generated-metadata/graphql';
type SettingsAdminQueueJobRowDropdownMenuProps = {
jobId: string;
jobState: JobState;
onRetry?: () => void;
onDelete: () => void;
};
export const SettingsAdminQueueJobRowDropdownMenu = ({
jobId,
jobState,
onRetry,
onDelete,
}: SettingsAdminQueueJobRowDropdownMenuProps) => {
const dropdownId = `queue-job-row-${jobId}`;
const { closeDropdown } = useCloseDropdown();
const handleRetry = () => {
onRetry?.();
closeDropdown(dropdownId);
};
const handleDelete = () => {
onDelete();
closeDropdown(dropdownId);
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
clickableComponent={
<LightIconButton
aria-label="Job Actions"
Icon={IconDotsVertical}
accent="tertiary"
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
{jobState === JobState.FAILED && onRetry && (
<MenuItem
text={t`Retry`}
LeftIcon={IconRefresh}
onClick={handleRetry}
/>
)}
<MenuItem
accent="danger"
text={t`Delete`}
LeftIcon={IconTrash}
onClick={handleDelete}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,412 @@
import { SettingsAdminDeleteJobsConfirmationModal } from '@/settings/admin-panel/health-status/components/SettingsAdminDeleteJobsConfirmationModal';
import { SettingsAdminJobDetailsExpandable } from '@/settings/admin-panel/health-status/components/SettingsAdminJobDetailsExpandable';
import { SettingsAdminJobStateBadge } from '@/settings/admin-panel/health-status/components/SettingsAdminJobStateBadge';
import { SettingsAdminQueueJobRowDropdownMenu } from '@/settings/admin-panel/health-status/components/SettingsAdminQueueJobRowDropdownMenu';
import { SettingsAdminRetryJobsConfirmationModal } from '@/settings/admin-panel/health-status/components/SettingsAdminRetryJobsConfirmationModal';
import { useDeleteJobs } from '@/settings/admin-panel/health-status/hooks/useDeleteJobs';
import { useRetryJobs } from '@/settings/admin-panel/health-status/hooks/useRetryJobs';
import { Select } from '@/ui/input/components/Select';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { plural, t } from '@lingui/core/macro';
import { useState } from 'react';
import { IconRefresh, IconTrash } from 'twenty-ui/display';
import { Button, Checkbox } from 'twenty-ui/input';
import {
JobState,
type QueueJob,
useGetQueueJobsQuery,
} from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
type SettingsAdminQueueJobsTableProps = {
queueName: string;
onRetentionConfigLoaded?: (config: {
completedMaxAge: number;
completedMaxCount: number;
failedMaxAge: number;
failedMaxCount: number;
}) => void;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledControlsContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
`;
const StyledEmptyState = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
padding: ${({ theme }) => theme.spacing(8)};
text-align: center;
`;
const StyledPaginationContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledTableCell = styled(TableCell)`
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledExpandableTableRow = styled(TableRow)<{ isExpanded: boolean }>`
cursor: pointer;
background-color: ${({ theme, isExpanded }) =>
isExpanded ? theme.background.transparent.light : 'transparent'};
&:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
const StyledJobRowWrapper = styled.div`
display: contents;
`;
const StyledCheckboxCell = styled(TableCell)`
justify-content: center;
padding: 0;
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledHeaderCheckboxCell = styled(TableHeader)`
align-items: center;
display: flex;
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledButtonGroup = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const RETRY_MODAL_ID = 'retry-jobs-modal';
const DELETE_MODAL_ID = 'delete-jobs-modal';
const LIMIT = 50;
export const SettingsAdminQueueJobsTable = ({
queueName,
onRetentionConfigLoaded,
}: SettingsAdminQueueJobsTableProps) => {
const [page, setPage] = useState(0);
const [stateFilter, setStateFilter] = useState<JobState>(JobState.COMPLETED);
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(new Set());
const { openModal } = useModal();
const jobStateOptions: { value: JobState; label: string }[] = [
{ value: JobState.COMPLETED, label: t`Completed` },
{ value: JobState.FAILED, label: t`Failed` },
{ value: JobState.ACTIVE, label: t`Active` },
{ value: JobState.WAITING, label: t`Waiting` },
{ value: JobState.DELAYED, label: t`Delayed` },
{ value: JobState.PRIORITIZED, label: t`Prioritized` },
{ value: JobState.WAITING_CHILDREN, label: t`Waiting Children` },
];
const offset = page * LIMIT;
const { data, loading, refetch } = useGetQueueJobsQuery({
variables: {
queueName,
state: stateFilter,
limit: LIMIT,
offset,
},
fetchPolicy: 'network-only',
});
const { retryJobs, isRetrying } = useRetryJobs(queueName, () => {
refetch();
setSelectedJobIds(new Set());
});
const { deleteJobs, isDeleting } = useDeleteJobs(queueName, () => {
refetch();
setSelectedJobIds(new Set());
});
const jobs = data?.getQueueJobs?.jobs || [];
const hasMore = data?.getQueueJobs?.hasMore || false;
const totalCount = data?.getQueueJobs?.totalCount || 0;
const failedJobs = jobs.filter((job) => job.state === JobState.FAILED);
// Pass retention config to parent when data loads
const shouldPassConfig =
data?.getQueueJobs?.retentionConfig !== undefined &&
onRetentionConfigLoaded !== undefined;
if (shouldPassConfig) {
onRetentionConfigLoaded(data.getQueueJobs.retentionConfig);
}
const selectedCount = selectedJobIds.size;
const allJobsSelected =
jobs.length > 0 && jobs.every((job) => selectedJobIds.has(job.id));
const someJobsSelected =
jobs.some((job) => selectedJobIds.has(job.id)) && !allJobsSelected;
// Check if all selected jobs are failed (for showing retry button)
const selectedJobs = jobs.filter((job) => selectedJobIds.has(job.id));
const allSelectedAreFailed =
selectedJobs.length > 0 &&
selectedJobs.every((job) => job.state === JobState.FAILED);
const handleToggleAll = () => {
const shouldClearSelection = allJobsSelected === true;
if (shouldClearSelection) {
setSelectedJobIds(new Set());
} else {
setSelectedJobIds(new Set(jobs.map((job) => job.id)));
}
};
const handleToggleJob = (event: React.MouseEvent, jobId: string) => {
event.stopPropagation();
const newSelected = new Set(selectedJobIds);
if (newSelected.has(jobId)) {
newSelected.delete(jobId);
} else {
newSelected.add(jobId);
}
setSelectedJobIds(newSelected);
};
const handleRetrySelected = () => {
openModal(RETRY_MODAL_ID);
};
const confirmRetrySelected = async () => {
const jobIdsToRetry = selectedCount > 0 ? Array.from(selectedJobIds) : [];
await retryJobs(jobIdsToRetry);
};
const handleDeleteSelected = () => {
openModal(DELETE_MODAL_ID);
};
const confirmDeleteSelected = async () => {
await deleteJobs(Array.from(selectedJobIds));
};
const handleRetryOne = async (jobId: string) => {
await retryJobs([jobId]);
};
const handleDeleteOne = async (jobId: string) => {
await deleteJobs([jobId]);
};
const handleRowClick = (jobId: string) => {
setExpandedJobId(expandedJobId === jobId ? null : jobId);
};
const formatTimestampRelative = (timestamp?: number | null) => {
if (!timestamp) return '-';
return beautifyPastDateRelativeToNow(timestamp);
};
const formatTimestampFull = (timestamp?: number | null) => {
if (!timestamp) return '';
return new Date(timestamp).toLocaleString();
};
return (
<StyledContainer>
<StyledControlsContainer>
<Select
dropdownId="job-state-filter"
value={stateFilter}
options={jobStateOptions}
onChange={(value) => {
setStateFilter(value as JobState);
setPage(0);
setSelectedJobIds(new Set());
}}
selectSizeVariant="small"
/>
<StyledButtonGroup>
{selectedCount > 0 && (
<Button
Icon={IconTrash}
title={plural(selectedCount, {
one: `Delete ${selectedCount} Job`,
other: `Delete ${selectedCount} Jobs`,
})}
onClick={handleDeleteSelected}
disabled={isDeleting || loading}
size="small"
variant="secondary"
accent="danger"
/>
)}
{allSelectedAreFailed && (
<Button
Icon={IconRefresh}
title={plural(selectedCount, {
one: `Retry ${selectedCount} Job`,
other: `Retry ${selectedCount} Jobs`,
})}
onClick={handleRetrySelected}
disabled={isRetrying || loading}
size="small"
variant="secondary"
/>
)}
{failedJobs.length > 0 && selectedCount === 0 && (
<Button
Icon={IconRefresh}
title={t`Retry All Failed`}
onClick={handleRetrySelected}
disabled={isRetrying || loading}
size="small"
variant="secondary"
/>
)}
</StyledButtonGroup>
</StyledControlsContainer>
{loading && jobs.length === 0 ? (
<StyledEmptyState>{t`Loading jobs...`}</StyledEmptyState>
) : jobs.length === 0 ? (
<StyledEmptyState>{t`No jobs found`}</StyledEmptyState>
) : (
<>
<Table>
<TableRow gridAutoColumns="32px 2fr 1fr 2fr 32px">
<StyledHeaderCheckboxCell>
{jobs.length > 0 && (
<Checkbox
checked={allJobsSelected}
indeterminate={someJobsSelected}
onChange={handleToggleAll}
/>
)}
</StyledHeaderCheckboxCell>
<TableHeader>{t`Job Name`}</TableHeader>
<TableHeader>{t`State`}</TableHeader>
<TableHeader align="right">{t`Timestamp`}</TableHeader>
<TableHeader></TableHeader>
</TableRow>
<TableBody>
{jobs.map((job: QueueJob) => {
const isExpanded = expandedJobId === job.id;
const isSelected = selectedJobIds.has(job.id);
return (
<StyledJobRowWrapper key={job.id}>
<StyledExpandableTableRow
gridAutoColumns="32px 2fr 1fr 2fr 32px"
onClick={() => handleRowClick(job.id)}
isExpanded={isExpanded}
>
<StyledCheckboxCell
onClick={(e) => {
e.stopPropagation();
handleToggleJob(e, job.id);
}}
>
<Checkbox checked={isSelected} />
</StyledCheckboxCell>
<StyledTableCell title={job.name}>
{job.name}
</StyledTableCell>
<TableCell>
<SettingsAdminJobStateBadge
state={job.state}
attemptsMade={job.attemptsMade}
/>
</TableCell>
<TableCell
align="right"
title={formatTimestampFull(
job.finishedOn || job.processedOn || job.timestamp,
)}
>
{formatTimestampRelative(
job.finishedOn || job.processedOn || job.timestamp,
)}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<SettingsAdminQueueJobRowDropdownMenu
jobId={job.id}
jobState={job.state}
onRetry={
job.state === JobState.FAILED
? () => handleRetryOne(job.id)
: undefined
}
onDelete={() => handleDeleteOne(job.id)}
/>
</TableCell>
</StyledExpandableTableRow>
<SettingsAdminJobDetailsExpandable
job={job}
isExpanded={isExpanded}
/>
</StyledJobRowWrapper>
);
})}
</TableBody>
</Table>
<StyledPaginationContainer>
<Button
title={t`Previous`}
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || loading}
size="small"
variant="secondary"
/>
<div>
{t`Page`} {page + 1} {totalCount > 0 ? t`of` : ''}{' '}
{totalCount > 0 ? Math.max(1, Math.ceil(totalCount / LIMIT)) : ''}
</div>
<Button
title={t`Next`}
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore || loading}
size="small"
variant="secondary"
/>
</StyledPaginationContainer>
</>
)}
<SettingsAdminRetryJobsConfirmationModal
modalId={RETRY_MODAL_ID}
jobCount={selectedCount > 0 ? selectedCount : failedJobs.length}
onConfirm={confirmRetrySelected}
/>
<SettingsAdminDeleteJobsConfirmationModal
modalId={DELETE_MODAL_ID}
jobCount={selectedCount}
onConfirm={confirmDeleteSelected}
/>
</StyledContainer>
);
};
@@ -0,0 +1,38 @@
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { plural, t } from '@lingui/core/macro';
type SettingsAdminRetryJobsConfirmationModalProps = {
modalId: string;
jobCount: number;
onConfirm: () => void;
onClose?: () => void;
};
export const SettingsAdminRetryJobsConfirmationModal = ({
modalId,
jobCount,
onConfirm,
onClose,
}: SettingsAdminRetryJobsConfirmationModalProps) => {
const title = plural(jobCount, {
one: `Retry ${jobCount} Job`,
other: `Retry ${jobCount} Jobs`,
});
const subtitle = plural(jobCount, {
one: `This will retry the selected job. It will be re-executed from the beginning.`,
other: `This will retry the selected jobs. They will be re-executed from the beginning.`,
});
return (
<ConfirmationModal
modalId={modalId}
title={title}
subtitle={subtitle}
onConfirmClick={onConfirm}
onClose={onClose}
confirmButtonText={t`Retry`}
confirmButtonAccent="blue"
/>
);
};
@@ -1,4 +1,4 @@
import { WorkerQueueMetricsSection } from '@/settings/admin-panel/health-status/components/WorkerQueueMetricsSection';
import { SettingsAdminWorkerQueueMetricsSection } from '@/settings/admin-panel/health-status/components/SettingsAdminWorkerQueueMetricsSection';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
@@ -10,7 +10,7 @@ const StyledErrorMessage = styled.div`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const WorkerHealthStatus = () => {
export const SettingsAdminWorkerHealthStatus = () => {
const { indicatorHealth } = useContext(SettingsAdminIndicatorHealthContext);
const isWorkerDown =
@@ -25,7 +25,10 @@ export const WorkerHealthStatus = () => {
</StyledErrorMessage>
) : (
(indicatorHealth.queues ?? []).map((queue) => (
<WorkerQueueMetricsSection key={queue.queueName} queue={queue} />
<SettingsAdminWorkerQueueMetricsSection
key={queue.queueName}
queue={queue}
/>
))
)}
</>
@@ -1,5 +1,5 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { WorkerMetricsTooltip } from '@/settings/admin-panel/health-status/components/WorkerMetricsTooltip';
import { SettingsAdminWorkerMetricsTooltip } from '@/settings/admin-panel/health-status/components/SettingsAdminWorkerMetricsTooltip';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@@ -33,16 +33,16 @@ const StyledSettingsAdminTableCard = styled(SettingsAdminTableCard)`
padding-right: ${({ theme }) => theme.spacing(2)};
`;
type WorkerMetricsGraphProps = {
type SettingsAdminWorkerMetricsGraphProps = {
queueName: string;
timeRange: QueueMetricsTimeRange;
onTimeRangeChange: (range: QueueMetricsTimeRange) => void;
};
export const WorkerMetricsGraph = ({
export const SettingsAdminWorkerMetricsGraph = ({
queueName,
timeRange,
}: WorkerMetricsGraphProps) => {
}: SettingsAdminWorkerMetricsGraphProps) => {
const theme = useTheme();
const { enqueueErrorSnackBar } = useSnackBar();
@@ -172,7 +172,9 @@ export const WorkerMetricsGraph = ({
gridYValues={4}
pointSize={0}
enableSlices="x"
sliceTooltip={({ slice }) => <WorkerMetricsTooltip slice={slice} />}
sliceTooltip={({ slice }) => (
<SettingsAdminWorkerMetricsTooltip slice={slice} />
)}
useMesh={true}
legends={[
{
@@ -1,5 +1,5 @@
import styled from '@emotion/styled';
import type { Point, LineSeries } from '@nivo/line';
import type { LineSeries, Point } from '@nivo/line';
import { type ReactElement } from 'react';
const StyledTooltipContainer = styled.div`
@@ -43,15 +43,15 @@ const StyledTooltipValue = styled.span`
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
type WorkerMetricsTooltipProps = {
type SettingsAdminWorkerMetricsTooltipProps = {
slice: {
points: readonly Point<LineSeries>[];
};
};
export const WorkerMetricsTooltip = ({
export const SettingsAdminWorkerMetricsTooltip = ({
slice,
}: WorkerMetricsTooltipProps): ReactElement => {
}: SettingsAdminWorkerMetricsTooltipProps): ReactElement => {
return (
<StyledTooltipContainer>
{slice.points.map((point) => (
@@ -0,0 +1,88 @@
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { WORKER_QUEUE_METRICS_SELECT_OPTIONS } from '@/settings/admin-panel/health-status/constants/WorkerQueueMetricsSelectOptions';
import { Select } from '@/ui/input/components/Select';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { lazy, Suspense, useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconList } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
type AdminPanelWorkerQueueHealth,
QueueMetricsTimeRange,
} from '~/generated-metadata/graphql';
const SettingsAdminWorkerMetricsGraph = lazy(() =>
import('./SettingsAdminWorkerMetricsGraph').then((module) => ({
default: module.SettingsAdminWorkerMetricsGraph,
})),
);
type SettingsAdminWorkerQueueMetricsSectionProps = {
queue: AdminPanelWorkerQueueHealth;
};
const StyledControlsContainer = styled.div`
align-items: flex-start;
display: flex;
justify-content: space-between;
`;
const StyledRightControls = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
export const SettingsAdminWorkerQueueMetricsSection = ({
queue,
}: SettingsAdminWorkerQueueMetricsSectionProps) => {
const [timeRange, setTimeRange] = useState(QueueMetricsTimeRange.OneHour);
return (
<StyledContainer>
<Section>
<StyledControlsContainer>
<H2Title title={queue.queueName} description={t`Queue performance`} />
<StyledRightControls>
<Button
Icon={IconList}
title={t`View Jobs`}
size="small"
variant="secondary"
to={getSettingsPath(SettingsPath.AdminPanelQueueDetail, {
queueName: queue.queueName,
})}
/>
<Select
dropdownId={`timerange-${queue.queueName}`}
value={timeRange}
options={WORKER_QUEUE_METRICS_SELECT_OPTIONS.map((option) => ({
...option,
label: t(option.label),
}))}
onChange={setTimeRange}
needIconCheck
selectSizeVariant="small"
/>
</StyledRightControls>
</StyledControlsContainer>
</Section>
<Suspense fallback={<ChartSkeletonLoader />}>
<SettingsAdminWorkerMetricsGraph
queueName={queue.queueName}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
/>
</Suspense>
</StyledContainer>
);
};
@@ -1,68 +0,0 @@
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { WORKER_QUEUE_METRICS_SELECT_OPTIONS } from '@/settings/admin-panel/health-status/constants/WorkerQueueMetricsSelectOptions';
import { Select } from '@/ui/input/components/Select';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { lazy, Suspense, useState } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
type AdminPanelWorkerQueueHealth,
QueueMetricsTimeRange,
} from '~/generated-metadata/graphql';
const WorkerMetricsGraph = lazy(() =>
import('./WorkerMetricsGraph').then((module) => ({
default: module.WorkerMetricsGraph,
})),
);
type WorkerQueueMetricsSectionProps = {
queue: AdminPanelWorkerQueueHealth;
};
const StyledControlsContainer = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
export const WorkerQueueMetricsSection = ({
queue,
}: WorkerQueueMetricsSectionProps) => {
const [timeRange, setTimeRange] = useState(QueueMetricsTimeRange.OneHour);
return (
<StyledContainer>
<Section>
<StyledControlsContainer>
<H2Title title={queue.queueName} description={t`Queue performance`} />
<Select
dropdownId={`timerange-${queue.queueName}`}
value={timeRange}
options={WORKER_QUEUE_METRICS_SELECT_OPTIONS.map((option) => ({
...option,
label: t(option.label),
}))}
onChange={setTimeRange}
needIconCheck
selectSizeVariant="small"
/>
</StyledControlsContainer>
</Section>
<Suspense fallback={<ChartSkeletonLoader />}>
<WorkerMetricsGraph
queueName={queue.queueName}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
/>
</Suspense>
</StyledContainer>
);
};
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const DELETE_JOBS = gql`
mutation DeleteJobs($queueName: String!, $jobIds: [String!]!) {
deleteJobs(queueName: $queueName, jobIds: $jobIds) {
deletedCount
results {
jobId
success
error
}
}
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const RETRY_JOBS = gql`
mutation RetryJobs($queueName: String!, $jobIds: [String!]!) {
retryJobs(queueName: $queueName, jobIds: $jobIds) {
retriedCount
results {
jobId
success
error
}
}
}
`;
@@ -0,0 +1,41 @@
import { gql } from '@apollo/client';
export const GET_QUEUE_JOBS = gql`
query GetQueueJobs(
$queueName: String!
$state: JobState!
$limit: Int
$offset: Int
) {
getQueueJobs(
queueName: $queueName
state: $state
limit: $limit
offset: $offset
) {
jobs {
id
name
data
state
timestamp
failedReason
processedOn
finishedOn
attemptsMade
returnValue
logs
stackTrace
}
count
totalCount
hasMore
retentionConfig {
completedMaxAge
completedMaxCount
failedMaxAge
failedMaxCount
}
}
}
`;
@@ -0,0 +1,83 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { plural, t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useDeleteJobsMutation } from '~/generated-metadata/graphql';
import { getErrorMessageFromApolloError } from '~/utils/get-error-message-from-apollo-error.util';
export const useDeleteJobs = (queueName: string, onSuccess?: () => void) => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [isDeleting, setIsDeleting] = useState(false);
const [deleteJobsMutation] = useDeleteJobsMutation();
const deleteJobs = async (jobIds: string[]) => {
setIsDeleting(true);
try {
const result = await deleteJobsMutation({
variables: {
queueName,
jobIds,
},
});
const response = result.data?.deleteJobs;
if (isDefined(response)) {
const { deletedCount, results } = response;
const failedResults = results.filter((r) => !r.success);
if (deletedCount > 0) {
if (failedResults.length > 0) {
enqueueSuccessSnackBar({
message: plural(deletedCount, {
one: `Successfully deleted ${deletedCount} job`,
other: `Successfully deleted ${deletedCount} jobs`,
}),
});
enqueueErrorSnackBar({
message: plural(failedResults.length, {
one: `${failedResults.length} job could not be deleted`,
other: `${failedResults.length} jobs could not be deleted`,
}),
});
} else {
enqueueSuccessSnackBar({
message: plural(deletedCount, {
one: `Successfully deleted ${deletedCount} job`,
other: `Successfully deleted ${deletedCount} jobs`,
}),
});
}
onSuccess?.();
} else {
const errorMessages = failedResults
.map((r) => r.error)
.filter(Boolean);
const errorDetails =
errorMessages.length > 0 ? `: ${errorMessages[0]}` : '';
enqueueErrorSnackBar({
message: t`No jobs were deleted${errorDetails}`,
});
}
}
} catch (error) {
enqueueErrorSnackBar({
message:
error instanceof ApolloError
? getErrorMessageFromApolloError(error)
: t`Failed to delete jobs. Please try again later.`,
});
} finally {
setIsDeleting(false);
}
};
return {
deleteJobs,
isDeleting,
};
};
@@ -0,0 +1,87 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { plural, t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useRetryJobsMutation } from '~/generated-metadata/graphql';
import { getErrorMessageFromApolloError } from '~/utils/get-error-message-from-apollo-error.util';
export const useRetryJobs = (queueName: string, onSuccess?: () => void) => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [isRetrying, setIsRetrying] = useState(false);
const [retryJobsMutation] = useRetryJobsMutation();
const retryJobs = async (jobIds: string[]) => {
setIsRetrying(true);
try {
const result = await retryJobsMutation({
variables: {
queueName,
jobIds,
},
});
const response = result.data?.retryJobs;
if (isDefined(response)) {
const { retriedCount, results } = response;
const failedResults = results.filter((r) => !r.success);
if (retriedCount === -1) {
enqueueSuccessSnackBar({
message: t`All failed jobs have been retried`,
});
} else if (retriedCount > 0) {
if (failedResults.length > 0) {
enqueueSuccessSnackBar({
message: plural(retriedCount, {
one: `Successfully retried ${retriedCount} job`,
other: `Successfully retried ${retriedCount} jobs`,
}),
});
enqueueErrorSnackBar({
message: plural(failedResults.length, {
one: `${failedResults.length} job could not be retried`,
other: `${failedResults.length} jobs could not be retried`,
}),
});
} else {
enqueueSuccessSnackBar({
message: plural(retriedCount, {
one: `Successfully retried ${retriedCount} job`,
other: `Successfully retried ${retriedCount} jobs`,
}),
});
}
} else {
const errorMessages = failedResults
.map((r) => r.error)
.filter(Boolean);
const errorDetails =
errorMessages.length > 0 ? `: ${errorMessages[0]}` : '';
enqueueErrorSnackBar({
message: t`No jobs were retried${errorDetails}`,
});
}
onSuccess?.();
}
} catch (error) {
enqueueErrorSnackBar({
message:
error instanceof ApolloError
? getErrorMessageFromApolloError(error)
: t`Failed to retry jobs. Please try again later.`,
});
} finally {
setIsRetrying(false);
}
};
return {
retryJobs,
isRetrying,
};
};