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:
+16
-13
@@ -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
|
||||
+38
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -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}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ const HealthStatusIcons: { [k in HealthIndicatorId]: IconComponent } = {
|
||||
[HealthIndicatorId.app]: IconAppWindow,
|
||||
};
|
||||
|
||||
export const SettingsHealthStatusListCard = ({
|
||||
export const SettingsAdminHealthStatusListCard = ({
|
||||
services,
|
||||
loading,
|
||||
}: {
|
||||
+6
-6
@@ -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;
|
||||
|
||||
+140
@@ -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>
|
||||
);
|
||||
};
|
||||
+52
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+69
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+412
@@ -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>
|
||||
);
|
||||
};
|
||||
+38
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+6
-3
@@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
+7
-5
@@ -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={[
|
||||
{
|
||||
+4
-4
@@ -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) => (
|
||||
+88
@@ -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>
|
||||
);
|
||||
};
|
||||
-68
@@ -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>
|
||||
);
|
||||
};
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+41
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+83
@@ -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,
|
||||
};
|
||||
};
|
||||
+87
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user