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
@@ -5,7 +5,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
@ObjectType()
@ObjectType('BillingPriceLicensed')
export class BillingPriceLicensedDTO {
@Field(() => SubscriptionInterval)
recurringInterval: SubscriptionInterval;
@@ -6,7 +6,7 @@ import { BillingPriceTierDTO } from 'src/engine/core-modules/billing/dtos/billin
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
@ObjectType()
@ObjectType('BillingPriceMetered')
export class BillingPriceMeteredDTO {
@Field(() => [BillingPriceTierDTO])
tiers: BillingPriceTierDTO[];
@@ -2,7 +2,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
@ObjectType('BillingPriceTier')
export class BillingPriceTierDTO {
@Field(() => Number, { nullable: true })
upTo: number | null;
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class BillingSubscriptionSchedulePhaseItem {
@ObjectType('BillingSubscriptionSchedulePhaseItem')
export class BillingSubscriptionSchedulePhaseItemDTO {
@Field(() => String)
price: string;
@@ -9,14 +9,14 @@ export class BillingSubscriptionSchedulePhaseItem {
quantity?: number;
}
@ObjectType()
export class BillingSubscriptionSchedulePhase {
@ObjectType('BillingSubscriptionSchedulePhase')
export class BillingSubscriptionSchedulePhaseDTO {
@Field(() => Number)
start_date: number;
@Field(() => Number)
end_date: number;
@Field(() => [BillingSubscriptionSchedulePhaseItem])
items: Array<BillingSubscriptionSchedulePhaseItem>;
@Field(() => [BillingSubscriptionSchedulePhaseItemDTO])
items: Array<BillingSubscriptionSchedulePhaseItemDTO>;
}
@@ -4,7 +4,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { Min } from 'class-validator';
@ObjectType()
@ObjectType('BillingTrialPeriod')
export class BillingTrialPeriodDTO {
@Field(() => Number)
@Min(0)
@@ -3,6 +3,7 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import graphqlTypeJson from 'graphql-type-json';
import Stripe from 'stripe';
import {
Column,
@@ -16,16 +17,15 @@ import {
Relation,
UpdateDateColumn,
} from 'typeorm';
import graphqlTypeJson from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
import { BillingSubscriptionItemDTO } from 'src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingSubscriptionSchedulePhase } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
registerEnumType(SubscriptionInterval, { name: 'SubscriptionInterval' });
@@ -122,9 +122,9 @@ export class BillingSubscription {
@Column({ nullable: false, type: 'jsonb', default: {} })
metadata: Stripe.Metadata;
@Field(() => [BillingSubscriptionSchedulePhase])
@Field(() => [BillingSubscriptionSchedulePhaseDTO])
@Column({ nullable: false, type: 'jsonb', default: [] })
phases: Array<BillingSubscriptionSchedulePhase>;
phases: Array<BillingSubscriptionSchedulePhaseDTO>;
@Column({ nullable: true, type: 'timestamptz' })
cancelAt: Date | null;
@@ -3,21 +3,21 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import {
assertIsDefinedOrThrow,
findOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import Stripe from 'stripe';
import { BillingSubscriptionSchedulePhase } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
import { BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
@Injectable()
export class BillingSubscriptionPhaseService {
@@ -28,7 +28,7 @@ export class BillingSubscriptionPhaseService {
private readonly billingPriceService: BillingPriceService,
) {}
async getDetailsFromPhase(phase: BillingSubscriptionSchedulePhase) {
async getDetailsFromPhase(phase: BillingSubscriptionSchedulePhaseDTO) {
const meteredPrice = await this.billingPriceRepository.findOneOrFail({
where: {
stripePriceId: findOrThrow(
@@ -3,56 +3,56 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { differenceInDays } from 'date-fns';
import {
assertIsDefinedOrThrow,
findOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { differenceInDays } from 'date-fns';
import { Not, Repository } from 'typeorm';
import type Stripe from 'stripe';
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-customer.util';
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
import {
getSubscriptionStatus,
transformStripeSubscriptionEventToDatabaseSubscription,
} from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-subscription.util';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { LicensedBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
import { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type';
import { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
import { ensureFutureStartDate } from 'src/engine/core-modules/billing/utils/ensure-future-start-date.util';
import { getOppositeInterval } from 'src/engine/core-modules/billing/utils/get-opposite-interval';
import { getOppositePlan } from 'src/engine/core-modules/billing/utils/get-opposite-plan';
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { LicensedBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
import { BillingSubscriptionSchedulePhase } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
import { getOppositeInterval } from 'src/engine/core-modules/billing/utils/get-opposite-interval';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
import { BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { getOppositePlan } from 'src/engine/core-modules/billing/utils/get-opposite-plan';
import {
getSubscriptionStatus,
transformStripeSubscriptionEventToDatabaseSubscription,
} from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-subscription.util';
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-customer.util';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type';
import { ensureFutureStartDate } from 'src/engine/core-modules/billing/utils/ensure-future-start-date.util';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
@Injectable()
export class BillingSubscriptionService {
@@ -287,7 +287,7 @@ export class BillingSubscriptionService {
const currentPhaseDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
currentEditable as BillingSubscriptionSchedulePhase,
currentEditable as BillingSubscriptionSchedulePhaseDTO,
);
await this.changeMeteredPrice(
@@ -634,12 +634,12 @@ export class BillingSubscriptionService {
}
const currentPhaseDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
currentEditable as BillingSubscriptionSchedulePhase,
currentEditable as BillingSubscriptionSchedulePhaseDTO,
);
const hasNextInitially = !!nextEditable;
const nextPhaseDetailsInitial = hasNextInitially
? await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
nextEditable as BillingSubscriptionSchedulePhaseDTO,
)
: undefined;
const currentCap = (currentPhaseDetails.meteredPrice as BillingMeterPrice)
@@ -734,7 +734,7 @@ export class BillingSubscriptionService {
const hasNext = !!nextEditable;
const nextPhaseDetails = hasNext
? await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
nextEditable as BillingSubscriptionSchedulePhaseDTO,
)
: undefined;
const currentLicensedId = currentSnap
@@ -906,7 +906,7 @@ export class BillingSubscriptionService {
const currentDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
currentEditable as BillingSubscriptionSchedulePhase,
currentEditable as BillingSubscriptionSchedulePhaseDTO,
);
const { nextEditable } = await this.loadScheduleEditable(
billingSubscription.stripeSubscriptionId,
@@ -925,7 +925,7 @@ export class BillingSubscriptionService {
const nextDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
nextEditable as BillingSubscriptionSchedulePhaseDTO,
);
if (nextDetails.interval !== targetInterval) {
@@ -1000,7 +1000,7 @@ export class BillingSubscriptionService {
if (nextEditable && currentEditable) {
const reloadedNextDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
nextEditable as BillingSubscriptionSchedulePhaseDTO,
);
const mappedNext = await this.resolvePrices({
@@ -1048,7 +1048,7 @@ export class BillingSubscriptionService {
const nextDetails = hasNext
? await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
nextEditable as BillingSubscriptionSchedulePhaseDTO,
)
: undefined;
@@ -1099,7 +1099,7 @@ export class BillingSubscriptionService {
const currentDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
currentEditable as BillingSubscriptionSchedulePhase,
currentEditable as BillingSubscriptionSchedulePhaseDTO,
);
const currentPlan = currentDetails.plan.planKey;
@@ -1115,7 +1115,7 @@ export class BillingSubscriptionService {
const nextDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
nextEditable as BillingSubscriptionSchedulePhaseDTO,
);
if (nextDetails.plan.planKey !== targetPlanKey) {
@@ -1181,7 +1181,7 @@ export class BillingSubscriptionService {
if (nextEditable && currentEditable) {
const nextDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
nextEditable as BillingSubscriptionSchedulePhaseDTO,
);
const preservedNextInterval = nextDetails?.interval ?? interval;
@@ -1233,7 +1233,7 @@ export class BillingSubscriptionService {
const nextDetails = hasNext
? await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
nextEditable as BillingSubscriptionSchedulePhaseDTO,
)
: undefined;